Files
mailgosend/internal/webadmin/handlers.go
T
2026-05-24 17:15:48 +00:00

1051 lines
29 KiB
Go

package webadmin
import (
"context"
cryptoRand "crypto/rand"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"strconv"
"strings"
"time"
appCrypto "ghb.freebede.com/nahakubuilder/mailgosend/internal/crypto"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/db"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/dkim"
"ghb.freebede.com/nahakubuilder/mailgosend/internal/models"
)
// ---- Dashboard ----
type dashboardData struct {
basePage
Stats *db.AdminStats
}
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
stats, err := s.deps.DB.GetAdminStats(ctx)
if err != nil {
log.Printf("[admin] stats: %v", err)
stats = &db.AdminStats{}
}
flash, errMsg := flashFrom(r)
s.render(w, "dashboard", dashboardData{
basePage: s.newBase(r, flash, errMsg),
Stats: stats,
})
}
// ---- Domains ----
type domainsData struct {
basePage
Domains []*models.Domain
}
func (s *Server) domainsList(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
doms, err := s.deps.DB.ListDomains(ctx)
if err != nil {
log.Printf("[admin] list domains: %v", err)
}
flash, errMsg := flashFrom(r)
s.render(w, "domains", domainsData{
basePage: s.newBase(r, flash, errMsg),
Domains: doms,
})
}
func (s *Server) domainsCreate(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if !s.validateCSRF(w, r) {
return
}
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
selector := strings.TrimSpace(r.FormValue("selector"))
algo := r.FormValue("algo")
if !validDomain(name) {
redirect(w, r, "/admin/domains", "", "Invalid domain name.")
return
}
if selector == "" {
selector = "godkim"
}
if !validIdentifier(selector) {
redirect(w, r, "/admin/domains", "", "Invalid DKIM selector.")
return
}
if algo != "rsa2048" && algo != "ed25519" {
algo = "rsa2048"
}
domID, err := s.deps.DB.CreateDomain(ctx, name, selector, algo)
if err != nil {
log.Printf("[admin] create domain: %v", err)
redirect(w, r, "/admin/domains", "", "Failed to create domain.")
return
}
// Auto-generate DKIM key pair.
if err := s.generateDKIM(ctx, domID, algo, selector); err != nil {
log.Printf("[admin] dkim keygen: %v", err)
}
redirect(w, r, fmt.Sprintf("/admin/domains/%d", domID), "Domain created.", "")
}
type domainDetailData struct {
basePage
Domain *models.Domain
Users []*models.User
Hostname string
DMARCReportCount int
// DNS records (what to configure)
MXRecord string
DKIMRecord string
SPFHint string
DMARCHint string
AutoconfigCNAME string
AutodiscoverCNAME string
SMTPSRVRecord string
IMAPSRVRecord string
}
func (s *Server) domainDetail(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
id := pathID(r, "id")
dom, err := s.deps.DB.GetDomainByID(ctx, id)
if err != nil || dom == nil {
http.NotFound(w, r)
return
}
users, _ := s.deps.DB.ListUsers(ctx, id)
flash, errMsg := flashFrom(r)
hostname := s.deps.Cfg.Hostname
if hostname == "" || hostname == "mail.example.com" {
hostname = "mail." + dom.Name
}
dkimRec := ""
if dom.DKIMPublic != "" {
pub := strings.ReplaceAll(dom.DKIMPublic, "-----BEGIN PUBLIC KEY-----", "")
pub = strings.ReplaceAll(pub, "-----END PUBLIC KEY-----", "")
pub = strings.ReplaceAll(pub, "\n", "")
pub = strings.TrimSpace(pub)
dkimRec = fmt.Sprintf(`%s._domainkey.%s IN TXT "v=DKIM1; k=%s; p=%s"`,
dom.DKIMSelector, dom.Name, dkimAlgoKey(dom.DKIMAlgo), pub)
}
// Build DMARC hint — use monitoring address when configured.
dmarcHint := ""
if dom.DMARCRua != "" {
dmarcHint = fmt.Sprintf(`_dmarc.%s IN TXT "v=DMARC1; p=quarantine; rua=mailto:%s"`, dom.Name, dom.DMARCRua)
} else {
dmarcHint = fmt.Sprintf(`_dmarc.%s IN TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@%s"`, dom.Name, dom.Name)
}
dmarcCount, _ := s.deps.DB.DMARCReportCount(ctx, id)
s.render(w, "domain", domainDetailData{
basePage: s.newBase(r, flash, errMsg),
Domain: dom,
Users: users,
Hostname: hostname,
DMARCReportCount: dmarcCount,
MXRecord: fmt.Sprintf(`%s IN MX 10 %s.`, dom.Name, hostname),
DKIMRecord: dkimRec,
SPFHint: fmt.Sprintf(`%s IN TXT "v=spf1 a mx ~all"`, dom.Name),
DMARCHint: dmarcHint,
AutoconfigCNAME: fmt.Sprintf(`autoconfig.%s IN CNAME %s.`, dom.Name, hostname),
AutodiscoverCNAME: fmt.Sprintf(`autodiscover.%s IN CNAME %s.`, dom.Name, hostname),
SMTPSRVRecord: fmt.Sprintf(`_submission._tcp.%s IN SRV 0 1 587 %s.`, dom.Name, hostname),
IMAPSRVRecord: fmt.Sprintf(`_imaps._tcp.%s IN SRV 0 1 993 %s.`, dom.Name, hostname),
})
}
// ---- DNS check ----
type dnsCheckItem struct {
Status string `json:"status"` // "ok" | "warn" | "error" | "missing"
Found string `json:"found"`
Message string `json:"message"`
}
type dnsCheckResult struct {
SPF dnsCheckItem `json:"spf"`
MX dnsCheckItem `json:"mx"`
DKIM dnsCheckItem `json:"dkim"`
DMARC dnsCheckItem `json:"dmarc"`
}
func (s *Server) domainDNSCheck(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
id := pathID(r, "id")
dom, err := s.deps.DB.GetDomainByID(ctx, id)
if err != nil || dom == nil {
http.NotFound(w, r)
return
}
result := dnsCheckResult{
SPF: dnsSPFCheck(dom.Name),
MX: dnsMXCheck(dom.Name),
DKIM: dnsDKIMCheck(dom.DKIMSelector, dom.Name),
DMARC: dnsDMARCCheck(dom.Name),
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(result); err != nil {
log.Printf("[admin] dnscheck encode: %v", err)
}
}
func dnsSPFCheck(domain string) dnsCheckItem {
txts, err := net.LookupTXT(domain)
if err != nil {
return dnsCheckItem{"missing", "", "DNS lookup failed: " + err.Error()}
}
for _, txt := range txts {
if strings.HasPrefix(strings.TrimSpace(txt), "v=spf1") {
return dnsCheckItem{"ok", txt, "SPF record found"}
}
}
return dnsCheckItem{"error", "", "No SPF TXT record found. Add: v=spf1 a mx ~all"}
}
func dnsMXCheck(domain string) dnsCheckItem {
mxs, err := net.LookupMX(domain)
if err != nil || len(mxs) == 0 {
return dnsCheckItem{"warn", "", "No MX record found (optional but strongly recommended)"}
}
var parts []string
for _, mx := range mxs {
parts = append(parts, fmt.Sprintf("%d %s", mx.Pref, mx.Host))
}
return dnsCheckItem{"ok", strings.Join(parts, ", "), "MX record found"}
}
func dnsDKIMCheck(selector, domain string) dnsCheckItem {
if selector == "" {
return dnsCheckItem{"warn", "", "No DKIM selector configured for this domain"}
}
name := selector + "._domainkey." + domain
txts, err := net.LookupTXT(name)
if err != nil || len(txts) == 0 {
return dnsCheckItem{"error", "", "DKIM TXT record not found at " + name}
}
rec := strings.Join(txts, "")
if strings.Contains(rec, "v=DKIM1") {
return dnsCheckItem{"ok", rec, "DKIM record found at " + name}
}
return dnsCheckItem{"warn", rec, "TXT record found but missing v=DKIM1 tag at " + name}
}
func dnsDMARCCheck(domain string) dnsCheckItem {
txts, err := net.LookupTXT("_dmarc." + domain)
if err != nil || len(txts) == 0 {
return dnsCheckItem{"warn", "", "No DMARC record found (recommended for email reputation)"}
}
for _, txt := range txts {
if strings.HasPrefix(strings.TrimSpace(txt), "v=DMARC1") {
return dnsCheckItem{"ok", txt, "DMARC record found"}
}
}
return dnsCheckItem{"warn", "", "No valid v=DMARC1 record found at _dmarc." + domain}
}
func (s *Server) domainToggleEnable(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if !s.validateCSRF(w, r) {
return
}
id := pathID(r, "id")
enabled := r.FormValue("enabled") == "1"
if err := s.deps.DB.SetDomainEnabled(ctx, id, enabled); err != nil {
log.Printf("[admin] domain enable: %v", err)
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Update failed.")
return
}
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "Domain updated.", "")
}
func (s *Server) domainGenDKIM(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
if !s.validateCSRF(w, r) {
return
}
id := pathID(r, "id")
dom, err := s.deps.DB.GetDomainByID(ctx, id)
if err != nil || dom == nil {
http.NotFound(w, r)
return
}
algo := r.FormValue("algo")
if algo != "rsa2048" && algo != "ed25519" {
algo = dom.DKIMAlgo
}
if algo == "" {
algo = "rsa2048"
}
if err := s.generateDKIM(ctx, id, algo, dom.DKIMSelector); err != nil {
log.Printf("[admin] dkim regen: %v", err)
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "DKIM generation failed.")
return
}
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "DKIM key regenerated. Update your DNS TXT record.", "")
}
func (s *Server) domainSetLimits(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if !s.validateCSRF(w, r) {
return
}
id := pathID(r, "id")
maxUsers, _ := strconv.Atoi(r.FormValue("max_users"))
maxQuotaMB, _ := strconv.ParseInt(r.FormValue("max_quota_mb"), 10, 64)
if maxUsers < 0 {
maxUsers = 0
}
if maxQuotaMB < 0 {
maxQuotaMB = 0
}
if err := s.deps.DB.SetDomainLimits(ctx, id, maxUsers, maxQuotaMB*1024*1024); err != nil {
log.Printf("[admin] domain limits: %v", err)
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Update failed.")
return
}
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "Limits updated.", "")
}
func (s *Server) domainDelete(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if !s.validateCSRF(w, r) {
return
}
id := pathID(r, "id")
if err := s.deps.DB.DeleteDomain(ctx, id); err != nil {
log.Printf("[admin] delete domain: %v", err)
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Delete failed.")
return
}
redirect(w, r, "/admin/domains", "Domain deleted.", "")
}
// ---- Users ----
type usersData struct {
basePage
Users []*db.UserWithDomain
Domains []*models.Domain
}
func (s *Server) usersList(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
users, err := s.deps.DB.ListAllUsers(ctx)
if err != nil {
log.Printf("[admin] list users: %v", err)
}
doms, _ := s.deps.DB.ListDomains(ctx)
flash, errMsg := flashFrom(r)
s.render(w, "users", usersData{
basePage: s.newBase(r, flash, errMsg),
Users: users,
Domains: doms,
})
}
func (s *Server) usersCreate(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if !s.validateCSRF(w, r) {
return
}
domainID, _ := strconv.ParseInt(r.FormValue("domain_id"), 10, 64)
username := strings.ToLower(strings.TrimSpace(r.FormValue("username")))
password := r.FormValue("password")
displayName := strings.TrimSpace(r.FormValue("display_name"))
quotaMB, _ := strconv.ParseInt(r.FormValue("quota_mb"), 10, 64)
domainAdmin := r.FormValue("domain_admin") == "1"
if domainID <= 0 || !validUsername(username) || len(password) < 8 || len(password) > 1024 {
redirect(w, r, "/admin/users", "", "Invalid input. Username must be alphanumeric, password min 8 chars.")
return
}
if quotaMB <= 0 {
quotaMB = 1024 // 1 GB default
}
dom, err := s.deps.DB.GetDomainByID(ctx, domainID)
if err != nil || dom == nil {
redirect(w, r, "/admin/users", "", "Domain not found.")
return
}
email := username + "@" + dom.Name
exists, err := s.deps.DB.UserExistsByEmail(ctx, email)
if err != nil || exists {
redirect(w, r, "/admin/users", "", "Email already in use.")
return
}
hash, err := appCrypto.HashPassword(password)
if err != nil {
redirect(w, r, "/admin/users", "", "Password hashing failed.")
return
}
userID, err := s.deps.DB.CreateUser(ctx, domainID, username, email, hash, displayName, quotaMB*1024*1024, domainAdmin)
if err != nil {
log.Printf("[admin] create user: %v", err)
redirect(w, r, "/admin/users", "", "Failed to create user.")
return
}
// Create default mailboxes, calendar, address book.
if err := createDefaultMailboxes(ctx, s.deps.DB, userID); err != nil {
log.Printf("[admin] default mailboxes: %v", err)
}
if _, err := s.deps.DB.EnsureDefaultCalendar(ctx, userID); err != nil {
log.Printf("[admin] default calendar: %v", err)
}
if _, err := s.deps.DB.EnsureDefaultAddressBook(ctx, userID); err != nil {
log.Printf("[admin] default address book: %v", err)
}
redirect(w, r, fmt.Sprintf("/admin/users/%d", userID), "User created.", "")
}
type userDetailData struct {
basePage
U *db.UserWithDomain
}
func (s *Server) userDetail(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
id := pathID(r, "id")
users, err := s.deps.DB.ListAllUsers(ctx)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
var found *db.UserWithDomain
for _, u := range users {
if u.ID == id {
found = u
break
}
}
if found == nil {
http.NotFound(w, r)
return
}
flash, errMsg := flashFrom(r)
s.render(w, "user", userDetailData{
basePage: s.newBase(r, flash, errMsg),
U: found,
})
}
func (s *Server) userUpdate(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if !s.validateCSRF(w, r) {
return
}
id := pathID(r, "id")
enabled := r.FormValue("enabled") == "1"
admin := r.FormValue("admin") == "1"
domainAdmin := r.FormValue("domain_admin") == "1"
quotaMB, _ := strconv.ParseInt(r.FormValue("quota_mb"), 10, 64)
displayName := strings.TrimSpace(r.FormValue("display_name"))
if len(displayName) > 255 {
displayName = displayName[:255]
}
if quotaMB < 0 {
quotaMB = 0
}
if err := s.deps.DB.SetUserEnabled(ctx, id, enabled); err != nil {
log.Printf("[admin] user enabled: %v", err)
}
if err := s.deps.DB.SetUserAdmin(ctx, id, admin, domainAdmin); err != nil {
log.Printf("[admin] user admin: %v", err)
}
if err := s.deps.DB.SetUserQuota(ctx, id, quotaMB*1024*1024); err != nil {
log.Printf("[admin] user quota: %v", err)
}
if err := s.deps.DB.SetUserDisplayName(ctx, id, displayName); err != nil {
log.Printf("[admin] user display: %v", err)
}
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "User updated.", "")
}
func (s *Server) userPassword(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if !s.validateCSRF(w, r) {
return
}
id := pathID(r, "id")
password := r.FormValue("password")
if len(password) < 8 || len(password) > 1024 {
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Password must be 8-1024 characters.")
return
}
hash, err := appCrypto.HashPassword(password)
if err != nil {
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Password error.")
return
}
if err := s.deps.DB.SetUserPassword(ctx, id, hash); err != nil {
log.Printf("[admin] set password: %v", err)
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Failed to update password.")
return
}
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "Password updated.", "")
}
func (s *Server) userDelete(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if !s.validateCSRF(w, r) {
return
}
id := pathID(r, "id")
if err := s.deps.DB.DeleteUser(ctx, id); err != nil {
log.Printf("[admin] delete user: %v", err)
redirect(w, r, fmt.Sprintf("/admin/users/%d", id), "", "Delete failed.")
return
}
redirect(w, r, "/admin/users", "User deleted.", "")
}
// ---- Queue ----
type queueData struct {
basePage
Entries []*db.QueueEntry
}
func (s *Server) queueList(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
entries, err := s.deps.DB.ListQueueEntries(ctx)
if err != nil {
log.Printf("[admin] queue list: %v", err)
}
flash, errMsg := flashFrom(r)
s.render(w, "queue", queueData{
basePage: s.newBase(r, flash, errMsg),
Entries: entries,
})
}
func (s *Server) queueRetry(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if !s.validateCSRF(w, r) {
return
}
id := pathID(r, "id")
if err := s.deps.DB.RetryQueueEntry(ctx, id); err != nil {
log.Printf("[admin] queue retry: %v", err)
redirect(w, r, "/admin/queue", "", "Retry failed.")
return
}
redirect(w, r, "/admin/queue", "Entry queued for immediate retry.", "")
}
func (s *Server) queueDelete(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if !s.validateCSRF(w, r) {
return
}
id := pathID(r, "id")
if err := s.deps.DB.DeleteQueueEntry(ctx, id); err != nil {
log.Printf("[admin] queue delete: %v", err)
redirect(w, r, "/admin/queue", "", "Delete failed.")
return
}
redirect(w, r, "/admin/queue", "Queue entry deleted.", "")
}
// ---- IP Bans ----
type bansData struct {
basePage
Bans []*db.IPBan
}
func (s *Server) bansList(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
bans, err := s.deps.DB.ListIPBans(ctx)
if err != nil {
log.Printf("[admin] list bans: %v", err)
}
flash, errMsg := flashFrom(r)
s.render(w, "bans", bansData{
basePage: s.newBase(r, flash, errMsg),
Bans: bans,
})
}
func (s *Server) bansAdd(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if !s.validateCSRF(w, r) {
return
}
ip := strings.TrimSpace(r.FormValue("ip"))
reason := strings.TrimSpace(r.FormValue("reason"))
hours, _ := strconv.Atoi(r.FormValue("hours"))
if net.ParseIP(ip) == nil {
redirect(w, r, "/admin/bans", "", "Invalid IP address.")
return
}
if len(reason) > 255 {
reason = reason[:255]
}
if hours < 0 {
hours = 0
}
if err := s.deps.DB.AddIPBan(ctx, ip, reason, hours); err != nil {
log.Printf("[admin] add ban: %v", err)
redirect(w, r, "/admin/bans", "", "Failed to add ban.")
return
}
redirect(w, r, "/admin/bans", fmt.Sprintf("IP %s banned.", ip), "")
}
func (s *Server) bansRemove(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if !s.validateCSRF(w, r) {
return
}
ip := r.PathValue("ip")
if net.ParseIP(ip) == nil {
redirect(w, r, "/admin/bans", "", "Invalid IP.")
return
}
if err := s.deps.DB.RemoveIPBan(ctx, ip); err != nil {
log.Printf("[admin] remove ban: %v", err)
redirect(w, r, "/admin/bans", "", "Remove failed.")
return
}
redirect(w, r, "/admin/bans", fmt.Sprintf("Ban on %s removed.", ip), "")
}
// ---- Security Events ----
type eventsData struct {
basePage
Events []*db.SecurityEvent
}
func (s *Server) eventsList(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
limit := 200
if q := r.URL.Query().Get("limit"); q != "" {
if n, err := strconv.Atoi(q); err == nil && n > 0 && n <= 1000 {
limit = n
}
}
evs, err := s.deps.DB.ListSecurityEvents(ctx, limit)
if err != nil {
log.Printf("[admin] events: %v", err)
}
flash, errMsg := flashFrom(r)
s.render(w, "events", eventsData{
basePage: s.newBase(r, flash, errMsg),
Events: evs,
})
}
// ---- DMARC monitoring ----
// domainEnableDMARC generates a random DMARC monitoring address for a domain.
func (s *Server) domainEnableDMARC(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if !s.validateCSRF(w, r) {
return
}
id := pathID(r, "id")
dom, err := s.deps.DB.GetDomainByID(ctx, id)
if err != nil || dom == nil {
http.NotFound(w, r)
return
}
// Generate a random 6-byte token → 12 hex chars.
token, err := generateToken(6)
if err != nil {
log.Printf("[admin] dmarc token gen: %v", err)
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Failed to generate monitoring address.")
return
}
rua := "dmarc-" + token + "@" + dom.Name
if err := s.deps.DB.SetDomainDMARCRua(ctx, id, rua); err != nil {
log.Printf("[admin] dmarc enable: %v", err)
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Failed to save monitoring address.")
return
}
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id),
"DMARC monitoring enabled. Update your DNS DMARC record with the new rua= address.", "")
}
// domainDisableDMARC clears the DMARC monitoring address for a domain.
func (s *Server) domainDisableDMARC(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if !s.validateCSRF(w, r) {
return
}
id := pathID(r, "id")
if err := s.deps.DB.SetDomainDMARCRua(ctx, id, ""); err != nil {
log.Printf("[admin] dmarc disable: %v", err)
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "", "Failed to disable monitoring.")
return
}
redirect(w, r, fmt.Sprintf("/admin/domains/%d", id), "DMARC monitoring disabled.", "")
}
// domainDMARCReports renders the DMARC aggregate reports page for a domain.
func (s *Server) domainDMARCReports(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
id := pathID(r, "id")
dom, err := s.deps.DB.GetDomainByID(ctx, id)
if err != nil || dom == nil {
http.NotFound(w, r)
return
}
limit := 50
reports, err := s.deps.DB.ListDMARCReports(ctx, id, limit)
if err != nil {
log.Printf("[admin] dmarc reports: %v", err)
}
flash, errMsg := flashFrom(r)
s.render(w, "dmarc", struct {
basePage
Domain *models.Domain
Reports []*models.DMARCReport
}{
basePage: s.newBase(r, flash, errMsg),
Domain: dom,
Reports: reports,
})
}
// ---- internal helpers ----
// validateCSRF checks the CSRF token; on failure writes a 403 and returns false.
func (s *Server) validateCSRF(w http.ResponseWriter, r *http.Request) bool {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return false
}
sess, _, _ := s.deps.Sessions.Get(r)
if sess == nil {
http.Error(w, "unauthenticated", http.StatusForbidden)
return false
}
if !s.checkCSRF(r, sess.TokenHash) {
http.Error(w, "CSRF validation failed", http.StatusForbidden)
return false
}
return true
}
// pathID extracts a positive int64 from a URL path value. Returns 0 on error.
func pathID(r *http.Request, key string) int64 {
id, _ := strconv.ParseInt(r.PathValue(key), 10, 64)
return id
}
// generateToken returns n random bytes as a lowercase hex string.
func generateToken(n int) (string, error) {
buf := make([]byte, n)
if _, err := cryptoRand.Read(buf); err != nil {
return "", err
}
return fmt.Sprintf("%x", buf), nil
}
// validDomain accepts simple dot-separated labels (a-z0-9 and hyphens).
func validDomain(s string) bool {
if len(s) < 3 || len(s) > 253 {
return false
}
for _, label := range strings.Split(s, ".") {
if len(label) == 0 || len(label) > 63 {
return false
}
for _, c := range label {
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
return false
}
}
}
return true
}
// validUsername accepts lowercase alphanumeric + dots + hyphens, 1-64 chars.
func validUsername(s string) bool {
if len(s) < 1 || len(s) > 64 {
return false
}
for _, c := range s {
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '-' || c == '_') {
return false
}
}
return true
}
// validIdentifier accepts [a-zA-Z0-9_-], 1-63 chars.
func validIdentifier(s string) bool {
if len(s) < 1 || len(s) > 63 {
return false
}
for _, c := range s {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return false
}
}
return true
}
// dkimAlgoKey converts our algo string to the DNS TXT k= value.
func dkimAlgoKey(algo string) string {
if algo == "ed25519" {
return "ed25519"
}
return "rsa"
}
// generateDKIM creates a new DKIM key pair and persists it encrypted.
func (s *Server) generateDKIM(ctx context.Context, domainID int64, algo, selector string) error {
privPEM, pubPEM, err := dkim.GenerateKeyPair(algo)
if err != nil {
return fmt.Errorf("keygen: %w", err)
}
privEnc, err := s.deps.Crypt.EncryptGlobal("dkim", []byte(privPEM))
if err != nil {
return fmt.Errorf("encrypt dkim key: %w", err)
}
if err := s.deps.DB.SaveDKIMKeys(ctx, domainID, privEnc, pubPEM); err != nil {
return fmt.Errorf("save dkim keys: %w", err)
}
// Update algo + selector in case they changed.
_, err = s.deps.DB.SQL().ExecContext(ctx,
"UPDATE domains SET dkim_algo=?, dkim_selector=? WHERE id=?", algo, selector, domainID)
return err
}
// createDefaultMailboxes creates INBOX, Sent, Drafts, Trash, Spam, Archive for a new user.
func createDefaultMailboxes(ctx context.Context, database *db.DB, userID int64) error {
return database.CreateDefaultMailboxes(ctx, userID)
}
// ---- First-run setup ----
type setupPage struct {
basePage
}
// setupGet renders the initial setup form (only accessible when no admin user exists).
func (s *Server) setupGet(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
has, err := s.deps.DB.HasAdminUser(ctx)
if err != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
if has {
// Setup already done — redirect to login.
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
flash, errMsg := flashFrom(r)
s.render(w, "setup", setupPage{basePage: newBaseNoUser(flash, errMsg)})
}
// setupPost creates the first admin user + its primary domain.
// Only callable when no admin user exists; subsequent calls redirect to login.
func (s *Server) setupPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
has, err := s.deps.DB.HasAdminUser(ctx)
if err != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
if has {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
password := r.FormValue("password")
confirm := r.FormValue("confirm_password")
domain := strings.ToLower(strings.TrimSpace(r.FormValue("domain")))
// Validate.
if email == "" || len(email) > 254 || !strings.Contains(email, "@") {
redirect(w, r, "/admin/setup", "", "Invalid email address.")
return
}
if domain == "" || len(domain) > 253 || strings.Contains(domain, " ") {
redirect(w, r, "/admin/setup", "", "Invalid domain name.")
return
}
if len(password) < 8 || len(password) > 1024 {
redirect(w, r, "/admin/setup", "", "Password must be at least 8 characters.")
return
}
if password != confirm {
redirect(w, r, "/admin/setup", "", "Passwords do not match.")
return
}
hash, err := appCrypto.HashPassword(password)
if err != nil {
log.Printf("[admin] setup hash: %v", err)
redirect(w, r, "/admin/setup", "", "Internal error.")
return
}
// Create domain first (user references it).
selector := s.deps.Cfg.DKIMSelector
if selector == "" {
selector = "godkim"
}
algo := s.deps.Cfg.DKIMAlgo
if algo == "" {
algo = "rsa2048"
}
domainID, err := s.deps.DB.CreateDomain(ctx, domain, selector, algo)
if err != nil {
log.Printf("[admin] setup create domain: %v", err)
redirect(w, r, "/admin/setup", "", "Failed to create domain.")
return
}
// Auto-generate DKIM key pair for the new domain.
if err := s.generateDKIM(ctx, domainID, algo, selector); err != nil {
log.Printf("[admin] setup dkim keygen: %v", err)
// Non-fatal — admin can regenerate from domain detail page.
}
// Create admin user.
userID, err := s.deps.DB.CreateAdminUser(ctx, domainID, email, hash)
if err != nil {
log.Printf("[admin] setup create admin: %v", err)
redirect(w, r, "/admin/setup", "", "Failed to create admin user.")
return
}
// Default mailboxes + calendar + address book.
if err := createDefaultMailboxes(ctx, s.deps.DB, userID); err != nil {
log.Printf("[admin] setup mailboxes: %v", err)
}
if _, err := s.deps.DB.EnsureDefaultCalendar(ctx, userID); err != nil {
log.Printf("[admin] setup calendar: %v", err)
}
if _, err := s.deps.DB.EnsureDefaultAddressBook(ctx, userID); err != nil {
log.Printf("[admin] setup addressbook: %v", err)
}
log.Printf("[admin] setup complete: admin=%s domain=%s", email, domain)
redirect(w, r, "/admin/login", "Setup complete. Sign in with your new credentials.", "")
}