Files
mailgoserver/internal/webui/mailboxes.go
T

254 lines
8.3 KiB
Go

package webui
import (
"net/http"
"strconv"
"strings"
"mailgoserver/internal/db"
"mailgoserver/internal/mailstore"
)
const bytesPerGB = 1024 * 1024 * 1024
// buildMailboxEmail mirrors buildSenderEmail exactly: the domain is always resolved
// server-side by ID, never trusted as free text, so a mailbox can never end up
// assigned to a domain its own address doesn't belong to.
func (a *App) buildMailboxEmail(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 (a *App) mailboxesList(w http.ResponseWriter, r *http.Request) {
mailboxes, err := a.DB.ListMailboxes()
if err != nil {
setFlash(w, "error", "Error loading mailboxes")
}
scope := scopeFromContext(r)
var pairs [][2]any
for _, m := range mailboxes {
if !scope.Allowed(m.DomainID) {
continue
}
pctFull := 0.0
if m.QuotaBytes > 0 {
pctFull = float64(m.UsedBytes) / float64(m.QuotaBytes) * 100
}
pairs = append(pairs, [2]any{m.Mailbox, M{"domain_name": m.DomainName, "pct_full": pctFull}})
}
a.render(w, r, "mailboxes.html", M{"active": "mailboxes", "mailboxes": pairs})
}
func (a *App) addMailboxForm(w http.ResponseWriter, r *http.Request) {
domains, _ := a.accessibleDomains(r)
a.render(w, r, "add_mailbox.html", M{"active": "mailboxes", "domains": domains})
}
// addMailbox mirrors addSender's shape: local_part + domain_id resolved server-side
// into the real email, a random per-mailbox encryption key generated and sealed with
// the server master key (see internal/mailstore), and quota defaulting to the owning
// domain's configured default when left blank.
func (a *App) addMailbox(w http.ResponseWriter, r *http.Request) {
localPart := strings.TrimSpace(r.FormValue("local_part"))
password := r.FormValue("password")
domainID := int64(atoi(r.FormValue("domain_id")))
quotaGB := r.FormValue("quota_gb")
if !requireDomainAccess(w, r, domainID) {
return
}
email, err := a.buildMailboxEmail(localPart, domainID)
if err != nil {
setFlash(w, "error", "Error creating mailbox")
http.Redirect(w, r, Prefix+"/mailboxes/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+"/mailboxes/add", http.StatusFound)
return
}
if exists, _ := a.DB.MailboxEmailExists(email, -1); exists {
setFlash(w, "error", "A mailbox with this email already exists")
http.Redirect(w, r, Prefix+"/mailboxes/add", http.StatusFound)
return
}
quotaBytes := parseQuotaGB(quotaGB)
if quotaBytes <= 0 {
quotaBytes, _ = a.DB.GetDomainDefaultQuota(domainID)
}
hash, err := db.HashPassword(password)
if err != nil {
setFlash(w, "error", "Error creating mailbox")
http.Redirect(w, r, Prefix+"/mailboxes/add", http.StatusFound)
return
}
dek := mailstore.GenerateDEK()
wrapped, nonce, err := a.Mailstore.WrapDEK(dek)
if err != nil {
a.Logger.Error("wrap mailbox DEK: %v", err)
setFlash(w, "error", "Error creating mailbox")
http.Redirect(w, r, Prefix+"/mailboxes/add", http.StatusFound)
return
}
if _, err := a.DB.CreateMailbox(email, hash, domainID, quotaBytes, wrapped, nonce); err != nil {
setFlash(w, "error", "Error creating mailbox")
http.Redirect(w, r, Prefix+"/mailboxes/add", http.StatusFound)
return
}
setFlash(w, "success", "Mailbox added successfully")
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
}
func parseQuotaGB(s string) int64 {
gb, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil || gb <= 0 {
return 0
}
return int64(gb * bytesPerGB)
}
// mailboxWithAccess mirrors senderWithAccess.
func (a *App) mailboxWithAccess(w http.ResponseWriter, r *http.Request) (mailbox *db.Mailbox, ok bool) {
mailbox, err := a.DB.GetMailboxByID(pathID(r))
if err != nil || mailbox == nil {
http.NotFound(w, r)
return nil, false
}
if !requireDomainAccess(w, r, mailbox.DomainID) {
return nil, false
}
return mailbox, true
}
// resetMailboxMFA clears a mailbox owner's TOTP and passkeys — e.g. after a lost
// device, or (under enforce_mailbox_mfa) to unblock their webmail login without
// needing a domain/mailbox exemption — so they can sign back in and, if MFA is
// enforced, re-enroll from the webmail portal on their next login.
func (a *App) resetMailboxMFA(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
if err := a.DB.ResetMailboxMFA(mailbox.ID); err != nil {
setFlash(w, "error", "Error resetting MFA")
} else {
setFlash(w, "success", "MFA reset for "+mailbox.Email)
}
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
}
func (a *App) disableMailbox(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
if err := a.DB.SetMailboxActive(mailbox.ID, false); err != nil {
setFlash(w, "error", "Error disabling mailbox")
} else {
setFlash(w, "success", "Mailbox disabled")
}
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
}
func (a *App) enableMailbox(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
if err := a.DB.SetMailboxActive(mailbox.ID, true); err != nil {
setFlash(w, "error", "Error enabling mailbox")
} else {
setFlash(w, "success", "Mailbox enabled")
}
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
}
// removeMailbox deletes every stored message's on-disk ciphertext via mailstore first
// (so nothing is orphaned on disk), then hard-deletes the mailbox row and everything
// that references it.
func (a *App) removeMailbox(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
uids, err := a.DB.ListMessageUIDsForMailbox(mailbox.ID)
if err != nil {
setFlash(w, "error", "Error removing mailbox")
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
return
}
for _, uid := range uids {
if err := a.Mailstore.DeleteMessage(mailbox.ID, uid); err != nil {
a.Logger.Error("delete message %d for mailbox %d: %v", uid, mailbox.ID, err)
}
}
if err := a.DB.RemoveMailboxCascade(mailbox.ID); err != nil {
setFlash(w, "error", "Error removing mailbox")
} else {
setFlash(w, "success", "Mailbox permanently removed")
}
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
}
func (a *App) editMailboxForm(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
domains, _ := a.accessibleDomains(r)
a.render(w, r, "edit_mailbox.html", M{
"active": "mailboxes", "mailbox": mailbox, "domains": domains,
"local_part": localPartOf(mailbox.Email), "quota_gb": float64(mailbox.QuotaBytes) / bytesPerGB,
})
}
// editMailbox allows changing the portal password and quota. The local part/domain
// (and so the address itself) are intentionally NOT editable here — the IMAP/SMTP app
// passwords and encryption key are already bound to this mailbox's identity, and
// renaming it out from under those would orphan them. Remove and recreate instead.
func (a *App) editMailbox(w http.ResponseWriter, r *http.Request) {
mailbox, ok := a.mailboxWithAccess(w, r)
if !ok {
return
}
password := r.FormValue("password")
quotaBytes := parseQuotaGB(r.FormValue("quota_gb"))
if quotaBytes <= 0 {
quotaBytes = mailbox.QuotaBytes
}
if err := a.DB.SetMailboxQuota(mailbox.ID, quotaBytes); err != nil {
setFlash(w, "error", "Error updating mailbox")
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
return
}
if password != "" {
hash, err := db.HashPassword(password)
if err != nil {
setFlash(w, "error", "Error updating mailbox")
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
return
}
if err := a.DB.SetMailboxPasswordHash(mailbox.ID, hash); err != nil {
setFlash(w, "error", "Error updating mailbox")
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
return
}
}
if err := a.DB.SetMailboxMFAExempt(mailbox.ID, r.FormValue("mfa_exempt") == "on"); err != nil {
setFlash(w, "error", "Error updating mailbox")
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
return
}
setFlash(w, "success", "Mailbox updated successfully")
http.Redirect(w, r, Prefix+"/mailboxes", http.StatusFound)
}