package webui import ( "context" "net/http" "time" "mailgoserver/internal/db" ) // MailboxPrefix is the self-service webmail portal's URL prefix — a mailbox owner's // login/account area, entirely separate from the admin dashboard at Prefix. const MailboxPrefix = "/webmail" const mailboxSessionCookieName = "mailgoserver_mailbox_session" // mailboxCtxKey is its own type (not webui's ctxKey) so a mailbox session can never // collide with or be confused for an admin session in request context — the two // actor types are deliberately kept fully separate, per the parallel-schema design. type mailboxCtxKey int const ctxMailboxKey mailboxCtxKey = iota func setMailboxSessionCookie(w http.ResponseWriter, token string, secure bool) { http.SetCookie(w, &http.Cookie{ Name: mailboxSessionCookieName, Value: token, Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds()), }) } func clearMailboxSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{Name: mailboxSessionCookieName, Value: "", Path: "/", MaxAge: -1}) } // currentMailboxSession loads the session + mailbox for the request's cookie, if any // and valid. A nil session/mailbox (no error) means "not logged in". func (a *App) currentMailboxSession(r *http.Request) (*db.MailboxSession, *db.Mailbox, error) { c, err := r.Cookie(mailboxSessionCookieName) if err != nil || c.Value == "" { return nil, nil, nil } sess, err := a.DB.GetMailboxSession(c.Value) if err != nil || sess == nil { return nil, nil, err } if time.Now().After(sess.ExpiresAt) { _ = a.DB.DeleteMailboxSession(sess.Token) return nil, nil, nil } mbox, err := a.DB.GetMailboxByID(sess.MailboxID) if err != nil || mbox == nil { return nil, nil, err } return sess, mbox, nil } func mailboxFromContext(r *http.Request) *db.Mailbox { m, _ := r.Context().Value(ctxMailboxKey).(*db.Mailbox) return m } // requireMailboxAuth gates every webmail route behind a valid, fully-authenticated // mailbox session: logged in, and second factor satisfied if one is enabled. func (a *App) requireMailboxAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sess, mbox, err := a.currentMailboxSession(r) if err != nil { a.Logger.Error("mailbox session lookup: %v", err) } if sess == nil || mbox == nil { http.Redirect(w, r, MailboxPrefix+"/login", http.StatusFound) return } needsMFA := mbox.TOTPEnabled if !needsMFA { if n, _ := a.DB.CountMailboxWebAuthnCredentials(mbox.ID); n > 0 { needsMFA = true } } if needsMFA && !sess.MFAVerified { http.Redirect(w, r, MailboxPrefix+"/login/mfa", http.StatusFound) return } // enforce_mailbox_mfa: login itself is never blocked (see webmailLoginSubmit) — // instead, a mailbox with no MFA configured and no domain/mailbox exemption is // sent to the isolated /mfa-setup page (no other route reachable except the // actual totp/passkey setup actions) until they configure one. This never // touches IMAP/SMTP app-password auth — a completely separate, non-interactive // protocol path this gate has no bearing on; see mailboxNeedsMFASetup's doc // comment. if a.mailboxNeedsMFASetup(mbox) { if !mailboxMFASetupExempt(r.Method, r.URL.Path) { http.Redirect(w, r, MailboxPrefix+"/mfa-setup", http.StatusFound) return } } ctx := context.WithValue(r.Context(), ctxMailboxKey, mbox) next.ServeHTTP(w, r.WithContext(ctx)) }) } // mailboxMFASetupExempt mirrors adminMFASetupExempt for the webmail portal: the // isolated setup page itself, plus the actual form/API actions needed to complete // TOTP or passkey enrollment. Everything else — including the dashboard itself — // redirects to /mfa-setup. func mailboxMFASetupExempt(method, path string) bool { if method == http.MethodGet { return path == MailboxPrefix+"/mfa-setup" } if method != http.MethodPost { return false } switch path { case MailboxPrefix + "/account/totp/setup", MailboxPrefix + "/account/totp/confirm", MailboxPrefix + "/account/passkey/begin", MailboxPrefix + "/account/passkey/finish": return true } return false } // mailboxNeedsMFASetup reports whether [Auth] enforce_mailbox_mfa applies to this // mailbox and it doesn't have a second factor configured yet — false if enforcement // is off, MFA is already set up, or the mailbox/its domain is explicitly exempt. // Login is never blocked by this (see webmailLoginSubmit) — it gates every other // webmail route (see requireMailboxAuth/mailboxMFASetupExempt). Never applies to // IMAP/SMTP app-password auth, which has no interactive step to enforce MFA on // regardless. func (a *App) mailboxNeedsMFASetup(mbox *db.Mailbox) bool { if !a.Cfg.Section("Auth").Key("enforce_mailbox_mfa").MustBool(false) { return false } hasMFA := mbox.TOTPEnabled if !hasMFA { if n, _ := a.DB.CountMailboxWebAuthnCredentials(mbox.ID); n > 0 { hasMFA = true } } if hasMFA || mbox.MFAExempt { return false } if dom, err := a.DB.GetDomainByID(mbox.DomainID); err == nil && dom != nil && dom.MFAExempt { return false } return true }