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) + ")" }