223 lines
7.3 KiB
Go
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)
|
|
}
|