Files
mailgoserver/internal/webui/webmail_account.go
T

343 lines
14 KiB
Go

package webui
import (
"bytes"
"encoding/base64"
"fmt"
"html/template"
"image/png"
"net/http"
"net/mail"
"strings"
"github.com/pquerna/otp/totp"
"mailgoserver/internal/db"
)
// webmailDashboard is the mailbox owner's single self-service page: their own quota
// usage, password change, TOTP MFA enable/disable, registered passkeys, and app
// passwords for IMAP/SMTP clients — everything scoped to reusing the app-password
// CRUD already built for the admin-managed mailbox pages (db.ListAppPasswordsForMailbox
// etc.), just presented for self-service instead of admin management. Only ever
// reached with MFA already satisfying enforce_mailbox_mfa (or enforcement off) —
// requireMailboxAuth redirects everywhere else, including here, to the isolated
// /mfa-setup page otherwise (see webmailMFASetupRequiredPage).
func (a *App) webmailDashboard(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
passkeys, _ := a.DB.ListMailboxWebAuthnCredentials(mbox.ID)
passwords, _ := a.DB.ListAppPasswordsForMailbox(mbox.ID)
trustedSenders, _ := a.DB.ListTrustedImageSenders(mbox.ID)
sessions, _ := a.DB.ListMailboxSessions(mbox.ID)
domain, _ := a.DB.GetDomainByID(mbox.DomainID)
currentToken := ""
if c, err := r.Cookie(mailboxSessionCookieName); err == nil {
currentToken = c.Value
}
pctFull := 0.0
if mbox.QuotaBytes > 0 {
pctFull = float64(mbox.UsedBytes) / float64(mbox.QuotaBytes) * 100
}
// webmail_account.html is a standalone page (own <head>, no admin base.html/sidebar)
// so render() doesn't auto-populate flashes for it the way admin pages get — pop
// them explicitly here instead.
a.render(w, r, "webmail_account.html", M{
"mailbox": mbox, "passkeys": passkeys, "passwords": passwords, "pct_full": pctFull,
"trusted_senders": trustedSenders,
"sessions": sessions, "current_session_token": currentToken,
"domain": domain,
"flashes": popFlashes(w, r),
"active_section": "account",
})
}
// webmailRevokeSession ends one of this mailbox's own browser sessions — the current
// session can revoke itself too (acts as an immediate logout, same as the regular
// logout button), unlike app-password revocation which only ever targets a different
// credential than the one being used to revoke it.
func (a *App) webmailRevokeSession(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
token := r.PathValue("token")
revokingCurrent := false
if c, err := r.Cookie(mailboxSessionCookieName); err == nil {
revokingCurrent = c.Value == token
}
if err := a.DB.RevokeMailboxSession(token, mbox.ID); err != nil {
setFlash(w, "error", "Error revoking session")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if revokingCurrent {
clearMailboxSessionCookie(w)
http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound)
return
}
setFlash(w, "success", "Session revoked")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
// remoteImagesModes are the only valid values for esrv_mailboxes.remote_images_mode
// — see its schema.go comment for what each means.
var remoteImagesModes = map[string]bool{"ask": true, "trusted": true, "always": true}
func (a *App) webmailSetRemoteImagesMode(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
mode := r.FormValue("remote_images_mode")
if !remoteImagesModes[mode] {
setFlash(w, "error", "Invalid setting")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if err := a.DB.SetMailboxRemoteImagesMode(mbox.ID, mode); err != nil {
setFlash(w, "error", "Could not save preference")
} else {
setFlash(w, "success", "Preference saved")
}
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
// webmailSetDavEnabled saves this mailbox owner's own CalDAV/CardDAV opt-in — only
// actually takes effect for whichever protocol(s) the domain admin has also turned on
// (see db.SetDomainCalDAVEnabled/SetDomainCardDAVEnabled and DAVBasicAuth's gate); the
// checkbox is still saved even if the domain switch is currently off, so it's already
// set the moment an admin turns the domain switch on later.
func (a *App) webmailSetDavEnabled(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
caldav := r.FormValue("caldav_enabled") == "on"
carddav := r.FormValue("carddav_enabled") == "on"
if err := a.DB.SetMailboxDAVEnabled(mbox.ID, caldav, carddav); err != nil {
setFlash(w, "error", "Could not save preference")
} else {
setFlash(w, "success", "Preference saved")
}
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
func (a *App) webmailAddTrustedImageSender(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
email := strings.TrimSpace(r.FormValue("email"))
if email == "" {
setFlash(w, "error", "Enter an email address")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if err := a.DB.AddTrustedImageSender(mbox.ID, email); err != nil {
setFlash(w, "error", "Error adding sender")
} else {
setFlash(w, "success", email+" will now show images automatically")
}
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
func (a *App) webmailRemoveTrustedImageSender(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
id := int64(atoi(r.PathValue("id")))
if err := a.DB.RemoveTrustedImageSender(id, mbox.ID); err != nil {
setFlash(w, "error", "Error removing sender")
} else {
setFlash(w, "success", "Sender removed")
}
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
// webmailSetGroupMessages toggles the "group similar subjects" folder-view preference
// (see renderFolderOrSearch) — off by default, per-mailbox, purely a display choice.
func (a *App) webmailSetGroupMessages(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
if err := r.ParseForm(); err != nil {
setFlash(w, "error", "Invalid form data")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if err := a.DB.SetMailboxGroupMessages(mbox.ID, r.FormValue("group_messages") == "true"); err != nil {
a.Logger.Error("set group_messages for mailbox %d: %v", mbox.ID, err)
setFlash(w, "error", "Could not save preference")
} else {
setFlash(w, "success", "Preference saved")
}
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
// webmailSetForwarding sets or clears this mailbox's persistent forward — distinct
// from and independent of a per-rule forward action (webmail Rules page); this one
// applies unconditionally to every non-quarantined message. An empty forward_to turns
// forwarding off entirely (the "Off" radio in the form).
func (a *App) webmailSetForwarding(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
forwardTo := strings.TrimSpace(r.FormValue("forward_to"))
if r.FormValue("forward_enabled") != "on" {
forwardTo = ""
} else if _, err := mail.ParseAddress(forwardTo); err != nil {
setFlash(w, "error", "Enter a valid forwarding address")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
keepCopy := r.FormValue("forward_keep_copy") == "on"
if err := a.DB.SetMailboxForwarding(mbox.ID, forwardTo, keepCopy); err != nil {
a.Logger.Error("set forwarding for mailbox %d: %v", mbox.ID, err)
setFlash(w, "error", "Could not save forwarding setting")
} else {
setFlash(w, "success", "Forwarding setting saved")
}
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
// webmailRebuildMessageCache re-derives cached_from/cached_to/cached_subject/
// cached_preview for every message already in this mailbox — see
// mailstore.RebuildMessageCache's doc comment for why this exists: those fields are
// only ever computed once, at delivery time, so mail stored before a caching fix (like
// showing a sender's display name instead of the bare address) or addition (like the
// preview snippet) landed keeps showing the old/blank value until something
// retroactively re-derives it.
func (a *App) webmailRebuildMessageCache(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
updated, skipped := a.Mailstore.RebuildMessageCache(mbox.ID)
if len(skipped) > 0 {
a.Logger.Error("rebuild message cache for mailbox %d: %d skipped: %v", mbox.ID, len(skipped), skipped)
}
msg := fmt.Sprintf("Refreshed %d message(s)", updated)
if len(skipped) > 0 {
msg += fmt.Sprintf(" — %d could not be read and were left as-is", len(skipped))
}
setFlash(w, "success", msg)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
// webmailMFASetupRequiredPage is the isolated, no-navigation landing page
// requireMailboxAuth sends a mailbox owner to when enforce_mailbox_mfa applies and
// they have no second factor yet — the only page (besides the totp/passkey setup
// actions themselves) reachable until they set one up. Existing app passwords keep
// authenticating IMAP/SMTP clients throughout — that's a separate, non-interactive
// protocol path this gate has no bearing on.
func (a *App) webmailMFASetupRequiredPage(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
a.render(w, r, "webmail_mfa_setup_required.html", M{"email": mbox.Email, "flashes": popFlashes(w, r)})
}
func (a *App) webmailChangePassword(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
current := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirm := r.FormValue("new_password_confirm")
if !db.CheckPassword(current, mbox.PasswordHash) {
setFlash(w, "error", "Current password is incorrect")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if !isStrongPassword(newPassword) {
setFlash(w, "error", "New password must be at least 10 characters and include a letter, a number, and a symbol")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if newPassword != confirm {
setFlash(w, "error", "New passwords don't match")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
hash, err := db.HashPassword(newPassword)
if err != nil {
setFlash(w, "error", "Something went wrong")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if err := a.DB.SetMailboxPasswordHash(mbox.ID, hash); err != nil {
setFlash(w, "error", "Something went wrong")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
setFlash(w, "success", "Password updated")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
func (a *App) webmailTOTPSetupBegin(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
key, err := totp.Generate(totp.GenerateOpts{Issuer: "mailgoserver", AccountName: mbox.Email})
if err != nil {
setFlash(w, "error", "Could not generate a TOTP secret")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if err := a.DB.SetMailboxTOTPSecret(mbox.ID, key.Secret(), false); err != nil {
setFlash(w, "error", "Could not save the TOTP secret")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
img, err := key.Image(256, 256)
qrDataURI := ""
if err == nil {
var buf bytes.Buffer
if png.Encode(&buf, img) == nil {
qrDataURI = "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
}
}
// See totpSetupBegin's matching comment in account.go — data: URIs need to be
// typed as template.URL or html/template silently strips them to "#ZgotmplZ".
a.render(w, r, "webmail_totp_setup.html", M{"secret": key.Secret(), "qr_data_uri": template.URL(qrDataURI)})
}
func (a *App) webmailTOTPSetupConfirm(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
code := strings.TrimSpace(r.FormValue("code"))
if mbox.TOTPSecret == "" || !totp.Validate(code, mbox.TOTPSecret) {
setFlash(w, "error", "That code didn't match — try scanning the QR code again")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if err := a.DB.SetMailboxTOTPSecret(mbox.ID, mbox.TOTPSecret, true); err != nil {
setFlash(w, "error", "Something went wrong enabling MFA")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
_ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, a.requestIP(r), true, "TOTP authenticator enabled")
setFlash(w, "success", "Authenticator app MFA enabled")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
func (a *App) webmailTOTPDisable(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
if err := a.DB.DisableMailboxTOTP(mbox.ID); err != nil {
setFlash(w, "error", "Something went wrong")
} else {
_ = a.DB.LogAuthAttempt("mailbox_mfa", mbox.Email, a.requestIP(r), true, "TOTP authenticator disabled")
setFlash(w, "success", "Authenticator app MFA disabled")
}
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
// webmailAddAppPassword mirrors addAppPassword (mailbox_apppasswords.go) but for
// self-service — same generation/storage, just reached from the mailbox's own portal
// instead of an admin managing it on their behalf.
func (a *App) webmailAddAppPassword(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
label := strings.TrimSpace(r.FormValue("label"))
if label == "" {
label = "App password"
}
minLen := a.Cfg.Section("Mailstore").Key("app_password_min_length").MustInt(25)
secret := db.GenerateAppPassword(minLen)
hash, err := db.HashPassword(secret)
if err != nil {
setFlash(w, "error", "Error creating app password")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
if _, err := a.DB.CreateAppPassword(mbox.ID, label, hash, nil); err != nil {
setFlash(w, "error", "Error creating app password")
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
return
}
setFlash(w, "success", "App password created — copy it now, it will not be shown again: "+secret)
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}
func (a *App) webmailRevokeAppPassword(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
pwID := int64(atoi(r.PathValue("pw_id")))
if err := a.DB.RemoveAppPassword(pwID, mbox.ID); err != nil {
setFlash(w, "error", "Error revoking app password")
} else {
setFlash(w, "success", "App password revoked")
}
http.Redirect(w, r, MailboxPrefix+"/account", http.StatusFound)
}