// Package webmail implements the REST API and embedded SPA for GoMail's own // webmail client. The API wraps internal/accounts.GoMailProvider for message // operations — direct local access, no JMAP dependency — so this phase isn't // blocked on Phase 9's JMAP server. When JMAP lands, only this package's // internals need to change; the REST contract (and therefore the frontend) // stays the same. package webmail import ( "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "log/slog" "net/http" "net/mail" "sort" "strconv" "strings" "sync" "time" "gomail/internal/accounts" "gomail/internal/auth" "gomail/internal/crypto" "gomail/internal/db" "gomail/internal/mailstore" "gomail/internal/oauth2" "gomail/internal/totp" "gomail/internal/webauthn" "gomail/internal/webtoken" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) const sessionTTL = 24 * time.Hour type Handler struct { database *db.DB store *mailstore.Store mk *crypto.MasterKey jwtSecret string hostname string // WebAuthn RP ID; origin is "https://"+hostname oauthConfigs map[string]*oauth2.Config // keyed by "google" / "microsoft", nil entries if not configured oauthStateMu sync.Mutex oauthState map[string]oauthStateEntry // CSRF state -> pending link request webauthnMu sync.Mutex webauthnState map[string]webauthnChallengeEntry // challenge -> pending registration/assertion, same shape as oauthState } type oauthStateEntry struct { UserID string Provider string ExpiresAt time.Time } // webauthnChallengeEntry tracks one issued challenge. UserID is set for // both registration (the already-authenticated user registering a new // passkey) and login (the user identified by the mfa_pending token before // the passkey assertion completes it). type webauthnChallengeEntry struct { UserID string ExpiresAt time.Time } func NewHandler(database *db.DB, store *mailstore.Store, mk *crypto.MasterKey, jwtSecret, hostname string, oauthConfigs map[string]*oauth2.Config) *Handler { return &Handler{ database: database, store: store, mk: mk, jwtSecret: jwtSecret, hostname: hostname, oauthConfigs: oauthConfigs, oauthState: make(map[string]oauthStateEntry), webauthnState: make(map[string]webauthnChallengeEntry), } } func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/auth/login", h.login) mux.HandleFunc("/api/auth/mfa-verify", h.mfaVerify) mux.HandleFunc("/api/auth/forgot-password", h.forgotPassword) mux.HandleFunc("/api/auth/reset-password", h.resetPassword) mux.HandleFunc("/api/me", h.withAuth(h.getMe)) mux.HandleFunc("/api/me/mfa/setup", h.withAuth(h.mfaSetup)) mux.HandleFunc("/api/me/mfa/confirm", h.withAuth(h.mfaConfirm)) mux.HandleFunc("/api/me/mfa/disable", h.withAuth(h.mfaDisable)) mux.HandleFunc("/api/me/passkeys/register/start", h.withAuth(h.passkeyRegisterStart)) mux.HandleFunc("/api/me/passkeys/register/finish", h.withAuth(h.passkeyRegisterFinish)) mux.HandleFunc("/api/me/passkeys", h.withAuth(h.passkeys)) mux.HandleFunc("/api/me/passkeys/", h.withAuth(h.passkeyByID)) mux.HandleFunc("/api/auth/passkey/start", h.passkeyLoginStart) mux.HandleFunc("/api/auth/passkey/finish", h.passkeyLoginFinish) mux.HandleFunc("/api/me/recovery-email", h.withAuth(h.setRecoveryEmail)) mux.HandleFunc("/api/me/app-passwords", h.withAuth(h.appPasswords)) mux.HandleFunc("/api/me/app-passwords/", h.withAuth(h.appPasswordByID)) mux.HandleFunc("/api/folders", h.withAuth(h.listFolders)) mux.HandleFunc("/api/folders/", h.withAuth(h.listMessages)) mux.HandleFunc("/api/messages", h.withAuth(h.sendOrListMessages)) mux.HandleFunc("/api/messages/", h.withAuth(h.messageByID)) mux.HandleFunc("/api/inbox/unified", h.withAuth(h.unifiedInbox)) mux.HandleFunc("/api/search", h.withAuth(h.search)) mux.HandleFunc("/api/calendar/events", h.withAuth(h.calendarEvents)) mux.HandleFunc("/api/contacts", h.withAuth(h.contacts)) mux.HandleFunc("/api/quarantine", h.withAuth(h.listQuarantine)) mux.HandleFunc("/api/quarantine/", h.withAuth(h.releaseQuarantine)) mux.HandleFunc("/api/events", h.withAuth(h.sseEvents)) mux.HandleFunc("/api/accounts", h.withAuth(h.listAccounts)) mux.HandleFunc("/api/accounts/oauth/", h.oauthDispatch) // start needs auth (checked inline), callback doesn't (browser redirect) mux.HandleFunc("/api/accounts/imap", h.withAuth(h.linkIMAPAccount)) mux.HandleFunc("/api/accounts/", h.withAuth(h.deleteAccount)) } // ── JSON helpers ────────────────────────────────────────────────────────────── func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(v) } func writeErr(w http.ResponseWriter, code int, msg string) { writeJSON(w, code, map[string]string{"error": msg}) } // ── Auth ────────────────────────────────────────────────────────────────────── // titleCase upper-cases s's first byte — used only for the ASCII provider // names ("google", "microsoft") in display strings; strings.Title is // deprecated and its Unicode word-boundary handling is unneeded here. func titleCase(s string) string { if s == "" { return s } return strings.ToUpper(s[:1]) + s[1:] } func (h *Handler) login(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct{ Email, Password string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid request body") return } user, ok := auth.Authenticate(h.database, req.Email, req.Password, auth.ScopeIMAP) if !ok { slog.Info("webmail login failed", "email", req.Email) writeErr(w, http.StatusUnauthorized, "invalid credentials") return } if user.MFAEnabled { // Password alone is not enough — issue a short-lived, narrowly-scoped // pre-auth token instead of a real session. It can only be redeemed // at /api/auth/mfa-verify, and only with a correct TOTP or backup code. mfaToken, err := webtoken.IssueWithPurpose(h.jwtSecret, user.ID, user.TenantID, string(user.Role), "mfa_pending", 5*time.Minute) if err != nil { writeErr(w, http.StatusInternalServerError, "token generation failed") return } writeJSON(w, http.StatusOK, map[string]any{"mfa_required": true, "mfa_token": mfaToken}) return } token, err := webtoken.Issue(h.jwtSecret, user.ID, user.TenantID, string(user.Role), sessionTTL) if err != nil { writeErr(w, http.StatusInternalServerError, "token generation failed") return } h.database.Exec(`UPDATE users SET last_login_at = ? WHERE id = ?`, time.Now().UTC(), user.ID) writeJSON(w, http.StatusOK, map[string]any{ "token": token, "user": map[string]any{"id": user.ID, "email": user.Email, "display_name": user.DisplayName}, }) } // mfaVerify completes login for an MFA-enabled account — redeems the // pre-auth token from login() plus a valid TOTP or backup code for a real // session token. func (h *Handler) mfaVerify(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct{ MFAToken, Code string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid request body") return } claims, err := webtoken.Verify(h.jwtSecret, req.MFAToken) if err != nil || claims.Purpose != "mfa_pending" { writeErr(w, http.StatusUnauthorized, "invalid or expired MFA session") return } user, err := h.database.GetUser(claims.Subject) if err != nil || !user.Active { writeErr(w, http.StatusUnauthorized, "user not found or inactive") return } verified := false if user.TOTPSecretEnc != nil { plain, decErr := crypto.Decrypt(h.mk, user.ID, "totp-secret", user.TOTPSecretEnc) if decErr == nil { if ok, _ := totp.Validate(string(plain), req.Code); ok { verified = true } } } if !verified { // Fall back to a backup code — hashed the same way app passwords are. hash := sha256Hex(req.Code) if used, _ := h.database.ConsumeBackupCode(user.ID, hash); used { verified = true } } if !verified { writeErr(w, http.StatusUnauthorized, "invalid code") return } token, err := webtoken.Issue(h.jwtSecret, user.ID, user.TenantID, string(user.Role), sessionTTL) if err != nil { writeErr(w, http.StatusInternalServerError, "token generation failed") return } h.database.Exec(`UPDATE users SET last_login_at = ? WHERE id = ?`, time.Now().UTC(), user.ID) writeJSON(w, http.StatusOK, map[string]any{ "token": token, "user": map[string]any{"id": user.ID, "email": user.Email, "display_name": user.DisplayName}, }) } func (h *Handler) withAuth(next func(http.ResponseWriter, *http.Request, *db.User)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { tokenStr := "" if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") { tokenStr = strings.TrimPrefix(authHeader, "Bearer ") } else if cookie, err := r.Cookie("gomail_token"); err == nil { tokenStr = cookie.Value } if tokenStr == "" { writeErr(w, http.StatusUnauthorized, "missing token") return } claims, err := webtoken.Verify(h.jwtSecret, tokenStr) if err != nil { writeErr(w, http.StatusUnauthorized, "invalid or expired token") return } if claims.Purpose != "" { // A purpose-scoped token (mfa_pending, password_reset) is not a // session — accepting it here would let it bypass whatever the // purpose was gating (e.g. MFA). writeErr(w, http.StatusUnauthorized, "invalid or expired token") return } // claims.Subject is the user's ID (set at Issue time in login), not // an email — look up directly by ID. row := h.database.QueryRow(`SELECT id, tenant_id, domain_id, email, display_name, role, active FROM users WHERE id = ?`, claims.Subject) var user db.User if err := row.Scan(&user.ID, &user.TenantID, &user.DomainID, &user.Email, &user.DisplayName, &user.Role, &user.Active); err != nil { writeErr(w, http.StatusUnauthorized, "user not found") return } if !user.Active { writeErr(w, http.StatusForbidden, "account disabled") return } next(w, r, &user) } } func (h *Handler) getMe(w http.ResponseWriter, r *http.Request, user *db.User) { // withAuth's own row fetch doesn't select mfa_enabled/recovery_email // (most callers don't need them) — GetUser is the canonical full-row // fetch that does. fresh, err := h.database.GetUser(user.ID) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to load user") return } writeJSON(w, http.StatusOK, map[string]any{ "id": fresh.ID, "email": fresh.Email, "display_name": fresh.DisplayName, "role": fresh.Role, "mfa_enabled": fresh.MFAEnabled, "recovery_email": fresh.RecoveryEmail, }) } // ── Folders & messages ────────────────────────────────────────────────────────── // provider resolves which mailbox a request operates on. With no ?account= // query param it's the user's own local mailbox (today's only behavior, // unchanged). With ?account=, it's that account's // provider — after confirming the account actually belongs to this user, // since the ID otherwise comes straight from client input. func (h *Handler) provider(r *http.Request, user *db.User) (accounts.MailProvider, error) { id := r.URL.Query().Get("account") if id == "" { return accounts.NewGoMailProvider(h.database, h.store, user), nil } acct, err := h.database.GetLinkedAccount(id) if err != nil || acct.UserID != user.ID { return nil, fmt.Errorf("account not found") } return accounts.ProviderFor(acct, h.mk, h.database, h.oauthConfigs) } func (h *Handler) listFolders(w http.ResponseWriter, r *http.Request, user *db.User) { p, err := h.provider(r, user) if err != nil { writeErr(w, http.StatusForbidden, err.Error()) return } folders, err := p.ListFolders(r.Context()) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, folders) } // listMessages handles GET /api/folders/{folderID}/messages func (h *Handler) listMessages(w http.ResponseWriter, r *http.Request, user *db.User) { path := strings.TrimPrefix(r.URL.Path, "/api/folders/") parts := strings.SplitN(path, "/", 2) if len(parts) != 2 || parts[1] != "messages" { http.NotFound(w, r) return } folderID := parts[0] opts := accounts.ListOpts{} if l := r.URL.Query().Get("limit"); l != "" { opts.Limit, _ = strconv.Atoi(l) } if o := r.URL.Query().Get("offset"); o != "" { opts.Offset, _ = strconv.Atoi(o) } p, err := h.provider(r, user) if err != nil { writeErr(w, http.StatusForbidden, err.Error()) return } headers, err := p.ListMessages(r.Context(), folderID, opts) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, headers) } func (h *Handler) sendOrListMessages(w http.ResponseWriter, r *http.Request, user *db.User) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct { To []string `json:"to"` CC []string `json:"cc"` Subject string `json:"subject"` Body string `json:"body"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid request body") return } if len(req.To) == 0 { writeErr(w, http.StatusBadRequest, "at least one recipient required") return } p, err := h.provider(r, user) if err != nil { writeErr(w, http.StatusForbidden, err.Error()) return } msg := &accounts.OutgoingMessage{From: user.Email, To: req.To, CC: req.CC, Subject: req.Subject, Body: req.Body} if err := p.SendMessage(r.Context(), msg); err != nil { writeErr(w, http.StatusBadGateway, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"message": "sent"}) } // messageByID handles GET/PUT(flags)/DELETE/move on /api/messages/{folderID}/{messageID}[/flags|/move] func (h *Handler) messageByID(w http.ResponseWriter, r *http.Request, user *db.User) { path := strings.TrimPrefix(r.URL.Path, "/api/messages/") parts := strings.Split(path, "/") if len(parts) < 2 { http.NotFound(w, r) return } folderID, messageID := parts[0], parts[1] action := "" if len(parts) >= 3 { action = parts[2] } p, err := h.provider(r, user) if err != nil { writeErr(w, http.StatusForbidden, err.Error()) return } switch { case r.Method == http.MethodGet && action == "": full, err := p.GetMessage(r.Context(), folderID, messageID) if err != nil { writeErr(w, http.StatusNotFound, "message not found") return } writeJSON(w, http.StatusOK, full) case r.Method == http.MethodPut && action == "flags": var req struct{ Flags []string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid body") return } if err := p.SetFlags(r.Context(), folderID, messageID, req.Flags); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"message": "updated"}) case r.Method == http.MethodPost && action == "move": var req struct{ DestFolder string `json:"dest_folder"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid body") return } if err := p.Move(r.Context(), folderID, messageID, req.DestFolder); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"message": "moved"}) case r.Method == http.MethodDelete && action == "": if err := p.Delete(r.Context(), folderID, messageID); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"message": "deleted"}) default: w.WriteHeader(http.StatusMethodNotAllowed) } } type unifiedMessage struct { accounts.MessageHeader AccountID string `json:"account_id"` // "" means the local account — matches provider()'s ?account= convention AccountLabel string `json:"account_label"` } // unifiedInbox handles GET /api/inbox/unified — merges the Inbox folder // across the user's local mailbox and every linked account into one // newest-first list. A linked account that fails (unreachable server, // expired token) is skipped and reported in "warnings", not allowed to // fail the whole request — the same negative-path standard this project // applies elsewhere (see GOMAIL_HANDOVER.md). func (h *Handler) unifiedInbox(w http.ResponseWriter, r *http.Request, user *db.User) { const perAccountLimit = 30 const overallLimit = 100 type source struct { id, label string p accounts.MailProvider } sources := []source{{label: user.Email, p: accounts.NewGoMailProvider(h.database, h.store, user)}} linked, err := h.database.ListLinkedAccounts(user.ID) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } for i := range linked { acct := &linked[i] p, perr := accounts.ProviderFor(acct, h.mk, h.database, h.oauthConfigs) if perr != nil { continue // unsupported provider type — a config-time issue, not a per-request failure worth reporting } label := acct.DisplayName if label == "" { label = acct.EmailAddress } sources = append(sources, source{id: acct.ID, label: label, p: p}) } var merged []unifiedMessage var warnings []string for _, src := range sources { folders, ferr := src.p.ListFolders(r.Context()) if ferr != nil { warnings = append(warnings, fmt.Sprintf("%s: %v", src.label, ferr)) continue } var inboxID string for _, f := range folders { if f.Type == "inbox" { inboxID = f.ID break } } if inboxID == "" { continue } headers, merr := src.p.ListMessages(r.Context(), inboxID, accounts.ListOpts{Limit: perAccountLimit}) if merr != nil { warnings = append(warnings, fmt.Sprintf("%s: %v", src.label, merr)) continue } for _, hdr := range headers { merged = append(merged, unifiedMessage{MessageHeader: hdr, AccountID: src.id, AccountLabel: src.label}) } } sort.Slice(merged, func(i, j int) bool { ti, _ := mail.ParseDate(merged[i].Date) tj, _ := mail.ParseDate(merged[j].Date) return ti.After(tj) }) if len(merged) > overallLimit { merged = merged[:overallLimit] } writeJSON(w, http.StatusOK, map[string]any{"messages": merged, "warnings": warnings}) } // searchResult tags a match with the folder it lives in — a search spans // every folder in the account, so (unlike a single-folder listing) the // client needs to know which folder to open the message from. AccountID/ // AccountLabel follow unifiedMessage's convention ("" means the local // account) — populated when a search fans out across every linked account // (see search's doc comment), left blank for a single-account search. type searchResult struct { accounts.MessageHeader FolderName string `json:"folder_name"` AccountID string `json:"account_id,omitempty"` AccountLabel string `json:"account_label,omitempty"` } // search handles GET /api/search?q=...&body=1&folder=...&account=... — // reuses ListFolders/ListMessages/GetMessage exactly like every other // endpoint (same account-scoping via provider(), same header-cache-or- // fallback decrypt ListMessages already does) rather than a separate // index. Header matching (from/to/subject) is effectively free — it reuses // the same decrypt ListMessages already pays for a folder view. Body // matching is opt-in and live-decrypts on demand, capped at // maxBodySearchScans messages total (shared across every account searched, // not per-account) so one search can't force-decrypt an entire large // mailbox — see this project's "everything encrypted at rest" guarantee, // which an index over message content would weaken. // // With no ?account= and no ?folder=, the search fans out across the local // mailbox and every linked account — same source list, same "skip and warn // on a broken account" behavior as unifiedInbox — since a specific account // or folder ID otherwise pins the search to one provider's namespace. func (h *Handler) search(w http.ResponseWriter, r *http.Request, user *db.User) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } const maxResults = 100 const maxBodySearchScans = 500 query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) if query == "" { writeErr(w, http.StatusBadRequest, "q is required") return } searchBody := r.URL.Query().Get("body") == "1" || r.URL.Query().Get("body") == "true" onlyFolder := r.URL.Query().Get("folder") accountID := r.URL.Query().Get("account") type source struct { id, label string p accounts.MailProvider } var sources []source singleAccount := accountID != "" || onlyFolder != "" if singleAccount { p, err := h.provider(r, user) if err != nil { writeErr(w, http.StatusForbidden, err.Error()) return } sources = []source{{id: accountID, p: p}} } else { sources = append(sources, source{label: user.Email, p: accounts.NewGoMailProvider(h.database, h.store, user)}) linked, err := h.database.ListLinkedAccounts(user.ID) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } for i := range linked { acct := &linked[i] p, perr := accounts.ProviderFor(acct, h.mk, h.database, h.oauthConfigs) if perr != nil { continue // unsupported provider type — a config-time issue, not a per-request failure worth reporting } label := acct.DisplayName if label == "" { label = acct.EmailAddress } sources = append(sources, source{id: acct.ID, label: label, p: p}) } } var results []searchResult var warnings []string bodyScans := 0 truncated := false for _, src := range sources { var folders []accounts.Folder if onlyFolder != "" { folders = []accounts.Folder{{ID: onlyFolder}} } else { var ferr error folders, ferr = src.p.ListFolders(r.Context()) if ferr != nil { if singleAccount { writeErr(w, http.StatusInternalServerError, ferr.Error()) return } if src.label != "" { warnings = append(warnings, fmt.Sprintf("%s: %v", src.label, ferr)) } continue } } for _, f := range folders { headers, err := src.p.ListMessages(r.Context(), f.ID, accounts.ListOpts{}) if err != nil { continue } for _, hdr := range headers { matched := strings.Contains(strings.ToLower(hdr.From), query) || strings.Contains(strings.ToLower(hdr.To), query) || strings.Contains(strings.ToLower(hdr.Subject), query) if !matched && searchBody { if bodyScans >= maxBodySearchScans { truncated = true } else { bodyScans++ if full, ferr := src.p.GetMessage(r.Context(), f.ID, hdr.ID); ferr == nil { matched = strings.Contains(strings.ToLower(string(full.Raw)), query) } } } if matched { results = append(results, searchResult{MessageHeader: hdr, FolderName: f.DisplayName, AccountID: src.id, AccountLabel: src.label}) } } } } sort.Slice(results, func(i, j int) bool { ti, _ := mail.ParseDate(results[i].Date) tj, _ := mail.ParseDate(results[j].Date) return ti.After(tj) }) if len(results) > maxResults { results = results[:maxResults] truncated = true } writeJSON(w, http.StatusOK, map[string]any{"messages": results, "truncated": truncated, "warnings": warnings}) } // ── Calendar/contacts (linked accounts only — local calendar/contacts are // served by internal/dav's CalDAV/CardDAV server, a separate protocol) ────── // linkedProviderFor looks up id, checks it belongs to user (same ownership // check provider()/unifiedInbox already use), and builds its MailProvider. // Unlike provider(), there is no "local account" fallback here — local // calendar/contacts have no REST path, only CalDAV/CardDAV. func (h *Handler) linkedProviderFor(user *db.User, id string) (accounts.MailProvider, error) { if id == "" { return nil, fmt.Errorf("account is required") } acct, err := h.database.GetLinkedAccount(id) if err != nil || acct.UserID != user.ID { return nil, fmt.Errorf("account not found") } return accounts.ProviderFor(acct, h.mk, h.database, h.oauthConfigs) } func (h *Handler) calendarEvents(w http.ResponseWriter, r *http.Request, user *db.User) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } p, err := h.linkedProviderFor(user, r.URL.Query().Get("account")) if err != nil { writeErr(w, http.StatusForbidden, err.Error()) return } cp, ok := p.(accounts.CalendarProvider) if !ok { writeErr(w, http.StatusBadRequest, "this account type does not support calendar access") return } from := time.Now().UTC().AddDate(0, 0, -30) to := time.Now().UTC().AddDate(0, 0, 30) if v := r.URL.Query().Get("from"); v != "" { if t, err := time.Parse(time.RFC3339, v); err == nil { from = t } } if v := r.URL.Query().Get("to"); v != "" { if t, err := time.Parse(time.RFC3339, v); err == nil { to = t } } events, err := cp.ListEvents(r.Context(), from, to) if err != nil { writeErr(w, http.StatusBadGateway, err.Error()) return } writeJSON(w, http.StatusOK, events) } func (h *Handler) contacts(w http.ResponseWriter, r *http.Request, user *db.User) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } p, err := h.linkedProviderFor(user, r.URL.Query().Get("account")) if err != nil { writeErr(w, http.StatusForbidden, err.Error()) return } cp, ok := p.(accounts.ContactProvider) if !ok { writeErr(w, http.StatusBadRequest, "this account type does not support contacts access") return } list, err := cp.ListContacts(r.Context()) if err != nil { writeErr(w, http.StatusBadGateway, err.Error()) return } writeJSON(w, http.StatusOK, list) } // ── Quarantine ──────────────────────────────────────────────────────────────── func (h *Handler) listQuarantine(w http.ResponseWriter, r *http.Request, user *db.User) { entries, err := h.database.QuarantineEntriesForUser(user.Email, time.Now().AddDate(0, 0, -30)) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, entries) } func (h *Handler) releaseQuarantine(w http.ResponseWriter, r *http.Request, user *db.User) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/quarantine/"), "/release") entry, err := h.database.GetQuarantineEntry(id) if err != nil { writeErr(w, http.StatusNotFound, "quarantine entry not found") return } var toAddr string if err := h.database.QueryRow(`SELECT to_address FROM messages WHERE id = ?`, entry.MessageID).Scan(&toAddr); err != nil { writeErr(w, http.StatusNotFound, "underlying message not found") return } if toAddr != user.Email { writeErr(w, http.StatusForbidden, "not your message") return } raw, err := h.store.ReadQuarantineFile(entry.MessageID, entry.EMLPath) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to read quarantined message") return } if _, err := h.store.Deliver(user.ID, user.Email, "INBOX", raw); err != nil { writeErr(w, http.StatusInternalServerError, "failed to deliver released message") return } if err := h.database.ReleaseQuarantineEntry(id, user.Email); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"message": "released"}) } // ── SSE ─────────────────────────────────────────────────────────────────────── // sseEvents streams a countUpdate event whenever the INBOX message count // changes, polling every few seconds — a real push mechanism (fsnotify-style // instant delivery) is a natural follow-up once IMAP IDLE's polling loop is // generalized; this establishes the wire contract webmail's UI codes against // today. func (h *Handler) sseEvents(w http.ResponseWriter, r *http.Request, user *db.User) { flusher, ok := w.(http.Flusher) if !ok { writeErr(w, http.StatusInternalServerError, "streaming unsupported") return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") ctx := r.Context() ticker := time.NewTicker(3 * time.Second) defer ticker.Stop() lastCount := -1 for { select { case <-ctx.Done(): return case <-ticker.C: entries, err := h.database.ListMailboxEntries(user.ID, "INBOX") if err != nil { continue } if len(entries) != lastCount { lastCount = len(entries) fmt.Fprintf(w, "event: countUpdate\ndata: {\"mailbox\":\"INBOX\",\"total\":%d}\n\n", len(entries)) flusher.Flush() } } } } // ── Linked accounts ────────────────────────────────────────────────────────── func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request, user *db.User) { accts, err := h.database.ListLinkedAccounts(user.ID) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } // Never expose CredentialEnc — even encrypted, there's no reason to send // it to the client at all. type safeAccount struct { ID string `json:"id"` Provider string `json:"provider"` DisplayName string `json:"display_name"` EmailAddress string `json:"email_address"` LastSyncAt string `json:"last_sync_at,omitempty"` } out := make([]safeAccount, 0, len(accts)) for _, a := range accts { sa := safeAccount{ID: a.ID, Provider: string(a.Provider), DisplayName: a.DisplayName, EmailAddress: a.EmailAddress} if a.LastSyncAt != nil { sa.LastSyncAt = a.LastSyncAt.Format(time.RFC3339) } out = append(out, sa) } writeJSON(w, http.StatusOK, out) } func (h *Handler) deleteAccount(w http.ResponseWriter, r *http.Request, user *db.User) { if r.Method != http.MethodDelete { w.WriteHeader(http.StatusMethodNotAllowed) return } id := strings.TrimPrefix(r.URL.Path, "/api/accounts/") if id == "" || strings.Contains(id, "/") { http.NotFound(w, r) return } account, err := h.database.GetLinkedAccount(id) if err != nil || account.UserID != user.ID { writeErr(w, http.StatusNotFound, "account not found") return } if err := h.database.DeactivateLinkedAccount(id); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"message": "unlinked"}) } // linkIMAPAccount handles POST /api/accounts/imap — the password-based // counterpart to the OAuth linking flow, for a generic IMAP/SMTP provider. // Wraps accounts.LinkIMAPAccount, which already existed and was already // fully wired for encrypted credential storage; this was the only missing // piece, an HTTP entry point for it. func (h *Handler) linkIMAPAccount(w http.ResponseWriter, r *http.Request, user *db.User) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct { DisplayName string Email string Password string IMAPHost string IMAPPort int IMAPTLS string SMTPHost string SMTPPort int SMTPTLS string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Email == "" || req.Password == "" || req.IMAPHost == "" || req.SMTPHost == "" { writeErr(w, http.StatusBadRequest, "email, password, imap_host, and smtp_host are required") return } if req.DisplayName == "" { req.DisplayName = req.Email } account, err := accounts.LinkIMAPAccount(h.database, h.mk, user.ID, req.DisplayName, req.Email, req.Password, req.IMAPHost, req.IMAPPort, req.IMAPTLS, req.SMTPHost, req.SMTPPort, req.SMTPTLS) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusCreated, map[string]string{"id": account.ID}) } // oauthDispatch routes /api/accounts/oauth/{provider}/start and .../callback. // start requires an authenticated session (checked inline, not via withAuth, // since callback intentionally does NOT require one — it's a plain browser // redirect from the provider with no Authorization header available). func (h *Handler) oauthDispatch(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/accounts/oauth/") parts := strings.SplitN(path, "/", 2) if len(parts) != 2 { http.NotFound(w, r) return } provider, action := parts[0], parts[1] switch action { case "start": h.withAuth(func(w http.ResponseWriter, r *http.Request, user *db.User) { h.oauthStart(w, r, user, provider) })(w, r) case "callback": h.oauthCallback(w, r, provider) default: http.NotFound(w, r) } } func (h *Handler) oauthStart(w http.ResponseWriter, r *http.Request, user *db.User, provider string) { cfg, ok := h.oauthConfigs[provider] if !ok || cfg == nil { writeErr(w, http.StatusServiceUnavailable, fmt.Sprintf("%s OAuth is not configured on this server", provider)) return } state, err := randomState() if err != nil { writeErr(w, http.StatusInternalServerError, "failed to generate state") return } h.oauthStateMu.Lock() h.pruneExpiredState() h.oauthState[state] = oauthStateEntry{UserID: user.ID, Provider: provider, ExpiresAt: time.Now().UTC().Add(10 * time.Minute)} h.oauthStateMu.Unlock() writeJSON(w, http.StatusOK, map[string]string{"auth_url": cfg.BuildAuthURL(state)}) } func (h *Handler) oauthCallback(w http.ResponseWriter, r *http.Request, provider string) { code := r.URL.Query().Get("code") state := r.URL.Query().Get("state") if code == "" || state == "" { writeErr(w, http.StatusBadRequest, "missing code or state") return } h.oauthStateMu.Lock() entry, ok := h.oauthState[state] if ok { delete(h.oauthState, state) // one-time use } h.oauthStateMu.Unlock() if !ok { writeErr(w, http.StatusBadRequest, "invalid or expired state (possible CSRF attempt)") return } if entry.Provider != provider { writeErr(w, http.StatusBadRequest, "state/provider mismatch") return } if time.Now().UTC().After(entry.ExpiresAt) { writeErr(w, http.StatusBadRequest, "state expired, please try linking again") return } cfg, ok := h.oauthConfigs[provider] if !ok || cfg == nil { writeErr(w, http.StatusServiceUnavailable, "provider not configured") return } token, err := cfg.ExchangeCode(r.Context(), code) if err != nil { slog.Error("oauth2 code exchange failed", "provider", provider, "err", err) writeErr(w, http.StatusBadGateway, "failed to exchange authorization code") return } dbProvider := db.ProviderGmail if provider == "microsoft" { dbProvider = db.ProviderM365 } email, err := accounts.FetchOAuth2Email(r.Context(), provider, token.AccessToken) if err != nil { slog.Error("failed to look up account email from provider", "provider", provider, "err", err) writeErr(w, http.StatusBadGateway, "failed to look up account email") return } account, err := accounts.LinkOAuth2Account(h.database, h.mk, entry.UserID, titleCase(provider)+" Account", email, dbProvider, token) if err != nil { slog.Error("failed to store linked OAuth2 account", "err", err) writeErr(w, http.StatusInternalServerError, "failed to link account") return } writeJSON(w, http.StatusOK, map[string]string{"message": "linked", "account_id": account.ID}) } func (h *Handler) pruneExpiredState() { now := time.Now().UTC() for k, v := range h.oauthState { if now.After(v.ExpiresAt) { delete(h.oauthState, k) } } } func randomState() (string, error) { b := make([]byte, 24) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // ── MFA setup/confirm/disable ──────────────────────────────────────────────── func sha256Hex(s string) string { sum := sha256.Sum256([]byte(strings.TrimSpace(strings.ToUpper(s)))) return hex.EncodeToString(sum[:]) } // mfaSetup generates a new TOTP secret and stores it encrypted but NOT yet // enabled — the user must confirm one valid code (mfaConfirm) before MFA // actually takes effect, so an abandoned setup never locks anyone out. func (h *Handler) mfaSetup(w http.ResponseWriter, r *http.Request, user *db.User) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } secret, err := totp.GenerateSecret() if err != nil { writeErr(w, http.StatusInternalServerError, "failed to generate secret") return } encSecret, err := crypto.Encrypt(h.mk, user.ID, "totp-secret", []byte(secret)) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to encrypt secret") return } if err := h.database.SetPendingTOTPSecret(user.ID, encSecret); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } uri := totp.ProvisioningURI(secret, user.Email, "GoMail") writeJSON(w, http.StatusOK, map[string]string{"secret": secret, "provisioning_uri": uri}) } // mfaConfirm verifies one code against the pending secret and, on success, // enables MFA and generates backup codes (shown to the user exactly once). func (h *Handler) mfaConfirm(w http.ResponseWriter, r *http.Request, user *db.User) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct{ Code string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid request body") return } fresh, err := h.database.GetUser(user.ID) if err != nil || fresh.TOTPSecretEnc == nil { writeErr(w, http.StatusBadRequest, "no pending MFA setup — call /api/me/mfa/setup first") return } plain, err := crypto.Decrypt(h.mk, user.ID, "totp-secret", fresh.TOTPSecretEnc) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to decrypt pending secret") return } ok, err := totp.Validate(string(plain), req.Code) if err != nil || !ok { writeErr(w, http.StatusBadRequest, "invalid code") return } backupCodes := make([]string, 8) hashes := make([]string, 8) for i := range backupCodes { raw := make([]byte, 5) rand.Read(raw) code := strings.ToUpper(hex.EncodeToString(raw)) // 10 hex chars, easy to type backupCodes[i] = code hashes[i] = sha256Hex(code) } if err := h.database.ReplaceBackupCodes(user.ID, hashes); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } if err := h.database.SetMFAEnabled(user.ID, true); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"message": "MFA enabled", "backup_codes": backupCodes}) } func (h *Handler) mfaDisable(w http.ResponseWriter, r *http.Request, user *db.User) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct{ Password string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid request body") return } // Require the password again — disabling MFA is high-stakes enough that // a hijacked-but-still-logged-in session shouldn't be able to do it // with just the session token. if _, ok := auth.Authenticate(h.database, user.Email, req.Password, auth.ScopeIMAP); !ok { writeErr(w, http.StatusUnauthorized, "incorrect password") return } if err := h.database.ClearTOTPSecret(user.ID); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } if err := h.database.RecomputeMFAEnabled(user.ID); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"message": "MFA disabled"}) } // ── Passkeys (WebAuthn) ────────────────────────────────────────────────────── // See internal/webauthn's package doc comment for the two deliberate scope // decisions (no attestation verification, ES256/P-256 only). Passkeys are // an alternative second factor alongside TOTP/backup codes — they redeem // the same mfa_pending token mfaVerify does, not a separate login flow. func (h *Handler) origin() string { return "https://" + h.hostname } func loadPasskeys(user *db.User) []webauthn.StoredCredential { var creds []webauthn.StoredCredential if user.PasskeyCredentialsJSON != "" { json.Unmarshal([]byte(user.PasskeyCredentialsJSON), &creds) } return creds } func savePasskeys(database *db.DB, userID string, creds []webauthn.StoredCredential) error { if creds == nil { creds = []webauthn.StoredCredential{} } b, err := json.Marshal(creds) if err != nil { return err } if err := database.SetPasskeyCredentials(userID, string(b)); err != nil { return err } return database.RecomputeMFAEnabled(userID) } func (h *Handler) passkeyRegisterStart(w http.ResponseWriter, r *http.Request, user *db.User) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } challenge, err := webauthn.NewChallenge() if err != nil { writeErr(w, http.StatusInternalServerError, "failed to generate challenge") return } h.webauthnMu.Lock() h.pruneExpiredWebauthnState() h.webauthnState[challenge] = webauthnChallengeEntry{UserID: user.ID, ExpiresAt: time.Now().UTC().Add(5 * time.Minute)} h.webauthnMu.Unlock() writeJSON(w, http.StatusOK, map[string]any{ "rp": map[string]string{"id": h.hostname, "name": "GoMail"}, "user": map[string]string{"id": base64.RawURLEncoding.EncodeToString([]byte(user.ID)), "name": user.Email, "displayName": user.DisplayName}, "challenge": challenge, "pubKeyCredParams": []map[string]any{ {"alg": -7, "type": "public-key"}, // ES256 — see internal/webauthn's scope doc comment }, "timeout": 60000, "attestation": "none", "authenticatorSelection": map[string]string{"userVerification": "preferred"}, }) } func (h *Handler) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request, user *db.User) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct { Challenge string Name string ClientDataJSON string AttestationObject string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid request body") return } h.webauthnMu.Lock() entry, ok := h.webauthnState[req.Challenge] if ok { delete(h.webauthnState, req.Challenge) // one-time use } h.webauthnMu.Unlock() if !ok || entry.UserID != user.ID || time.Now().UTC().After(entry.ExpiresAt) { writeErr(w, http.StatusBadRequest, "invalid or expired registration challenge") return } clientDataJSON, err1 := base64.RawURLEncoding.DecodeString(req.ClientDataJSON) attestationObject, err2 := base64.RawURLEncoding.DecodeString(req.AttestationObject) if err1 != nil || err2 != nil { writeErr(w, http.StatusBadRequest, "invalid base64url encoding") return } authData, err := webauthn.VerifyRegistration(clientDataJSON, attestationObject, req.Challenge, h.hostname, h.origin()) if err != nil { writeErr(w, http.StatusBadRequest, "passkey registration failed: "+err.Error()) return } name := req.Name if name == "" { name = "Passkey" } cred := webauthn.StoredCredential{ ID: base64.RawURLEncoding.EncodeToString(authData.CredentialID), PublicKey: webauthn.EncodePublicKey(authData.PublicKey), SignCount: authData.SignCount, Name: name, CreatedAt: time.Now().UTC(), } // withAuth's own row fetch doesn't select passkey_credentials_json // (most callers don't need it) — GetUser is the canonical full-row // fetch that does; using the withAuth-provided user here would silently // discard every previously registered passkey on each new one. fresh, err := h.database.GetUser(user.ID) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to load user") return } creds := append(loadPasskeys(fresh), cred) if err := savePasskeys(h.database, user.ID, creds); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"message": "passkey added"}) } func (h *Handler) passkeys(w http.ResponseWriter, r *http.Request, user *db.User) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } type safeCred struct { ID string `json:"id"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` } fresh, err := h.database.GetUser(user.ID) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to load user") return } creds := loadPasskeys(fresh) out := make([]safeCred, 0, len(creds)) for _, c := range creds { out = append(out, safeCred{ID: c.ID, Name: c.Name, CreatedAt: c.CreatedAt}) } writeJSON(w, http.StatusOK, out) } func (h *Handler) passkeyByID(w http.ResponseWriter, r *http.Request, user *db.User) { if r.Method != http.MethodDelete { w.WriteHeader(http.StatusMethodNotAllowed) return } id := strings.TrimPrefix(r.URL.Path, "/api/me/passkeys/") fresh, err := h.database.GetUser(user.ID) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to load user") return } creds := loadPasskeys(fresh) kept := make([]webauthn.StoredCredential, 0, len(creds)) found := false for _, c := range creds { if c.ID == id { found = true continue } kept = append(kept, c) } if !found { writeErr(w, http.StatusNotFound, "passkey not found") return } if err := savePasskeys(h.database, user.ID, kept); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"message": "passkey removed"}) } func (h *Handler) pruneExpiredWebauthnState() { now := time.Now().UTC() for k, v := range h.webauthnState { if now.After(v.ExpiresAt) { delete(h.webauthnState, k) } } } // passkeyLoginStart handles POST /api/auth/passkey/start — takes the same // mfa_pending token login() issues, returns a WebAuthn assertion challenge // listing the user's registered credentials. func (h *Handler) passkeyLoginStart(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct{ MFAToken string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid request body") return } claims, err := webtoken.Verify(h.jwtSecret, req.MFAToken) if err != nil || claims.Purpose != "mfa_pending" { writeErr(w, http.StatusUnauthorized, "invalid or expired MFA session") return } user, err := h.database.GetUser(claims.Subject) if err != nil || !user.Active { writeErr(w, http.StatusUnauthorized, "user not found or inactive") return } creds := loadPasskeys(user) if len(creds) == 0 { writeErr(w, http.StatusBadRequest, "no passkeys registered for this account") return } challenge, err := webauthn.NewChallenge() if err != nil { writeErr(w, http.StatusInternalServerError, "failed to generate challenge") return } h.webauthnMu.Lock() h.pruneExpiredWebauthnState() h.webauthnState[challenge] = webauthnChallengeEntry{UserID: user.ID, ExpiresAt: time.Now().UTC().Add(5 * time.Minute)} h.webauthnMu.Unlock() allow := make([]map[string]string, 0, len(creds)) for _, c := range creds { allow = append(allow, map[string]string{"id": c.ID, "type": "public-key"}) } writeJSON(w, http.StatusOK, map[string]any{ "rpId": h.hostname, "challenge": challenge, "timeout": 60000, "userVerification": "preferred", "allowCredentials": allow, }) } // passkeyLoginFinish handles POST /api/auth/passkey/finish — verifies the // assertion and, on success, redeems the mfa_pending token for a real // session exactly like mfaVerify does. func (h *Handler) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct { MFAToken string Challenge string CredentialID string ClientDataJSON string AuthenticatorData string Signature string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid request body") return } claims, err := webtoken.Verify(h.jwtSecret, req.MFAToken) if err != nil || claims.Purpose != "mfa_pending" { writeErr(w, http.StatusUnauthorized, "invalid or expired MFA session") return } h.webauthnMu.Lock() entry, ok := h.webauthnState[req.Challenge] if ok { delete(h.webauthnState, req.Challenge) // one-time use } h.webauthnMu.Unlock() if !ok || entry.UserID != claims.Subject || time.Now().UTC().After(entry.ExpiresAt) { writeErr(w, http.StatusUnauthorized, "invalid or expired passkey challenge") return } user, err := h.database.GetUser(claims.Subject) if err != nil || !user.Active { writeErr(w, http.StatusUnauthorized, "user not found or inactive") return } creds := loadPasskeys(user) idx := -1 for i, c := range creds { if c.ID == req.CredentialID { idx = i break } } if idx == -1 { writeErr(w, http.StatusUnauthorized, "unknown credential") return } clientDataJSON, err1 := base64.RawURLEncoding.DecodeString(req.ClientDataJSON) authenticatorData, err2 := base64.RawURLEncoding.DecodeString(req.AuthenticatorData) signature, err3 := base64.RawURLEncoding.DecodeString(req.Signature) if err1 != nil || err2 != nil || err3 != nil { writeErr(w, http.StatusBadRequest, "invalid base64url encoding") return } newSignCount, err := webauthn.VerifyAssertion(creds[idx], clientDataJSON, authenticatorData, signature, req.Challenge, h.hostname, h.origin()) if err != nil { writeErr(w, http.StatusUnauthorized, "passkey verification failed: "+err.Error()) return } creds[idx].SignCount = newSignCount if err := savePasskeys(h.database, user.ID, creds); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } token, err := webtoken.Issue(h.jwtSecret, user.ID, user.TenantID, string(user.Role), sessionTTL) if err != nil { writeErr(w, http.StatusInternalServerError, "token generation failed") return } h.database.Exec(`UPDATE users SET last_login_at = ? WHERE id = ?`, time.Now().UTC(), user.ID) writeJSON(w, http.StatusOK, map[string]any{ "token": token, "user": map[string]any{"id": user.ID, "email": user.Email, "display_name": user.DisplayName}, }) } // ── App passwords ───────────────────────────────────────────────────────────── func (h *Handler) appPasswords(w http.ResponseWriter, r *http.Request, user *db.User) { switch r.Method { case http.MethodGet: rows, err := h.database.Query(`SELECT id, label, scopes, last_used_at, expires_at, created_at FROM app_passwords WHERE user_id = ? ORDER BY created_at DESC`, user.ID) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } defer rows.Close() type entry struct { ID, Label, Scopes string LastUsedAt, ExpiresAt *time.Time CreatedAt time.Time } var out []entry for rows.Next() { var e entry if err := rows.Scan(&e.ID, &e.Label, &e.Scopes, &e.LastUsedAt, &e.ExpiresAt, &e.CreatedAt); err != nil { continue } out = append(out, e) } writeJSON(w, http.StatusOK, out) case http.MethodPost: var req struct { Label string Scopes string ExpiresIn string // e.g. "30d", "" = never } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Label == "" { writeErr(w, http.StatusBadRequest, "label is required") return } if req.Scopes == "" { req.Scopes = "smtp,imap" } raw := make([]byte, 24) rand.Read(raw) token := strings.ToUpper(hex.EncodeToString(raw)) hash, err := bcrypt.GenerateFromPassword([]byte(token), 12) if err != nil { writeErr(w, http.StatusInternalServerError, "hashing failed") return } var expiresAt *time.Time if req.ExpiresIn != "" { d, err := parseDuration(req.ExpiresIn) if err != nil { writeErr(w, http.StatusBadRequest, "invalid expires_in format (use e.g. '30d', '90d')") return } t := time.Now().UTC().Add(d) expiresAt = &t } id := uuid.NewString() _, err = h.database.Exec(`INSERT INTO app_passwords (id, user_id, label, password_hash, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?)`, id, user.ID, req.Label, string(hash), req.Scopes, expiresAt) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusCreated, map[string]string{"id": id, "token": token}) // token shown exactly once default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (h *Handler) appPasswordByID(w http.ResponseWriter, r *http.Request, user *db.User) { if r.Method != http.MethodDelete { w.WriteHeader(http.StatusMethodNotAllowed) return } id := strings.TrimPrefix(r.URL.Path, "/api/me/app-passwords/") res, err := h.database.Exec(`DELETE FROM app_passwords WHERE id = ? AND user_id = ?`, id, user.ID) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } if n, _ := res.RowsAffected(); n == 0 { writeErr(w, http.StatusNotFound, "app password not found") return } writeJSON(w, http.StatusOK, map[string]string{"message": "revoked"}) } func parseDuration(s string) (time.Duration, error) { if strings.HasSuffix(s, "d") { var days int if _, err := fmt.Sscanf(s, "%dd", &days); err != nil { return 0, err } return time.Duration(days) * 24 * time.Hour, nil } return time.ParseDuration(s) } // ── Password reset (recovery-email based) ──────────────────────────────────── // forgotPassword always returns 200 regardless of whether the email // matches an account or that account has a recovery email configured — // leaking account existence via response differences is exactly what this // guards against. func (h *Handler) forgotPassword(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct{ Email string } json.NewDecoder(r.Body).Decode(&req) user, err := h.database.LookupUserByEmail(req.Email) if err == nil && user.RecoveryEmail != "" { fingerprint := webtoken.Fingerprint(user.PasswordHash) resetToken, tokErr := webtoken.IssueResetToken(h.jwtSecret, user.ID, user.TenantID, string(user.Role), fingerprint, 1*time.Hour) if tokErr == nil { body := fmt.Sprintf("A password reset was requested for your GoMail account (%s).\r\n\r\n"+ "Reset token (valid 1 hour): %s\r\n\r\n"+ "If you didn't request this, you can safely ignore this message.\r\n", user.Email, resetToken) raw := []byte(fmt.Sprintf("From: noreply@gomail\r\nTo: %s\r\nSubject: GoMail password reset\r\n\r\n%s", user.RecoveryEmail, body)) if _, queuePath, qErr := h.store.WriteQueueFile(raw); qErr == nil { h.database.InsertOutboundQueueEntry(&db.OutboundQueueEntry{ ID: uuid.NewString(), UserID: user.ID, FromAddress: "noreply@" + strings.SplitN(user.Email, "@", 2)[1], ToAddress: user.RecoveryEmail, EMLPath: queuePath, NextAttemptAt: time.Now().UTC(), }) } } } writeJSON(w, http.StatusOK, map[string]string{"message": "if an account with recovery email configured exists, a reset link has been sent"}) } func (h *Handler) resetPassword(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct{ Token, NewPassword string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.NewPassword) < 8 { writeErr(w, http.StatusBadRequest, "new_password must be at least 8 characters") return } claims, err := webtoken.Verify(h.jwtSecret, req.Token) if err != nil || claims.Purpose != "password_reset" { writeErr(w, http.StatusBadRequest, "invalid or expired reset token") return } current, err := h.database.GetUser(claims.Subject) if err != nil || !webtoken.FingerprintMatches(claims, current.PasswordHash) { // Either the user no longer exists, or the password has already // been changed since this token was issued (including via a prior // use of this same token) — reject either way, single-use enforced. writeErr(w, http.StatusBadRequest, "invalid or expired reset token") return } hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), 12) if err != nil { writeErr(w, http.StatusInternalServerError, "hashing failed") return } if err := h.database.SetUserPassword(claims.Subject, string(hash)); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"message": "password reset successful"}) } func (h *Handler) setRecoveryEmail(w http.ResponseWriter, r *http.Request, user *db.User) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct{ RecoveryEmail string } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid request body") return } if err := h.database.SetRecoveryEmail(user.ID, req.RecoveryEmail); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"message": "recovery email updated"}) }