This commit is contained in:
2026-08-10 21:15:19 +01:00
parent d7ca591b76
commit 4da942786e
97 changed files with 105039 additions and 3370 deletions
+23 -11
View File
@@ -43,23 +43,35 @@ func Open(driver, dsn string) (*DB, error) {
sqlDriverName = name
}
if d == "sqlite" {
// Set these as mattn/go-sqlite3 DSN params, not a post-open PRAGMA
// Exec: with more than one pooled connection, each new physical
// connection database/sql opens is a fresh SQLite connection that
// does NOT inherit a PRAGMA set on a different one (journal_mode is
// the one exception — it's persisted in the DB file itself).
// _foreign_keys and _busy_timeout must be per-connection, so they
// have to ride in the DSN to apply to every connection the pool
// ever opens, not just the first.
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
dsn += sep + "_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000"
}
sqlDB, err := sql.Open(sqlDriverName, dsn)
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}
if d == "sqlite" {
// SQLite doesn't handle concurrent writers well — serialize via single conn.
sqlDB.SetMaxOpenConns(1)
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
return nil, fmt.Errorf("enabling WAL mode: %w", err)
}
if _, err := sqlDB.Exec("PRAGMA foreign_keys=ON"); err != nil {
return nil, fmt.Errorf("enabling foreign keys: %w", err)
}
if _, err := sqlDB.Exec("PRAGMA busy_timeout=5000"); err != nil {
return nil, fmt.Errorf("setting busy timeout: %w", err)
}
// WAL mode already lets SQLite itself handle concurrent readers +
// one writer (with _busy_timeout above covering writer contention) —
// a small pool, not a single shared connection, so concurrent
// requests across SMTP/IMAP/webmail/admin/etc. aren't all serialized
// through one connection for no reason.
sqlDB.SetMaxOpenConns(4)
sqlDB.SetMaxIdleConns(4)
}
if err := sqlDB.Ping(); err != nil {
+36
View File
@@ -368,6 +368,42 @@ CREATE TABLE mfa_backup_codes (
CREATE INDEX idx_mfa_backup_codes_user ON mfa_backup_codes(user_id);
ALTER TABLE users ADD COLUMN recovery_email TEXT;
`,
},
},
{
// Encrypted header summary (From/To/Subject/Date), populated at
// delivery time so folder listing can decrypt a few hundred bytes
// instead of the full message body. NULL for messages delivered
// before this migration — ListMessages falls back to a full read
// for those, see accounts.GoMailProvider.ListMessages.
name: "0012_mailbox_header_cache",
sql: map[string]string{
"sqlite": `
ALTER TABLE mailbox_index ADD COLUMN header_enc BLOB;
`,
},
},
{
// Cached MTA-STS policy per recipient domain (RFC 8461) — not
// encrypted, a domain's mail policy is public data at the same
// trust level as its SPF/DMARC/MX records, none of which are
// encrypted either. Cached because the RFC requires honoring the
// policy's own max_age rather than re-fetching on every message.
name: "0013_mta_sts_policies",
sql: map[string]string{
"sqlite": `
CREATE TABLE mta_sts_policies (
id TEXT PRIMARY KEY,
domain TEXT UNIQUE NOT NULL,
policy_id TEXT NOT NULL,
mode TEXT NOT NULL,
mx_patterns TEXT NOT NULL,
max_age INTEGER NOT NULL,
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL
);
CREATE INDEX idx_mta_sts_policies_domain ON mta_sts_policies(domain);
`,
},
},
+15
View File
@@ -144,6 +144,7 @@ type MailboxEntry struct {
SizeBytes int64
ReceivedAt time.Time
InternalDate time.Time
HeaderEnc []byte // encrypted CachedHeader JSON; nil for pre-migration rows
}
// ── Outbound queue ───────────────────────────────────────────────────────────
@@ -342,6 +343,20 @@ type TLSCert struct {
UpdatedAt time.Time
}
// MTASTSPolicy is a cached RFC 8461 policy for a recipient domain, keyed by
// domain — not encrypted, a domain's published mail policy is public data
// (same trust level as its SPF/DMARC/MX records).
type MTASTSPolicy struct {
ID string
Domain string
PolicyID string
Mode string
MXPatterns string // JSON array
MaxAge int // seconds
FetchedAt time.Time
ExpiresAt time.Time
}
// ── MFA ───────────────────────────────────────────────────────────────────────
type MFABackupCode struct {
+79 -10
View File
@@ -288,9 +288,9 @@ func (db *DB) NextMailboxUID(userID, mailbox string) (int, error) {
// InsertMailboxEntry records a delivered message in a user's mailbox index.
func (db *DB) InsertMailboxEntry(e *MailboxEntry) error {
_, err := db.Exec(`
INSERT INTO mailbox_index (id, user_id, mailbox, uid, eml_path, flags, size_bytes, received_at, internal_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, e.ID, e.UserID, e.Mailbox, e.UID, e.EMLPath, e.Flags, e.SizeBytes, e.ReceivedAt, e.InternalDate)
INSERT INTO mailbox_index (id, user_id, mailbox, uid, eml_path, flags, size_bytes, received_at, internal_date, header_enc)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, e.ID, e.UserID, e.Mailbox, e.UID, e.EMLPath, e.Flags, e.SizeBytes, e.ReceivedAt, e.InternalDate, e.HeaderEnc)
if err != nil {
return fmt.Errorf("insert mailbox entry: %w", err)
}
@@ -301,7 +301,7 @@ func (db *DB) InsertMailboxEntry(e *MailboxEntry) error {
// UID ascending — the order IMAP sequence numbers are defined against.
func (db *DB) ListMailboxEntries(userID, mailbox string) ([]MailboxEntry, error) {
rows, err := db.Query(`
SELECT id, user_id, mailbox, uid, eml_path, flags, size_bytes, received_at, internal_date
SELECT id, user_id, mailbox, uid, eml_path, flags, size_bytes, received_at, internal_date, header_enc
FROM mailbox_index WHERE user_id = ? AND mailbox = ? ORDER BY uid ASC
`, userID, mailbox)
if err != nil {
@@ -312,7 +312,7 @@ func (db *DB) ListMailboxEntries(userID, mailbox string) ([]MailboxEntry, error)
var entries []MailboxEntry
for rows.Next() {
var e MailboxEntry
if err := rows.Scan(&e.ID, &e.UserID, &e.Mailbox, &e.UID, &e.EMLPath, &e.Flags, &e.SizeBytes, &e.ReceivedAt, &e.InternalDate); err != nil {
if err := rows.Scan(&e.ID, &e.UserID, &e.Mailbox, &e.UID, &e.EMLPath, &e.Flags, &e.SizeBytes, &e.ReceivedAt, &e.InternalDate, &e.HeaderEnc); err != nil {
continue
}
entries = append(entries, e)
@@ -584,11 +584,11 @@ func (db *DB) DeleteUser(id string) error {
func (db *DB) GetUser(id string) (*User, error) {
row := db.QueryRow(`SELECT id, tenant_id, domain_id, email, password_hash, display_name, role, active,
mfa_enabled, totp_secret_enc, recovery_email FROM users WHERE id = ?`, id)
mfa_enabled, totp_secret_enc, passkey_credentials_json, recovery_email FROM users WHERE id = ?`, id)
var u User
var displayName, recoveryEmail sql.NullString
err := row.Scan(&u.ID, &u.TenantID, &u.DomainID, &u.Email, &u.PasswordHash, &displayName, &u.Role, &u.Active,
&u.MFAEnabled, &u.TOTPSecretEnc, &recoveryEmail)
&u.MFAEnabled, &u.TOTPSecretEnc, &u.PasskeyCredentialsJSON, &recoveryEmail)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
@@ -1009,6 +1009,38 @@ func (db *DB) SetACMEAccountKey(domain string, keyEnc []byte) error {
return err
}
// ── MTA-STS ──────────────────────────────────────────────────────────────────
// GetMTASTSPolicy returns the cached policy for domain, or ErrNotFound if
// none is cached (including an expired one — callers re-fetch on expiry,
// there is no separate "expired but present" state to distinguish).
func (db *DB) GetMTASTSPolicy(domain string) (*MTASTSPolicy, error) {
row := db.QueryRow(`SELECT id, domain, policy_id, mode, mx_patterns, max_age, fetched_at, expires_at
FROM mta_sts_policies WHERE domain = ?`, domain)
var p MTASTSPolicy
err := row.Scan(&p.ID, &p.Domain, &p.PolicyID, &p.Mode, &p.MXPatterns, &p.MaxAge, &p.FetchedAt, &p.ExpiresAt)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get mta-sts policy: %w", err)
}
return &p, nil
}
// UpsertMTASTSPolicy creates or updates the cached policy for p.Domain.
func (db *DB) UpsertMTASTSPolicy(p *MTASTSPolicy) error {
existing, err := db.GetMTASTSPolicy(p.Domain)
if err == nil {
_, err := db.Exec(`UPDATE mta_sts_policies SET policy_id = ?, mode = ?, mx_patterns = ?, max_age = ?, fetched_at = ?, expires_at = ? WHERE id = ?`,
p.PolicyID, p.Mode, p.MXPatterns, p.MaxAge, p.FetchedAt, p.ExpiresAt, existing.ID)
return err
}
_, err = db.Exec(`INSERT INTO mta_sts_policies (id, domain, policy_id, mode, mx_patterns, max_age, fetched_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
uuidNew(), p.Domain, p.PolicyID, p.Mode, p.MXPatterns, p.MaxAge, p.FetchedAt, p.ExpiresAt)
return err
}
// ── MFA ───────────────────────────────────────────────────────────────────────
// SetPendingTOTPSecret stores an encrypted TOTP secret WITHOUT enabling
@@ -1024,11 +1056,39 @@ func (db *DB) SetMFAEnabled(userID string, enabled bool) error {
return err
}
// ClearTOTPSecret removes TOTP specifically — it does NOT touch
// mfa_enabled, since a user with passkeys registered must keep MFA
// enabled after disabling just TOTP. Callers should follow this with
// RecomputeMFAEnabled.
func (db *DB) ClearTOTPSecret(userID string) error {
_, err := db.Exec(`UPDATE users SET mfa_enabled = 0, totp_secret_enc = NULL WHERE id = ?`, userID)
_, err := db.Exec(`UPDATE users SET totp_secret_enc = NULL WHERE id = ?`, userID)
return err
}
// SetPasskeyCredentials replaces the user's stored passkey credential list
// (a JSON array — see webauthn.StoredCredential) wholesale; callers read-
// modify-write the current list rather than this doing any partial update.
func (db *DB) SetPasskeyCredentials(userID, credentialsJSON string) error {
_, err := db.Exec(`UPDATE users SET passkey_credentials_json = ? WHERE id = ?`, credentialsJSON, userID)
return err
}
// RecomputeMFAEnabled sets mfa_enabled based on whether the user currently
// has any working second factor (TOTP confirmed, or at least one passkey)
// — called after ClearTOTPSecret or a passkey deletion, so removing one
// factor while the other remains doesn't silently disable the MFA
// requirement, and removing the last factor doesn't silently leave it
// enabled with nothing to satisfy it.
func (db *DB) RecomputeMFAEnabled(userID string) error {
user, err := db.GetUser(userID)
if err != nil {
return err
}
hasTOTP := user.TOTPSecretEnc != nil
hasPasskey := user.PasskeyCredentialsJSON != "" && user.PasskeyCredentialsJSON != "[]"
return db.SetMFAEnabled(userID, hasTOTP || hasPasskey)
}
// ReplaceBackupCodes deletes any existing backup codes for the user and
// inserts a fresh set — called once at MFA confirm time; codes are shown to
// the user exactly once, matching how app passwords are handled.
@@ -1078,10 +1138,19 @@ func (db *DB) ConsumeBackupCode(userID, candidateHash string) (bool, error) {
if matchID == "" {
return false, nil
}
if _, err := db.Exec(`UPDATE mfa_backup_codes SET used_at = ? WHERE id = ?`, time.Now().UTC(), matchID); err != nil {
// AND used_at IS NULL + RowsAffected close the race: without it, two
// concurrent requests could both pass the SELECT scan above for the
// same code and both "successfully" consume it once MaxOpenConns > 1
// lets them run concurrently instead of serializing by accident.
res, err := db.Exec(`UPDATE mfa_backup_codes SET used_at = ? WHERE id = ? AND used_at IS NULL`, time.Now().UTC(), matchID)
if err != nil {
return false, err
}
return true, nil
n, err := res.RowsAffected()
if err != nil {
return false, err
}
return n == 1, nil
}
func (db *DB) SetRecoveryEmail(userID, email string) error {