268 lines
8.9 KiB
Go
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) + ")"
|
|
}
|