package webui import ( "net/http" "regexp" "strings" "mailgoserver/internal/db" ) // validLocalPart mirrors the RFC-5321-ish practical subset most mail systems accept // for the part of an address before "@" — letters, digits, and . _ % + -. var validLocalPart = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+$`) // buildSenderEmail resolves the domain by ID (so the client can't just type an // arbitrary domain string) and joins it with the local part — this is what actually // fixes "any text accepted, then assigned to an unrelated domain": the address is // always @, never free text. func (a *App) buildSenderEmail(localPart string, domainID int64) (string, error) { dom, err := a.DB.GetDomainByID(domainID) if err != nil { return "", err } if dom == nil || !validLocalPart.MatchString(localPart) { return "", nil } return localPart + "@" + dom.DomainName, nil } func localPartOf(email string) string { if i := strings.Index(email, "@"); i >= 0 { return email[:i] } return email } func (a *App) sendersList(w http.ResponseWriter, r *http.Request) { senders, err := a.DB.ListSenders() if err != nil { setFlash(w, "error", "Error loading senders") } scope := scopeFromContext(r) // Templates use {% for sender, domain in senders %} — pair each row up as [sender, domain]. var pairs [][2]any for _, s := range senders { if !scope.Allowed(s.DomainID) { continue } pairs = append(pairs, [2]any{s.Sender, M{"domain_name": s.DomainName}}) } a.render(w, r, "senders.html", M{"active": "senders", "senders": pairs, "users": pairs}) } func (a *App) addSenderForm(w http.ResponseWriter, r *http.Request) { domains, _ := a.accessibleDomains(r) a.render(w, r, "add_sender.html", M{"active": "senders", "domains": domains}) } // addSender mirrors senders.py's add_sender() POST branch. The email is always // @, built server-side from the resolved domain name — // see buildSenderEmail — so a sender can never end up assigned to a domain its own // address doesn't belong to. func (a *App) addSender(w http.ResponseWriter, r *http.Request) { localPart := strings.TrimSpace(r.FormValue("local_part")) password := r.FormValue("password") domainID := int64(atoi(r.FormValue("domain_id"))) canSendAsDomain := r.FormValue("can_send_as_domain") == "on" storeMessage := r.FormValue("store_message_content") == "on" if !requireDomainAccess(w, r, domainID) { return } email, err := a.buildSenderEmail(localPart, domainID) if err != nil { setFlash(w, "error", "Error creating sender") http.Redirect(w, r, Prefix+"/senders/add", http.StatusFound) return } if email == "" || password == "" { setFlash(w, "error", "All fields are required and the local part may only contain letters, numbers, and . _ % + -") http.Redirect(w, r, Prefix+"/senders/add", http.StatusFound) return } if exists, _ := a.DB.EmailExists(email, -1); exists { setFlash(w, "error", "A sender with this email already exists") http.Redirect(w, r, Prefix+"/senders/add", http.StatusFound) return } hash, err := db.HashPassword(password) if err != nil { setFlash(w, "error", "Error creating sender") http.Redirect(w, r, Prefix+"/senders/add", http.StatusFound) return } if _, err := a.DB.CreateSender(email, hash, domainID, canSendAsDomain, storeMessage); err != nil { setFlash(w, "error", "Error creating sender") http.Redirect(w, r, Prefix+"/senders/add", http.StatusFound) return } setFlash(w, "success", "Sender added successfully") http.Redirect(w, r, Prefix+"/senders", http.StatusFound) } // senderWithAccess fetches a sender by path ID and confirms it belongs to a domain the // current admin can manage, writing 404 and returning ok=false otherwise. func (a *App) senderWithAccess(w http.ResponseWriter, r *http.Request) (sender *db.Sender, ok bool) { sender, err := a.DB.GetSenderByID(pathID(r)) if err != nil || sender == nil { http.NotFound(w, r) return nil, false } if !requireDomainAccess(w, r, sender.DomainID) { return nil, false } return sender, true } func (a *App) disableSender(w http.ResponseWriter, r *http.Request) { sender, ok := a.senderWithAccess(w, r) if !ok { return } if err := a.DB.SetSenderActive(sender.ID, false); err != nil { setFlash(w, "error", "Error disabling sender") } else { setFlash(w, "success", "Sender disabled") } http.Redirect(w, r, Prefix+"/senders", http.StatusFound) } func (a *App) enableSender(w http.ResponseWriter, r *http.Request) { sender, ok := a.senderWithAccess(w, r) if !ok { return } if err := a.DB.SetSenderActive(sender.ID, true); err != nil { setFlash(w, "error", "Error enabling sender") } else { setFlash(w, "success", "Sender enabled") } http.Redirect(w, r, Prefix+"/senders", http.StatusFound) } func (a *App) removeSender(w http.ResponseWriter, r *http.Request) { sender, ok := a.senderWithAccess(w, r) if !ok { return } if err := a.DB.RemoveSender(sender.ID); err != nil { setFlash(w, "error", "Error removing sender") } else { setFlash(w, "success", "Sender permanently removed") } http.Redirect(w, r, Prefix+"/senders", http.StatusFound) } func (a *App) editSenderForm(w http.ResponseWriter, r *http.Request) { sender, ok := a.senderWithAccess(w, r) if !ok { return } domains, _ := a.accessibleDomains(r) a.render(w, r, "edit_sender.html", M{"active": "senders", "sender": sender, "domains": domains, "local_part": localPartOf(sender.Email)}) } // editSender mirrors senders.py's edit_sender() POST branch. As in addSender, the // email is rebuilt server-side from local_part + the resolved domain, never trusted // as free text — see buildSenderEmail. Both the sender's current domain and the // (possibly different) target domain from the form must be within the admin's scope. func (a *App) editSender(w http.ResponseWriter, r *http.Request) { sender, ok := a.senderWithAccess(w, r) if !ok { return } id := sender.ID domains, _ := a.accessibleDomains(r) localPart := strings.TrimSpace(r.FormValue("local_part")) password := r.FormValue("password") domainID := int64(atoi(r.FormValue("domain_id"))) canSendAsDomain := r.FormValue("can_send_as_domain") == "on" storeMessage := r.FormValue("store_message_content") == "on" renderErr := func(msg string) { setFlash(w, "error", msg) a.render(w, r, "edit_sender.html", M{"active": "senders", "sender": sender, "domains": domains, "local_part": localPart}) } if !requireDomainAccess(w, r, domainID) { return } email, err := a.buildSenderEmail(localPart, domainID) if err != nil { renderErr("Error updating sender") return } if email == "" { renderErr("Please provide a valid local part (letters, numbers, and . _ % + - only) and domain") return } if exists, _ := a.DB.EmailExists(email, id); exists { renderErr("A sender with this email already exists") return } var hash string if password != "" { hash, err = db.HashPassword(password) if err != nil { setFlash(w, "error", "Error updating sender") http.Redirect(w, r, Prefix+"/senders", http.StatusFound) return } } if err := a.DB.UpdateSender(id, email, hash, domainID, canSendAsDomain, storeMessage); err != nil { setFlash(w, "error", "Error updating sender") http.Redirect(w, r, Prefix+"/senders", http.StatusFound) return } setFlash(w, "success", "Sender updated successfully") http.Redirect(w, r, Prefix+"/senders", http.StatusFound) }