package webui import ( "bytes" "encoding/base64" "html/template" "image/png" "net/http" "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) pctFull := 0.0 if mbox.QuotaBytes > 0 { pctFull = float64(mbox.UsedBytes) / float64(mbox.QuotaBytes) * 100 } // webmail_account.html is a standalone page (own , 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, "flashes": popFlashes(w, r), }) } // 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) }