Files
mailgoserver/internal/webui/senders.go
T
2026-08-12 12:56:22 +01:00

223 lines
7.3 KiB
Go

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 <local_part>@<selected domain's real name>, 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
// <local_part>@<selected domain>, 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)
}