Files
mailgoserver/internal/webui/domains.go
T

268 lines
8.9 KiB
Go

package webui
import (
"net/http"
"regexp"
"strconv"
"strings"
"mailgoserver/internal/db"
)
func pathID(r *http.Request) int64 {
return int64(atoi(r.PathValue("id")))
}
// validDomainName requires a real, multi-label FQDN (e.g. "example.com" or
// "mail.example.com") — a bare word like "zczsdc" has no dot and is rejected.
var validDomainName = regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$`)
func isValidDomainName(name string) bool {
return len(name) <= 253 && validDomainName.MatchString(name)
}
// domainsList mirrors domains.py's domains_list(), scoped to the current admin's
// assigned domains unless they're a global admin.
func (a *App) domainsList(w http.ResponseWriter, r *http.Request) {
allDomains, err := a.DB.ListDomains()
if err != nil {
setFlash(w, "error", "Error loading domains")
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
return
}
scope := scopeFromContext(r)
var domains []db.Domain
var rows []M
for _, d := range allDomains {
if !scope.Allowed(d.ID) {
continue
}
domains = append(domains, d)
senderCount, _ := a.DB.CountSendersForDomain(d.ID)
hasActiveDKIM, _ := a.DB.HasActiveDKIMForDomain(d.ID)
hasAnyDKIM, _ := a.DB.HasAnyDKIMForDomain(d.ID)
rows = append(rows, M{
"domain": d, "sender_count": senderCount,
"has_active_dkim": hasActiveDKIM, "has_any_dkim": hasAnyDKIM,
})
}
a.render(w, r, "domains.html", M{"active": "domains", "domains": domains, "rows": rows})
}
func (a *App) addDomainForm(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "add_domain.html", M{"active": "domains"})
}
// addDomain mirrors domains.py's add_domain() POST branch.
func (a *App) addDomain(w http.ResponseWriter, r *http.Request) {
name := strings.ToLower(strings.TrimSpace(r.FormValue("domain_name")))
if !isValidDomainName(name) {
setFlash(w, "error", "Please enter a real domain name, e.g. example.com or mail.example.com")
http.Redirect(w, r, Prefix+"/domains/add", http.StatusFound)
return
}
existing, _ := a.DB.GetDomainByNameExact(name)
if existing != nil {
setFlash(w, "error", "Domain already exists")
http.Redirect(w, r, Prefix+"/domains/add", http.StatusFound)
return
}
newID, err := a.DB.CreateDomain(name)
if err != nil {
setFlash(w, "error", "Error adding domain")
http.Redirect(w, r, Prefix+"/domains/add", http.StatusFound)
return
}
// A scoped admin who creates a domain automatically gets management access to it
// — otherwise they'd create a domain and immediately lose the ability to see it.
if scope := scopeFromContext(r); !scope.Global {
if err := a.DB.GrantDomainAccess(userFromContext(r).ID, newID); err != nil {
a.Logger.Error("grant domain access after create: %v", err)
}
}
if _, err := a.DKIM.GenerateDKIMKeypair(name, "", false); err != nil {
a.Logger.Error("DKIM generation for %s failed: %v", name, err)
}
setFlash(w, "success", "Domain added successfully")
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
}
// toggleDomainOff mirrors domains.py's delete_domain(): unconditional soft-disable.
func (a *App) toggleDomainOff(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
if !requireDomainAccess(w, r, id) {
return
}
if err := a.DB.SetDomainActive(id, false); err != nil {
setFlash(w, "error", "Error disabling domain")
} else {
setFlash(w, "success", "Domain disabled")
}
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
}
func (a *App) editDomainForm(w http.ResponseWriter, r *http.Request) {
dom, err := a.DB.GetDomainByID(pathID(r))
if err != nil || dom == nil {
http.NotFound(w, r)
return
}
if !requireDomainAccess(w, r, dom.ID) {
return
}
a.render(w, r, "edit_domain.html", M{"active": "domains", "domain": dom})
}
// editDomain mirrors domains.py's edit_domain() POST branch.
func (a *App) editDomain(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
dom, err := a.DB.GetDomainByID(id)
if err != nil || dom == nil {
http.NotFound(w, r)
return
}
if !requireDomainAccess(w, r, dom.ID) {
return
}
name := strings.ToLower(strings.TrimSpace(r.FormValue("domain_name")))
requiresAuth := r.FormValue("requires_auth") == "on"
if !isValidDomainName(name) {
setFlash(w, "error", "Please enter a real domain name, e.g. example.com or mail.example.com")
a.render(w, r, "edit_domain.html", M{"active": "domains", "domain": dom})
return
}
if other, _ := a.DB.GetDomainByNameExact(name); other != nil && other.ID != id {
setFlash(w, "error", "Domain already exists")
a.render(w, r, "edit_domain.html", M{"active": "domains", "domain": dom})
return
}
if err := a.DB.UpdateDomain(id, name, requiresAuth); err != nil {
setFlash(w, "error", "Error updating domain")
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
return
}
if err := a.DB.SetDomainMFAExempt(id, r.FormValue("mfa_exempt") == "on"); err != nil {
setFlash(w, "error", "Error updating domain")
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
return
}
setFlash(w, "success", "Domain updated successfully")
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
}
// toggleDomain mirrors domains.py's toggle_domain().
func (a *App) toggleDomain(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
dom, err := a.DB.GetDomainByID(id)
if err != nil || dom == nil {
http.NotFound(w, r)
return
}
if !requireDomainAccess(w, r, dom.ID) {
return
}
if err := a.DB.SetDomainActive(id, !dom.IsActive); err != nil {
setFlash(w, "error", "Error updating domain status")
} else if dom.IsActive {
setFlash(w, "success", "Domain disabled")
} else {
setFlash(w, "success", "Domain enabled")
}
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
}
// removeDomain mirrors domains.py's remove_domain(): hard delete + cascade.
func (a *App) removeDomain(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
dom, err := a.DB.GetDomainByID(id)
if err != nil || dom == nil {
http.NotFound(w, r)
return
}
if !requireDomainAccess(w, r, dom.ID) {
return
}
senders, ips, keys, headers, err := a.DB.RemoveDomainCascade(id)
if err != nil {
setFlash(w, "error", "Error removing domain")
} else {
setFlash(w, "success", domainRemovedMessage(dom.DomainName, senders, ips, keys, headers))
}
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
}
// verifyDomainCheck queries the domain's DNS TXT ownership record via 1.1.1.1 and
// 8.8.8.8 and marks it verified if found, mirroring the DKIM/SPF "Check DNS" pattern.
func (a *App) verifyDomainCheck(w http.ResponseWriter, r *http.Request) {
id := pathID(r)
dom, err := a.DB.GetDomainByID(id)
if err != nil || dom == nil {
http.NotFound(w, r)
return
}
if !requireDomainAccess(w, r, dom.ID) {
return
}
verified, records, err := checkDomainOwnership(dom.DomainName, dom.VerificationToken)
if err != nil {
writeJSON(w, http.StatusOK, M{"success": false, "message": "DNS lookup failed: " + err.Error()})
return
}
if verified {
if err := a.DB.SetDomainVerified(id, true); err != nil {
writeJSON(w, http.StatusOK, M{"success": false, "message": "Verified via DNS but failed to save: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, M{"success": true, "message": "Domain ownership verified — it can now send mail.", "records": records})
return
}
writeJSON(w, http.StatusOK, M{"success": false, "message": "TXT record not found or doesn't match yet. DNS changes can take a while to propagate.", "records": records})
}
// accessibleDomains returns the active domains the current admin can pick from in a
// dropdown (add/edit sender, IP, DKIM forms) — all of them for a global admin, only
// their assigned ones for a scoped admin.
func (a *App) accessibleDomains(r *http.Request) ([]db.Domain, error) {
all, err := a.DB.ListActiveDomains()
if err != nil {
return nil, err
}
scope := scopeFromContext(r)
if scope.Global {
return all, nil
}
var out []db.Domain
for _, d := range all {
if scope.Allowed(d.ID) {
out = append(out, d)
}
}
return out, nil
}
// accessibleDomainNames is accessibleDomains's counterpart for tables that store the
// domain as text (mail_from, auth log identifiers) rather than a domain_id — email
// logs and auth logs, which predate per-domain admin scoping. isGlobal=true means
// "don't filter, they can see everything" and names will be nil.
func (a *App) accessibleDomainNames(r *http.Request) (names map[string]bool, isGlobal bool, err error) {
scope := scopeFromContext(r)
if scope.Global {
return nil, true, nil
}
domains, err := a.DB.ListDomains()
if err != nil {
return nil, false, err
}
names = make(map[string]bool)
for _, d := range domains {
if scope.Allowed(d.ID) {
names[strings.ToLower(d.DomainName)] = true
}
}
return names, false, nil
}
func domainRemovedMessage(name string, senders, ips, keys, headers int) string {
return "Domain " + name + " and associated records removed (senders: " + strconv.Itoa(senders) + ", IPs: " + strconv.Itoa(ips) + ", DKIM keys: " + strconv.Itoa(keys) + ", custom headers: " + strconv.Itoa(headers) + ")"
}