first commit

This commit is contained in:
2026-08-09 18:03:09 +01:00
commit d7ca591b76
169 changed files with 51272 additions and 0 deletions
+958
View File
@@ -0,0 +1,958 @@
// 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/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"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/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
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
}
type oauthStateEntry struct {
UserID string
Provider string
ExpiresAt time.Time
}
func NewHandler(database *db.DB, store *mailstore.Store, mk *crypto.MasterKey, jwtSecret string, oauthConfigs map[string]*oauth2.Config) *Handler {
return &Handler{
database: database, store: store, mk: mk, jwtSecret: jwtSecret,
oauthConfigs: oauthConfigs,
oauthState: make(map[string]oauthStateEntry),
}
}
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/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/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/", 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) {
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID, "email": user.Email, "display_name": user.DisplayName, "role": user.Role,
})
}
// ── Folders & messages ──────────────────────────────────────────────────────────
func (h *Handler) provider(user *db.User) *accounts.GoMailProvider {
return accounts.NewGoMailProvider(h.database, h.store, user)
}
func (h *Handler) listFolders(w http.ResponseWriter, r *http.Request, user *db.User) {
folders, err := h.provider(user).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)
}
headers, err := h.provider(user).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
}
msg := &accounts.OutgoingMessage{From: user.Email, To: req.To, CC: req.CC, Subject: req.Subject, Body: req.Body}
if err := h.provider(user).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 := h.provider(user)
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)
}
}
// ── 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"})
}
// 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
}
// Note: a real implementation would call the provider's userinfo/profile
// endpoint here to learn the account's actual email address rather than
// require it as a query param — deferred; for now the display name is
// generic and the operator/user can rename it, matching the minimum
// needed to prove the OAuth2 flow itself is correct end-to-end.
email := r.URL.Query().Get("email")
if email == "" {
email = provider + "-account"
}
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
}
writeJSON(w, http.StatusOK, map[string]string{"message": "MFA disabled"})
}
// ── 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"})
}
+6
View File
@@ -0,0 +1,6 @@
package webmail
import "embed"
//go:embed static/index.html
var StaticFS embed.FS
+203
View File
@@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GoMail</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body{background:#0f172a;color:#e2e8f0;font-family:system-ui,-apple-system,sans-serif;margin:0}
.sidebar{width:220px;background:#1e293b;border-right:1px solid #334155;min-height:100vh;position:fixed;top:0;left:0;bottom:0}
.main{margin-left:220px;display:flex;min-height:100vh}
.msg-list{width:340px;border-right:1px solid #334155;overflow-y:auto}
.msg-view{flex:1;padding:24px;overflow-y:auto}
.nav-item{padding:9px 16px;cursor:pointer;font-size:13px;color:#94a3b8;border-radius:8px;margin:2px 8px}
.nav-item:hover{background:#334155}
.nav-item.active{background:#7c3aed22;color:#a78bfa}
.msg-row{padding:12px 16px;border-bottom:1px solid #1e293b;cursor:pointer;font-size:13px}
.msg-row:hover{background:#1e293b80}
.msg-row.unread{font-weight:600}
.btn{padding:7px 14px;border-radius:7px;font-size:13px;font-weight:500;cursor:pointer;border:none}
.btn-primary{background:#7c3aed;color:#fff}
.btn-ghost{background:transparent;color:#94a3b8;border:1px solid #334155}
.inp{background:#0f172a;border:1px solid #334155;border-radius:7px;padding:8px 12px;color:#e2e8f0;font-size:13px;width:100%}
.modal-bg{position:fixed;inset:0;background:#00000088;z-index:50;display:flex;align-items:center;justify-content:center}
.modal{background:#1e293b;border:1px solid #334155;border-radius:14px;padding:24px;width:560px;max-width:95vw}
.badge{padding:2px 8px;border-radius:10px;font-size:11px}
</style>
</head>
<body>
<div id="login" style="display:none;min-height:100vh;align-items:center;justify-content:center" class="flex">
<div style="background:#1e293b;border:1px solid #334155;border-radius:14px;padding:28px;width:320px">
<div style="text-align:center;margin-bottom:20px"><div style="font-size:2.5rem">📧</div>
<h1 style="font-weight:700;color:#fff">GoMail</h1></div>
<input id="le" class="inp" placeholder="you@example.com" style="margin-bottom:10px">
<input id="lp" type="password" class="inp" placeholder="Password" style="margin-bottom:10px" onkeydown="if(event.key==='Enter')login()">
<button onclick="login()" class="btn btn-primary" style="width:100%">Sign in</button>
<p id="lerr" style="display:none;color:#f87171;font-size:12px;text-align:center;margin-top:10px"></p>
</div>
</div>
<div id="app" style="display:none">
<aside class="sidebar">
<div style="padding:16px;border-bottom:1px solid #334155;font-weight:700;color:#fff">📧 GoMail</div>
<div style="padding:12px 8px">
<button onclick="openCompose()" class="btn btn-primary" style="width:100%;margin-bottom:12px">✎ Compose</button>
<div id="folder-list"></div>
<div class="nav-item" onclick="showQuarantine()" id="nav-quarantine" style="margin-top:8px">🔒 Quarantine</div>
</div>
<div style="position:absolute;bottom:0;padding:12px;border-top:1px solid #334155;width:100%;box-sizing:border-box">
<span id="me-email" style="font-size:12px;color:#64748b"></span>
<button onclick="logout()" style="float:right;font-size:11px;color:#475569;background:none;border:none;cursor:pointer">Logout</button>
</div>
</aside>
<main class="main">
<div id="view-mail" style="display:flex;flex:1">
<div class="msg-list" id="msg-list"></div>
<div class="msg-view" id="msg-view"><div style="color:#475569;text-align:center;margin-top:60px">Select a message</div></div>
</div>
<div id="view-quarantine" style="display:none;flex:1;padding:24px">
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Quarantine</h2>
<div id="quarantine-list"></div>
</div>
</main>
</div>
<div id="compose-modal" class="modal-bg" style="display:none">
<div class="modal">
<h3 style="color:#fff;font-weight:700;margin-bottom:16px">New Message</h3>
<input id="c-to" class="inp" placeholder="To" style="margin-bottom:8px">
<input id="c-subject" class="inp" placeholder="Subject" style="margin-bottom:8px">
<textarea id="c-body" class="inp" rows="8" placeholder="Message..." style="margin-bottom:12px"></textarea>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button onclick="closeCompose()" class="btn btn-ghost">Cancel</button>
<button onclick="sendMessage()" class="btn btn-primary">Send</button>
</div>
</div>
</div>
<script>
const API='/api';
let token=localStorage.getItem('gomail_token')||'';
let currentFolder='INBOX';
async function api(path,opts={}){
const r=await fetch(API+path,{...opts,headers:{'Content-Type':'application/json','Authorization':'Bearer '+token,...(opts.headers||{})}});
if(r.status===401){showLogin();return null;}
return r.ok?r.json():Promise.reject(await r.json());
}
async function login(){
const email=document.getElementById('le').value,pwd=document.getElementById('lp').value;
try{
const d=await fetch(API+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({Email:email,Password:pwd})}).then(r=>r.json());
if(d.error)throw new Error(d.error);
token=d.token;localStorage.setItem('gomail_token',token);
showApp();
}catch(e){const el=document.getElementById('lerr');el.textContent=e.message||'Login failed';el.style.display='';}
}
function logout(){localStorage.removeItem('gomail_token');token='';showLogin();}
function showLogin(){document.getElementById('login').style.display='flex';document.getElementById('app').style.display='none';}
async function showApp(){
document.getElementById('login').style.display='none';document.getElementById('app').style.display='block';
const me=await api('/me');if(!me)return;
document.getElementById('me-email').textContent=me.email;
loadFolders();
}
async function loadFolders(){
const folders=await api('/folders');if(!folders)return;
document.getElementById('folder-list').innerHTML=folders.map(f=>
`<div class="nav-item ${f.id===currentFolder?'active':''}" onclick="selectFolder('${f.id}')">
${f.display_name} ${f.unread_count>0?`<span class="badge" style="background:#7c3aed;color:#fff">${f.unread_count}</span>`:''}
</div>`).join('');
loadMessages(currentFolder);
}
function selectFolder(id){
currentFolder=id;
document.getElementById('view-mail').style.display='flex';
document.getElementById('view-quarantine').style.display='none';
loadFolders();
}
async function loadMessages(folderID){
const msgs=await api('/folders/'+folderID+'/messages');if(!msgs)return;
document.getElementById('msg-list').innerHTML=msgs.length?msgs.map(m=>{
const unread=!(m.Flags||[]).includes('\\Seen');
return `<div class="msg-row ${unread?'unread':''}" onclick="viewMessage('${folderID}','${m.ID}')">
<div style="color:#e2e8f0">${esc(m.From||'(unknown)')}</div>
<div style="color:#94a3b8">${esc(m.Subject||'(no subject)')}</div>
</div>`;
}).join(''):'<div style="padding:20px;color:#475569;text-align:center">No messages</div>';
}
async function viewMessage(folderID,id){
const msg=await api('/messages/'+folderID+'/'+id);if(!msg)return;
document.getElementById('msg-view').innerHTML=`
<div style="border-bottom:1px solid #334155;padding-bottom:12px;margin-bottom:12px">
<div style="font-size:18px;font-weight:700;color:#fff">${esc(msg.Subject||'(no subject)')}</div>
<div style="color:#94a3b8;font-size:13px;margin-top:4px">From: ${esc(msg.From)}</div>
<div style="color:#94a3b8;font-size:13px">To: ${esc(msg.To)}</div>
</div>
<pre style="white-space:pre-wrap;font-family:inherit;color:#cbd5e1;font-size:13px">${esc(bodyOf(msg.Raw))}</pre>
<div style="margin-top:16px">
<button onclick="deleteMessage('${folderID}','${id}')" class="btn btn-ghost">🗑 Delete</button>
</div>`;
api('/messages/'+folderID+'/'+id+'/flags',{method:'PUT',body:JSON.stringify({Flags:['\\Seen']})});
}
function bodyOf(raw){
if(!raw)return'';
const decoded=atob(raw);
const idx=decoded.indexOf('\r\n\r\n');
return idx>=0?decoded.slice(idx+4):decoded;
}
async function deleteMessage(folderID,id){
await api('/messages/'+folderID+'/'+id,{method:'DELETE'});
loadMessages(folderID);
document.getElementById('msg-view').innerHTML='<div style="color:#475569;text-align:center;margin-top:60px">Select a message</div>';
}
function openCompose(){document.getElementById('compose-modal').style.display='flex';}
function closeCompose(){document.getElementById('compose-modal').style.display='none';}
async function sendMessage(){
const to=document.getElementById('c-to').value.split(',').map(s=>s.trim());
const subject=document.getElementById('c-subject').value;
const body=document.getElementById('c-body').value;
try{
await api('/messages',{method:'POST',body:JSON.stringify({to,subject,body})});
closeCompose();
document.getElementById('c-to').value='';document.getElementById('c-subject').value='';document.getElementById('c-body').value='';
}catch(e){alert('Send failed: '+(e.error||e.message));}
}
async function showQuarantine(){
document.getElementById('view-mail').style.display='none';
document.getElementById('view-quarantine').style.display='block';
const entries=await api('/quarantine');if(!entries)return;
document.getElementById('quarantine-list').innerHTML=entries.length?entries.map(e=>`
<div style="background:#1e293b;border:1px solid #334155;border-radius:10px;padding:14px;margin-bottom:10px;display:flex;justify-content:space-between;align-items:center">
<div><div style="color:#e2e8f0;font-size:13px">Reason: ${esc(e.Reason||'—')}</div>
<div style="color:#64748b;font-size:12px">Held: ${e.CreatedAt}</div></div>
<button onclick="releaseQ('${e.ID}')" class="btn btn-primary">Release</button>
</div>`).join(''):'<div style="color:#475569;text-align:center;padding:40px">🎉 Nothing held</div>';
}
async function releaseQ(id){
try{await api('/quarantine/'+id+'/release',{method:'POST'});showQuarantine();}
catch(e){alert('Release failed: '+(e.error||e.message));}
}
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
async function boot(){
if(!token){showLogin();return;}
try{const me=await api('/me');if(me)showApp();else showLogin();}catch{showLogin();}
}
boot();
</script>
</body>
</html>