255 lines
8.4 KiB
Go
255 lines
8.4 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 {
|
|
_ = a.DB.LogAuthAttempt("mailbox_mfa", mailbox.Email, 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)
|
|
}
|