2026-08-09 18:03:09 +01:00
// 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"
2026-08-10 21:15:19 +01:00
"encoding/base64"
2026-08-09 18:03:09 +01:00
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
2026-08-10 21:15:19 +01:00
"net/mail"
"sort"
2026-08-09 18:03:09 +01:00
"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"
2026-08-10 21:15:19 +01:00
"gomail/internal/webauthn"
2026-08-09 18:03:09 +01:00
"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
2026-08-10 21:15:19 +01:00
hostname string // WebAuthn RP ID; origin is "https://"+hostname
2026-08-09 18:03:09 +01:00
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
2026-08-10 21:15:19 +01:00
webauthnMu sync . Mutex
webauthnState map [ string ] webauthnChallengeEntry // challenge -> pending registration/assertion, same shape as oauthState
2026-08-09 18:03:09 +01:00
}
type oauthStateEntry struct {
UserID string
Provider string
ExpiresAt time . Time
}
2026-08-10 21:15:19 +01:00
// 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 {
2026-08-09 18:03:09 +01:00
return & Handler {
2026-08-10 21:15:19 +01:00
database : database , store : store , mk : mk , jwtSecret : jwtSecret , hostname : hostname ,
oauthConfigs : oauthConfigs ,
oauthState : make ( map [ string ] oauthStateEntry ),
webauthnState : make ( map [ string ] webauthnChallengeEntry ),
2026-08-09 18:03:09 +01:00
}
}
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 ))
2026-08-10 21:15:19 +01:00
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 )
2026-08-09 18:03:09 +01:00
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 ))
2026-08-10 21:15:19 +01:00
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 ))
2026-08-09 18:03:09 +01:00
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)
2026-08-10 21:15:19 +01:00
mux . HandleFunc ( "/api/accounts/imap" , h . withAuth ( h . linkIMAPAccount ))
2026-08-09 18:03:09 +01:00
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 ) {
2026-08-10 21:15:19 +01:00
// 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
}
2026-08-09 18:03:09 +01:00
writeJSON ( w , http . StatusOK , map [ string ] any {
2026-08-10 21:15:19 +01:00
"id" : fresh . ID , "email" : fresh . Email , "display_name" : fresh . DisplayName , "role" : fresh . Role ,
"mfa_enabled" : fresh . MFAEnabled , "recovery_email" : fresh . RecoveryEmail ,
2026-08-09 18:03:09 +01:00
})
}
// ── Folders & messages ──────────────────────────────────────────────────────────
2026-08-10 21:15:19 +01:00
// 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=<linked-account-id>, 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 )
2026-08-09 18:03:09 +01:00
}
func ( h * Handler ) listFolders ( w http . ResponseWriter , r * http . Request , user * db . User ) {
2026-08-10 21:15:19 +01:00
p , err := h . provider ( r , user )
if err != nil {
writeErr ( w , http . StatusForbidden , err . Error ())
return
}
folders , err := p . ListFolders ( r . Context ())
2026-08-09 18:03:09 +01:00
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 )
}
2026-08-10 21:15:19 +01:00
p , err := h . provider ( r , user )
if err != nil {
writeErr ( w , http . StatusForbidden , err . Error ())
return
}
headers , err := p . ListMessages ( r . Context (), folderID , opts )
2026-08-09 18:03:09 +01:00
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
}
2026-08-10 21:15:19 +01:00
p , err := h . provider ( r , user )
if err != nil {
writeErr ( w , http . StatusForbidden , err . Error ())
return
}
2026-08-09 18:03:09 +01:00
msg := & accounts . OutgoingMessage { From : user . Email , To : req . To , CC : req . CC , Subject : req . Subject , Body : req . Body }
2026-08-10 21:15:19 +01:00
if err := p . SendMessage ( r . Context (), msg ); err != nil {
2026-08-09 18:03:09 +01:00
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 ]
}
2026-08-10 21:15:19 +01:00
p , err := h . provider ( r , user )
if err != nil {
writeErr ( w , http . StatusForbidden , err . Error ())
return
}
2026-08-09 18:03:09 +01:00
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 )
}
}
2026-08-10 21:15:19 +01:00
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 )
}
2026-08-09 18:03:09 +01:00
// ── 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" })
}
2026-08-10 21:15:19 +01:00
// 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 })
}
2026-08-09 18:03:09 +01:00
// 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
}
2026-08-10 21:15:19 +01:00
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
2026-08-09 18:03:09 +01:00
}
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
}
2026-08-10 21:15:19 +01:00
if err := h . database . RecomputeMFAEnabled ( user . ID ); err != nil {
writeErr ( w , http . StatusInternalServerError , err . Error ())
return
}
2026-08-09 18:03:09 +01:00
writeJSON ( w , http . StatusOK , map [ string ] string { "message" : "MFA disabled" })
}
2026-08-10 21:15:19 +01:00
// ── 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 },
})
}
2026-08-09 18:03:09 +01:00
// ── 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" })
}