2026-08-12 12:56:22 +01:00
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
}
2026-08-13 08:07:19 +01:00
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
}
2026-08-12 12:56:22 +01:00
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 ) + ")"
}