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 { _ = a.DB.LogAuthAttempt("mailbox_mfa", mailbox.Email, a.requestIP(r), true, "MFA reset by admin "+userFromContext(r).Username) 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) }