package webui import ( "encoding/json" "fmt" "net/http" "regexp" "strings" "time" "mailgoserver/internal/db" "mailgoserver/internal/dkim" "mailgoserver/internal/dnspublish" ) func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func isAjax(r *http.Request) bool { return r.Header.Get("X-Requested-With") == "XMLHttpRequest" || strings.Contains(r.Header.Get("Content-Type"), "application/json") } // dkimList mirrors dkim.py's dkim_list(). func (a *App) dkimList(w http.ResponseWriter, r *http.Request) { active, err := a.DB.ListActiveDKIMKeysWithDomain() if err != nil { setFlash(w, "error", "Error loading DKIM keys") } inactive, _ := a.DB.ListInactiveDKIMKeysWithDomain() publicIP := getPublicIP(a.Cfg) scope := scopeFromContext(r) var dkimData []M for _, k := range active { if !scope.Allowed(k.DomainID) { continue } rec, _ := a.DKIM.GetDKIMPublicKeyRecord(k.DomainName) if rec == nil { rec = &dkim.DNSRecord{} } spfCheck := checkDNSRecord(k.DomainName) existingSPF := "" for _, txt := range spfCheck.Records { if strings.Contains(txt, "v=spf1") { existingSPF = txt break } } dkimData = append(dkimData, M{ "domain": M{"id": k.DomainID, "domain_name": k.DomainName}, "dkim_key": k.DKIMKey, "dns_record": M{"name": rec.Name, "value": rec.Value}, "existing_spf": existingSPF, "recommended_spf": generateSPFRecord(publicIP, existingSPF), "public_ip": publicIP, }) } var oldData []M for _, k := range inactive { if !scope.Allowed(k.DomainID) { continue } status := "Disabled" if k.ReplacedAt != nil { status = "Replaced" } oldData = append(oldData, M{ "domain": M{"id": k.DomainID, "domain_name": k.DomainName}, "dkim_key": k.DKIMKey, "status_text": status, }) } warningDays := a.Cfg.Section("DKIM").Key("key_age_warning_days").MustInt(180) globalKey, _ := a.DKIM.GetActiveGlobalDKIMKey() globalHostname := a.Cfg.Section("DKIM").Key("global_dkim_hostname").String() var globalRecord *dkim.DNSRecord if globalKey != nil && globalHostname != "" { globalRecord, _ = a.DKIM.GlobalDKIMPublicKeyRecord(globalHostname) } a.render(w, r, "dkim.html", M{ "active": "dkim", "dkim_data": dkimData, "old_dkim_data": oldData, "key_age_warning_days": warningDays, "global_key": globalKey, "global_hostname": globalHostname, "global_record": globalRecord, }) } // createDKIM mirrors dkim.py's create_dkim(). func (a *App) createDKIM(w http.ResponseWriter, r *http.Request) { domain := r.FormValue("domain") selector := r.FormValue("selector") if domain == "" { writeJSON(w, http.StatusBadRequest, M{"success": false, "message": "Domain is required"}) return } dom, err := a.DB.GetDomainByNameExact(domain) if err != nil || dom == nil { writeJSON(w, http.StatusNotFound, M{"success": false, "message": "Domain not found"}) return } if !scopeFromContext(r).Allowed(dom.ID) { writeJSON(w, http.StatusNotFound, M{"success": false, "message": "Domain not found"}) return } if err := a.DB.DeactivateActiveDKIMKeysForDomain(dom.ID, time.Now()); err != nil { writeJSON(w, http.StatusInternalServerError, M{"success": false, "message": "Failed to create DKIM key"}) return } ok, publishErr, err := a.DKIM.GenerateAndPublish(domain, selector, true) if err != nil || !ok { writeJSON(w, http.StatusInternalServerError, M{"success": false, "message": "Failed to create DKIM key"}) return } message := "DKIM key created successfully" if publishErr != nil { message = "DKIM key created, but automatic DNS publish failed: " + publishErr.Error() + " — update your DNS manually" } writeJSON(w, http.StatusOK, M{"success": true, "message": message}) } // regenerateDKIM mirrors dkim.py's regenerate_dkim() — path id is a DOMAIN id. func (a *App) regenerateDKIM(w http.ResponseWriter, r *http.Request) { domainID := pathID(r) dom, err := a.DB.GetDomainByID(domainID) if err != nil || dom == nil { http.NotFound(w, r) return } if !requireDomainAccess(w, r, dom.ID) { return } current, _ := a.DB.GetActiveDKIMKeyByDomainID(domainID) selector := "" if current != nil { selector = current.Selector } if err := a.DB.DeactivateActiveDKIMKeysForDomain(domainID, time.Now()); err != nil { a.dkimActionFailed(w, r, "Failed to regenerate DKIM key") return } ok, publishErr, err := a.DKIM.GenerateAndPublish(dom.DomainName, selector, true) if err != nil || !ok { a.dkimActionFailed(w, r, "Failed to regenerate DKIM key") return } message := "DKIM key regenerated successfully" if publishErr != nil { message = "DKIM key regenerated, but automatic DNS publish failed: " + publishErr.Error() + " — update your DNS manually" } if isAjax(r) { publicIP := getPublicIP(a.Cfg) rec, _ := a.DKIM.GetDKIMPublicKeyRecord(dom.DomainName) spfCheck := checkDNSRecord(dom.DomainName) existingSPF := "" for _, txt := range spfCheck.Records { if strings.Contains(txt, "v=spf1") { existingSPF = txt break } } newKey, _ := a.DB.GetActiveDKIMKeyByDomainID(domainID) writeJSON(w, http.StatusOK, M{ "success": true, "message": message, "new_key": newKey, "dns_record": rec, "existing_spf": existingSPF, "recommended_spf": generateSPFRecord(publicIP, existingSPF), "public_ip": publicIP, "domain": dom.DomainName, }) return } category := "success" if publishErr != nil { category = "error" } setFlash(w, category, message) http.Redirect(w, r, Prefix+"/dkim", http.StatusFound) } func (a *App) dkimActionFailed(w http.ResponseWriter, r *http.Request, message string) { if isAjax(r) { writeJSON(w, http.StatusInternalServerError, M{"success": false, "message": message}) return } setFlash(w, "error", message) http.Redirect(w, r, Prefix+"/dkim", http.StatusFound) } func (a *App) editDKIMForm(w http.ResponseWriter, r *http.Request) { key, ok := a.dkimKeyWithAccess(w, r) if !ok { return } dom, _ := a.DB.GetDomainByID(key.DomainID) dnsCreds, _ := a.DB.GetDomainDNSCredentials(key.DomainID) a.render(w, r, "edit_dkim.html", M{"active": "dkim", "dkim_key": key, "domain": dom, "dns_creds": dnsCreds}) } var selectorPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) // dkimKeyWithAccess fetches a DKIM key by path ID and confirms it belongs to a domain // the current admin can manage. func (a *App) dkimKeyWithAccess(w http.ResponseWriter, r *http.Request) (key *db.DKIMKey, ok bool) { key, err := a.DB.GetDKIMKeyByID(pathID(r)) if err != nil || key == nil { http.NotFound(w, r) return nil, false } if !requireDomainAccess(w, r, key.DomainID) { return nil, false } return key, true } // editDKIM mirrors dkim.py's edit_dkim() POST branch. func (a *App) editDKIM(w http.ResponseWriter, r *http.Request) { key, ok := a.dkimKeyWithAccess(w, r) if !ok { return } id := key.ID dom, _ := a.DB.GetDomainByID(key.DomainID) selector := strings.TrimSpace(r.FormValue("selector")) if selector == "" || !selectorPattern.MatchString(selector) { setFlash(w, "error", "Selector must contain only letters, numbers, hyphens, and underscores") a.render(w, r, "edit_dkim.html", M{"active": "dkim", "dkim_key": key, "domain": dom}) return } if exists, _ := a.DB.SelectorExistsForDomain(key.DomainID, selector, id); exists { setFlash(w, "error", "This selector is already in use for this domain") a.render(w, r, "edit_dkim.html", M{"active": "dkim", "dkim_key": key, "domain": dom}) return } if err := a.DB.UpdateDKIMKeySelector(id, selector); err != nil { setFlash(w, "error", "Error updating selector") http.Redirect(w, r, Prefix+"/dkim", http.StatusFound) return } setFlash(w, "success", "DKIM selector updated successfully") http.Redirect(w, r, Prefix+"/dkim", http.StatusFound) } // toggleDKIM mirrors dkim.py's toggle_dkim(). func (a *App) toggleDKIM(w http.ResponseWriter, r *http.Request) { key, ok := a.dkimKeyWithAccess(w, r) if !ok { return } id := key.ID newActive := !key.IsActive if newActive { if err := a.DB.DeactivateActiveDKIMKeysForDomain(key.DomainID, time.Now()); err != nil { a.dkimActionFailed(w, r, "Error toggling DKIM status") return } } if err := a.DB.SetDKIMKeyActive(id, newActive, time.Now()); err != nil { a.dkimActionFailed(w, r, "Error toggling DKIM status") return } message := "DKIM key disabled" if newActive { message = "DKIM key enabled" } if isAjax(r) { writeJSON(w, http.StatusOK, M{"success": true, "message": message, "is_active": newActive}) return } setFlash(w, "success", message) http.Redirect(w, r, Prefix+"/dkim", http.StatusFound) } func (a *App) removeDKIM(w http.ResponseWriter, r *http.Request) { key, ok := a.dkimKeyWithAccess(w, r) if !ok { return } if err := a.DB.RemoveDKIMKey(key.ID); err != nil { setFlash(w, "error", "Error removing DKIM key") } else { setFlash(w, "success", "DKIM key permanently removed") } http.Redirect(w, r, Prefix+"/dkim", http.StatusFound) } // dkimDNSAutomationProviders are the DNS providers dnspublish.SetTXTRecord supports — // shared by both the per-domain automation form here and the settings-page provider // select (mirrors internal/acmecert's fixed [LetsEncrypt] provider set). var dkimDNSAutomationProviders = map[string]bool{"cloudflare": true, "route53": true, "digitalocean": true, "gcloud": true} // dkimDNSAutomationUpdate saves a domain's dkim_dns_automation mode and (if provided) // its DNS provider credentials for GenerateAndPublish to use on the next // create/regenerate. Routed as /dkim/{id}/dns-automation with a KEY id (not a domain // id) — same as editDKIM/toggleDKIM above, and the page it's embedded in // (edit_dkim.html, reached via that same key id) — unlike regenerateDKIM's // /dkim/{id}/regenerate, which is the one DKIM route keyed by domain id instead. func (a *App) dkimDNSAutomationUpdate(w http.ResponseWriter, r *http.Request) { key, ok := a.dkimKeyWithAccess(w, r) if !ok { return } domainID := key.DomainID back := fmt.Sprintf("%s/dkim/%d/edit", Prefix, key.ID) dom, err := a.DB.GetDomainByID(domainID) if err != nil || dom == nil { http.NotFound(w, r) return } mode := r.FormValue("dkim_dns_automation") if mode != "manual" && mode != "automatic" { setFlash(w, "error", "Invalid DNS automation mode") http.Redirect(w, r, back, http.StatusFound) return } if err := a.DB.SetDomainDKIMDNSAutomation(domainID, mode); err != nil { setFlash(w, "error", "Error saving DNS automation setting") http.Redirect(w, r, back, http.StatusFound) return } provider := r.FormValue("dns_provider") if provider != "" { if !dkimDNSAutomationProviders[provider] { setFlash(w, "error", "Unrecognized DNS provider") http.Redirect(w, r, back, http.StatusFound) return } zoneName := strings.TrimSpace(r.FormValue("zone_name")) if zoneName == "" { zoneName = dom.DomainName } creds := db.DomainDNSCredentials{ DomainID: domainID, Provider: provider, ZoneName: zoneName, CloudflareAPIToken: r.FormValue("cloudflare_api_token"), Route53AccessKeyID: r.FormValue("route53_access_key_id"), Route53SecretAccessKey: r.FormValue("route53_secret_access_key"), Route53Region: r.FormValue("route53_region"), DigitalOceanAPIToken: r.FormValue("digitalocean_api_token"), GCloudProject: r.FormValue("gcloud_project"), GCloudServiceAccountJSON: r.FormValue("gcloud_service_account_json"), } if err := a.DB.SetDomainDNSCredentials(creds); err != nil { setFlash(w, "error", "Error saving DNS provider credentials") http.Redirect(w, r, back, http.StatusFound) return } } setFlash(w, "success", "DNS automation settings saved") http.Redirect(w, r, back, http.StatusFound) } // globalDKIMCredsFromConfig builds dnspublish.Credentials from [DKIM]'s // global_dkim_* settings, or nil if no provider is configured — mirrors // internal/acmecert's buildDNSProvider, just against dnspublish's generic interface // instead of lego's ACME-challenge-shaped one (see internal/dnspublish's doc comment // for why those can't be reused for each other). func (a *App) globalDKIMCredsFromConfig() *dnspublish.Credentials { sec := a.Cfg.Section("DKIM") provider := sec.Key("global_dkim_provider").String() if provider == "" { return nil } return &dnspublish.Credentials{ Provider: provider, ZoneName: sec.Key("global_dkim_hostname").String(), CloudflareAPIToken: sec.Key("global_dkim_cloudflare_api_token").String(), Route53AccessKeyID: sec.Key("global_dkim_route53_access_key_id").String(), Route53SecretAccessKey: sec.Key("global_dkim_route53_secret_access_key").String(), Route53Region: sec.Key("global_dkim_route53_region").String(), DigitalOceanAPIToken: sec.Key("global_dkim_digitalocean_api_token").String(), GCloudProject: sec.Key("global_dkim_gcloud_project").String(), GCloudServiceAccountJSON: sec.Key("global_dkim_gcloud_service_account_json").String(), } } // regenerateGlobalDKIM (re)generates the one shared/global DKIM key — global-admin // only (wired via requireGlobalAdmin at the route), since it's server-wide, not // attributable to any one domain, same reasoning as /backups and /monitoring. Routed // as the parameter-free /dkim/global-key/regenerate (not /dkim/{id}/regenerate's // {id}-shaped sibling) to avoid any ambiguity with that domain-id route. func (a *App) regenerateGlobalDKIM(w http.ResponseWriter, r *http.Request) { hostname := a.Cfg.Section("DKIM").Key("global_dkim_hostname").String() publishErr, err := a.DKIM.GenerateGlobalDKIMKey(hostname, a.globalDKIMCredsFromConfig()) if err != nil { setFlash(w, "error", "Failed to regenerate the shared DKIM key") http.Redirect(w, r, Prefix+"/dkim", http.StatusFound) return } if publishErr != nil { setFlash(w, "error", "Shared DKIM key regenerated, but automatic DNS publish failed: "+publishErr.Error()+" — update your DNS manually") } else { setFlash(w, "success", "Shared DKIM key regenerated successfully") } http.Redirect(w, r, Prefix+"/dkim", http.StatusFound) } // dkimUseGlobalUpdate saves whether this domain signs with the shared/global DKIM key // instead of its own — routed by KEY id, same as dkimDNSAutomationUpdate above. func (a *App) dkimUseGlobalUpdate(w http.ResponseWriter, r *http.Request) { key, ok := a.dkimKeyWithAccess(w, r) if !ok { return } back := fmt.Sprintf("%s/dkim/%d/edit", Prefix, key.ID) if err := a.DB.SetDomainUseGlobalDKIM(key.DomainID, r.FormValue("use_global_dkim") == "on"); err != nil { setFlash(w, "error", "Error saving setting") http.Redirect(w, r, back, http.StatusFound) return } setFlash(w, "success", "Setting saved") http.Redirect(w, r, back, http.StatusFound) } // checkDKIMDNS mirrors dkim.py's check_dkim_dns(). func (a *App) checkDKIMDNS(w http.ResponseWriter, r *http.Request) { domain := r.FormValue("domain") selector := r.FormValue("selector") rec, err := a.DKIM.GetDKIMPublicKeyRecord(domain) if err != nil || rec == nil { writeJSON(w, http.StatusOK, M{"success": false, "message": "No active DKIM key for domain"}) return } dnsName := selector + "._domainkey." + domain result := checkDNSRecord(dnsName) found := false expected := strings.Trim(rec.Value, `"`) for _, txt := range result.Records { if strings.Contains(txt, expected) || strings.Contains(expected, txt) { found = true break } } message := "DKIM record not found or does not match" if found { message = "DKIM record found and matches" } else if !result.Success { message = result.Message } writeJSON(w, http.StatusOK, M{"success": found, "message": message, "records": result.Records}) } // checkSPFDNS mirrors dkim.py's check_spf_dns(). func (a *App) checkSPFDNS(w http.ResponseWriter, r *http.Request) { domain := r.FormValue("domain") result := checkDNSRecord(domain) var spfRecord string for _, txt := range result.Records { if strings.Contains(txt, "v=spf1") { spfRecord = txt break } } publicIP := getPublicIP(a.Cfg) validForServer := spfRecord != "" && strings.Contains(spfRecord, "ip4:"+publicIP) message := "SPF record not found" if spfRecord != "" { message = "SPF record found" } writeJSON(w, http.StatusOK, M{ "success": spfRecord != "", "message": message, "records": result.Records, "spf_record": spfRecord, "spf_valid_for_server": validForServer, "spf_check_message": message, "public_ip": publicIP, }) }