2026-08-09 18:03:09 +01:00
package db
import (
"crypto/subtle"
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
)
func uuidNew () string { return uuid . NewString () }
// LookupDomain finds an active domain by name, along with its tenant.
// Returns ErrNotFound if the domain is not hosted here or is inactive.
func ( db * DB ) LookupDomain ( domain string ) ( * Domain , * Tenant , error ) {
row := db . QueryRow ( `
SELECT d.id, d.tenant_id, d.domain, d.active, d.dkim_selector, d.dkim_private_key_enc, d.accept_all, d.created_at,
t.id, t.name, t.display_name, t.digest_interval_mins, t.max_accounts, t.quota_mb_per_user, t.settings_json, t.created_at
FROM domains d
JOIN tenants t ON t.id = d.tenant_id
WHERE d.domain = ? AND d.active = 1
` , domain )
var d Domain
var t Tenant
var dkimSelector , tenantDisplayName sql . NullString
err := row . Scan (
& d . ID , & d . TenantID , & d . Domain , & d . Active , & dkimSelector , & d . DKIMPrivateKeyEnc , & d . AcceptAll , & d . CreatedAt ,
& t . ID , & t . Name , & tenantDisplayName , & t . DigestIntervalMins , & t . MaxAccounts , & t . QuotaMBPerUser , & t . SettingsJSON , & t . CreatedAt ,
)
d . DKIMSelector = dkimSelector . String
t . DisplayName = tenantDisplayName . String
if err == sql . ErrNoRows {
return nil , nil , ErrNotFound
}
if err != nil {
return nil , nil , fmt . Errorf ( "lookup domain %q: %w" , domain , err )
}
return & d , & t , nil
}
// LookupUserByEmail finds an active user by email address.
func ( db * DB ) LookupUserByEmail ( email 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, quota_mb, used_bytes, digest_enabled, digest_interval_mins, created_at
FROM users WHERE email = ? AND active = 1
` , email )
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 . QuotaMB , & u . UsedBytes , & u . DigestEnabled , & u . DigestIntervalMins , & u . CreatedAt ,
)
u . DisplayName = displayName . String
u . RecoveryEmail = recoveryEmail . String
if err == sql . ErrNoRows {
return nil , ErrNotFound
}
if err != nil {
return nil , fmt . Errorf ( "lookup user %q: %w" , email , err )
}
return & u , nil
}
// LookupAlias resolves an alias address to its target (local user or external).
func ( db * DB ) LookupAlias ( fromAddress string ) ( * Alias , error ) {
row := db . QueryRow ( `
SELECT id, tenant_id, from_address, to_user_id, to_external, active
FROM aliases WHERE from_address = ? AND active = 1
` , fromAddress )
var a Alias
err := row . Scan ( & a . ID , & a . TenantID , & a . FromAddress , & a . ToUserID , & a . ToExternal , & a . Active )
if err == sql . ErrNoRows {
return nil , ErrNotFound
}
if err != nil {
return nil , fmt . Errorf ( "lookup alias %q: %w" , fromAddress , err )
}
return & a , nil
}
// MatchListRule checks a sender address+domain against the tenant's allow/block
// rules. Returns (matched, action) — allow rules are checked first so an
// explicit allow always wins over a domain-level block.
func ( db * DB ) MatchListRule ( tenantID , fromAddress , fromDomain string ) ( bool , ListRuleAction , error ) {
rows , err := db . Query ( `
SELECT list_type, match_type, value FROM list_rules
WHERE tenant_id = ? AND active = 1
` , tenantID )
if err != nil {
return false , "" , fmt . Errorf ( "query list rules: %w" , err )
}
defer rows . Close ()
sawBlock := false
for rows . Next () {
var listType ListRuleAction
var matchType , value string
if err := rows . Scan ( & listType , & matchType , & value ); err != nil {
continue
}
matched := ( matchType == "email" && value == fromAddress ) ||
( matchType == "domain" && value == fromDomain )
if ! matched {
continue
}
if listType == ListActionAllow {
return true , ListActionAllow , nil // allow wins immediately
}
sawBlock = true
}
if sawBlock {
return true , ListActionBlock , nil
}
return false , "" , nil
}
// InsertMessage records an audit-log row for a processed message and returns its ID.
func ( db * DB ) InsertMessage ( m * Message ) error {
_ , err := db . Exec ( `
INSERT INTO messages (id, tenant_id, from_address, to_address, subject, message_id_hdr,
size_bytes, verdict, total_score, sender_ip, relayed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
` , m . ID , m . TenantID , m . FromAddress , m . ToAddress , m . Subject , m . MessageIDHdr ,
m . SizeBytes , m . Verdict , m . TotalScore , m . SenderIP , m . RelayedAt )
if err != nil {
return fmt . Errorf ( "insert message: %w" , err )
}
return nil
}
// UpdateMessageVerdict updates a message row's verdict, score, and delivery
// timestamp after the pipeline has run — called after the initial insert so
// message_checks rows (which FK-reference messages.id) always have a valid
// parent row to attach to, regardless of how long pipeline evaluation takes.
func ( db * DB ) UpdateMessageVerdict ( id string , verdict MessageVerdict , score float64 , relayedAt * time . Time ) error {
_ , err := db . Exec ( `UPDATE messages SET verdict = ?, total_score = ?, relayed_at = ? WHERE id = ?` ,
verdict , score , relayedAt , id )
return err
}
// InsertMessageCheck records one pipeline stage's result for a message.
func ( db * DB ) InsertMessageCheck ( c * MessageCheck ) error {
_ , err := db . Exec ( `
INSERT INTO message_checks (id, message_id, stage, result, score, detail, duration_ms)
VALUES (?, ?, ?, ?, ?, ?, ?)
` , c . ID , c . MessageID , c . Stage , c . Result , c . Score , c . Detail , c . DurationMs )
if err != nil {
return fmt . Errorf ( "insert message check: %w" , err )
}
return nil
}
// ── Quarantine ────────────────────────────────────────────────────────────────
// InsertQuarantineEntry holds a message for review instead of delivering it.
func ( db * DB ) InsertQuarantineEntry ( e * QuarantineEntry ) error {
_ , err := db . Exec ( `
INSERT INTO quarantine (id, message_id, eml_path, status, reason, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
` , e . ID , e . MessageID , e . EMLPath , e . Status , e . Reason , e . ExpiresAt )
if err != nil {
return fmt . Errorf ( "insert quarantine entry: %w" , err )
}
return nil
}
// QuarantineEntriesForUser returns held quarantine entries whose underlying
// message was addressed to the given recipient — used both by the webmail
// quarantine view (later phase) and the digest notifier (this phase).
func ( db * DB ) QuarantineEntriesForUser ( toAddress string , since time . Time ) ([] QuarantineEntry , error ) {
rows , err := db . Query ( `
SELECT q.id, q.message_id, q.eml_path, q.status, q.reason, q.expires_at, q.created_at
FROM quarantine q
JOIN messages m ON m.id = q.message_id
WHERE m.to_address = ? AND q.status = ? AND q.created_at > ?
ORDER BY q.created_at DESC
` , toAddress , QuarantineHeld , since )
if err != nil {
return nil , fmt . Errorf ( "query quarantine for user: %w" , err )
}
defer rows . Close ()
var entries [] QuarantineEntry
for rows . Next () {
var e QuarantineEntry
var reason sql . NullString
if err := rows . Scan ( & e . ID , & e . MessageID , & e . EMLPath , & e . Status , & reason , & e . ExpiresAt , & e . CreatedAt ); err != nil {
continue
}
e . Reason = reason . String
entries = append ( entries , e )
}
return entries , nil
}
// ReleaseQuarantineEntry marks an entry released — the caller (webmail API in
// a later phase, or the digest's release-link handler) is responsible for
// actually delivering the underlying message to the recipient's mailbox.
func ( db * DB ) ReleaseQuarantineEntry ( id , releasedBy string ) error {
_ , err := db . Exec ( `
UPDATE quarantine SET status = ?, released_by = ?, released_at = ? WHERE id = ? AND status = ?
` , QuarantineReleased , releasedBy , time . Now (). UTC (), id , QuarantineHeld )
return err
}
func ( db * DB ) GetQuarantineEntry ( id string ) ( * QuarantineEntry , error ) {
row := db . QueryRow ( `
SELECT id, message_id, eml_path, status, reason, expires_at, created_at
FROM quarantine WHERE id = ?
` , id )
var e QuarantineEntry
var reason sql . NullString
err := row . Scan ( & e . ID , & e . MessageID , & e . EMLPath , & e . Status , & reason , & e . ExpiresAt , & e . CreatedAt )
if err == sql . ErrNoRows {
return nil , ErrNotFound
}
if err != nil {
return nil , fmt . Errorf ( "get quarantine entry: %w" , err )
}
e . Reason = reason . String
return & e , nil
}
// InsertReleaseToken creates a time-limited token for one-click release from
// a digest email link.
func ( db * DB ) InsertReleaseToken ( t * ReleaseToken ) error {
_ , err := db . Exec ( `
INSERT INTO release_tokens (id, quarantine_id, token, email, expires_at)
VALUES (?, ?, ?, ?, ?)
` , t . ID , t . QuarantineID , t . Token , t . Email , t . ExpiresAt )
return err
}
func ( db * DB ) LookupReleaseToken ( token string ) ( * ReleaseToken , error ) {
row := db . QueryRow ( `
SELECT id, quarantine_id, token, email, used_at, expires_at, created_at
FROM release_tokens WHERE token = ? AND used_at IS NULL AND expires_at > ?
` , token , time . Now (). UTC ())
var t ReleaseToken
err := row . Scan ( & t . ID , & t . QuarantineID , & t . Token , & t . Email , & t . UsedAt , & t . ExpiresAt , & t . CreatedAt )
if err == sql . ErrNoRows {
return nil , ErrNotFound
}
if err != nil {
return nil , fmt . Errorf ( "lookup release token: %w" , err )
}
return & t , nil
}
func ( db * DB ) MarkReleaseTokenUsed ( id string ) error {
_ , err := db . Exec ( `UPDATE release_tokens SET used_at = ? WHERE id = ?` , time . Now (). UTC (), id )
return err
}
// NextMailboxUID atomically allocates the next IMAP UID for a user's mailbox.
func ( db * DB ) NextMailboxUID ( userID , mailbox string ) ( int , error ) {
tx , err := db . Begin ()
if err != nil {
return 0 , err
}
defer tx . Rollback ()
_ , err = tx . Exec ( `
INSERT INTO mailbox_uid_counters (user_id, mailbox, next_uid) VALUES (?, ?, 2)
ON CONFLICT(user_id, mailbox) DO UPDATE SET next_uid = next_uid + 1
` , userID , mailbox )
if err != nil {
return 0 , fmt . Errorf ( "allocate uid: %w" , err )
}
var next int
if err := tx . QueryRow ( `SELECT next_uid FROM mailbox_uid_counters WHERE user_id = ? AND mailbox = ?` ,
userID , mailbox ). Scan ( & next ); err != nil {
return 0 , fmt . Errorf ( "read allocated uid: %w" , err )
}
if err := tx . Commit (); err != nil {
return 0 , err
}
return next - 1 , nil // the UID just consumed
}
// InsertMailboxEntry records a delivered message in a user's mailbox index.
func ( db * DB ) InsertMailboxEntry ( e * MailboxEntry ) error {
_ , err := db . Exec ( `
2026-08-10 21:15:19 +01:00
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 )
2026-08-09 18:03:09 +01:00
if err != nil {
return fmt . Errorf ( "insert mailbox entry: %w" , err )
}
return nil
}
// ListMailboxEntries returns all messages in a user's mailbox, ordered by
// UID ascending — the order IMAP sequence numbers are defined against.
func ( db * DB ) ListMailboxEntries ( userID , mailbox string ) ([] MailboxEntry , error ) {
rows , err := db . Query ( `
2026-08-10 21:15:19 +01:00
SELECT id, user_id, mailbox, uid, eml_path, flags, size_bytes, received_at, internal_date, header_enc
2026-08-09 18:03:09 +01:00
FROM mailbox_index WHERE user_id = ? AND mailbox = ? ORDER BY uid ASC
` , userID , mailbox )
if err != nil {
return nil , fmt . Errorf ( "list mailbox entries: %w" , err )
}
defer rows . Close ()
var entries [] MailboxEntry
for rows . Next () {
var e MailboxEntry
2026-08-10 21:15:19 +01:00
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 {
2026-08-09 18:03:09 +01:00
continue
}
entries = append ( entries , e )
}
return entries , nil
}
// ListMailboxNames returns the distinct mailbox (folder) names a user has —
// always includes INBOX even if currently empty, since every account has one.
func ( db * DB ) ListMailboxNames ( userID string ) ([] string , error ) {
rows , err := db . Query ( `SELECT DISTINCT mailbox FROM mailbox_index WHERE user_id = ? ORDER BY mailbox` , userID )
if err != nil {
return nil , fmt . Errorf ( "list mailbox names: %w" , err )
}
defer rows . Close ()
seen := map [ string ] bool { "INBOX" : true }
names := [] string { "INBOX" }
for rows . Next () {
var name string
if err := rows . Scan ( & name ); err != nil {
continue
}
if ! seen [ name ] {
seen [ name ] = true
names = append ( names , name )
}
}
return names , nil
}
// UpdateMailboxFlags overwrites the flag string for one message.
func ( db * DB ) UpdateMailboxFlags ( id , flags string ) error {
_ , err := db . Exec ( `UPDATE mailbox_index SET flags = ? WHERE id = ?` , flags , id )
return err
}
// DeleteMailboxEntry removes one message from the index (used by EXPUNGE and
// POP3 DELE) — the caller is responsible for also removing the on-disk file.
func ( db * DB ) DeleteMailboxEntry ( id string ) error {
_ , err := db . Exec ( `DELETE FROM mailbox_index WHERE id = ?` , id )
return err
}
// ── Linked accounts ──────────────────────────────────────────────────────────
func ( db * DB ) InsertLinkedAccount ( a * LinkedAccount ) error {
_ , err := db . Exec ( `
INSERT INTO linked_accounts (id, user_id, provider, display_name, email_address, auth_type,
imap_host, imap_port, imap_tls, smtp_host, smtp_port, smtp_tls, credential_enc, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
` , a . ID , a . UserID , a . Provider , a . DisplayName , a . EmailAddress , a . AuthType ,
a . IMAPHost , a . IMAPPort , a . IMAPTLS , a . SMTPHost , a . SMTPPort , a . SMTPTLS , a . CredentialEnc )
if err != nil {
return fmt . Errorf ( "insert linked account: %w" , err )
}
return nil
}
func ( db * DB ) ListLinkedAccounts ( userID string ) ([] LinkedAccount , error ) {
rows , err := db . Query ( `
SELECT id, user_id, provider, display_name, email_address, auth_type,
imap_host, imap_port, imap_tls, smtp_host, smtp_port, smtp_tls,
credential_enc, sync_state, cache_retention_days, active, created_at
FROM linked_accounts WHERE user_id = ? AND active = 1
` , userID )
if err != nil {
return nil , fmt . Errorf ( "list linked accounts: %w" , err )
}
defer rows . Close ()
var out [] LinkedAccount
for rows . Next () {
var a LinkedAccount
var displayName , imapHost , smtpHost , imapTLS , smtpTLS , syncState sql . NullString
var imapPort , smtpPort , cacheDays sql . NullInt64
if err := rows . Scan ( & a . ID , & a . UserID , & a . Provider , & displayName , & a . EmailAddress , & a . AuthType ,
& imapHost , & imapPort , & imapTLS , & smtpHost , & smtpPort , & smtpTLS ,
& a . CredentialEnc , & syncState , & cacheDays , & a . Active , & a . CreatedAt ); err != nil {
continue
}
a . DisplayName = displayName . String
a . IMAPHost = imapHost . String
a . IMAPPort = int ( imapPort . Int64 )
a . IMAPTLS = imapTLS . String
a . SMTPHost = smtpHost . String
a . SMTPPort = int ( smtpPort . Int64 )
a . SMTPTLS = smtpTLS . String
a . SyncState = syncState . String
a . CacheRetentionDays = int ( cacheDays . Int64 )
out = append ( out , a )
}
return out , nil
}
func ( db * DB ) GetLinkedAccount ( id string ) ( * LinkedAccount , error ) {
row := db . QueryRow ( `
SELECT id, user_id, provider, display_name, email_address, auth_type,
imap_host, imap_port, imap_tls, smtp_host, smtp_port, smtp_tls,
credential_enc, sync_state, cache_retention_days, active, created_at
FROM linked_accounts WHERE id = ?
` , id )
var a LinkedAccount
var displayName , imapHost , smtpHost , imapTLS , smtpTLS , syncState sql . NullString
var imapPort , smtpPort , cacheDays sql . NullInt64
err := row . Scan ( & a . ID , & a . UserID , & a . Provider , & displayName , & a . EmailAddress , & a . AuthType ,
& imapHost , & imapPort , & imapTLS , & smtpHost , & smtpPort , & smtpTLS ,
& a . CredentialEnc , & syncState , & cacheDays , & a . Active , & a . CreatedAt )
if err == sql . ErrNoRows {
return nil , ErrNotFound
}
if err != nil {
return nil , fmt . Errorf ( "get linked account: %w" , err )
}
a . DisplayName = displayName . String
a . IMAPHost = imapHost . String
a . IMAPPort = int ( imapPort . Int64 )
a . IMAPTLS = imapTLS . String
a . SMTPHost = smtpHost . String
a . SMTPPort = int ( smtpPort . Int64 )
a . SMTPTLS = smtpTLS . String
a . SyncState = syncState . String
a . CacheRetentionDays = int ( cacheDays . Int64 )
return & a , nil
}
func ( db * DB ) UpdateLinkedAccountSync ( id , syncState string , syncErr string ) error {
_ , err := db . Exec ( `UPDATE linked_accounts SET sync_state = ?, last_sync_at = ?, last_sync_error = ? WHERE id = ?` ,
syncState , time . Now (). UTC (), syncErr , id )
return err
}
func ( db * DB ) DeactivateLinkedAccount ( id string ) error {
_ , err := db . Exec ( `UPDATE linked_accounts SET active = 0 WHERE id = ?` , id )
return err
}
var ErrNotFound = fmt . Errorf ( "not found" )
// ── Admin: tenants ──────────────────────────────────────────────────────────────
func ( db * DB ) ListTenants () ([] Tenant , error ) {
rows , err := db . Query ( `SELECT id, name, display_name, digest_interval_mins, max_accounts, quota_mb_per_user, settings_json, created_at FROM tenants ORDER BY name` )
if err != nil {
return nil , fmt . Errorf ( "list tenants: %w" , err )
}
defer rows . Close ()
var out [] Tenant
for rows . Next () {
var t Tenant
var displayName sql . NullString
if err := rows . Scan ( & t . ID , & t . Name , & displayName , & t . DigestIntervalMins , & t . MaxAccounts , & t . QuotaMBPerUser , & t . SettingsJSON , & t . CreatedAt ); err != nil {
continue
}
t . DisplayName = displayName . String
out = append ( out , t )
}
return out , nil
}
func ( db * DB ) CreateTenant ( t * Tenant ) error {
_ , err := db . Exec ( `INSERT INTO tenants (id, name, display_name) VALUES (?, ?, ?)` , t . ID , t . Name , t . DisplayName )
return err
}
// ── Admin: domains ────────────────────────────────────────────────────────────
func ( db * DB ) ListDomains () ([] Domain , error ) {
rows , err := db . Query ( `SELECT id, tenant_id, domain, active, dkim_selector, dkim_private_key_enc, accept_all, created_at FROM domains ORDER BY domain` )
if err != nil {
return nil , fmt . Errorf ( "list domains: %w" , err )
}
defer rows . Close ()
var out [] Domain
for rows . Next () {
var d Domain
var dkimSelector sql . NullString
if err := rows . Scan ( & d . ID , & d . TenantID , & d . Domain , & d . Active , & dkimSelector , & d . DKIMPrivateKeyEnc , & d . AcceptAll , & d . CreatedAt ); err != nil {
continue
}
d . DKIMSelector = dkimSelector . String
out = append ( out , d )
}
return out , nil
}
func ( db * DB ) CreateDomain ( d * Domain ) error {
_ , err := db . Exec ( `INSERT INTO domains (id, tenant_id, domain, active, accept_all, dkim_selector, dkim_private_key_enc) VALUES (?, ?, ?, 1, 1, ?, ?)` ,
d . ID , d . TenantID , d . Domain , d . DKIMSelector , d . DKIMPrivateKeyEnc )
return err
}
func ( db * DB ) UpdateDomainDKIMKey ( id , selector string , keyEnc [] byte ) error {
_ , err := db . Exec ( `UPDATE domains SET dkim_selector = ?, dkim_private_key_enc = ? WHERE id = ?` , selector , keyEnc , id )
return err
}
func ( db * DB ) DeleteDomain ( id string ) error {
_ , err := db . Exec ( `DELETE FROM domains WHERE id = ?` , id )
return err
}
func ( db * DB ) GetDomain ( id string ) ( * Domain , error ) {
row := db . QueryRow ( `SELECT id, tenant_id, domain, active, dkim_selector, dkim_private_key_enc, accept_all, created_at FROM domains WHERE id = ?` , id )
var d Domain
var dkimSelector sql . NullString
err := row . Scan ( & d . ID , & d . TenantID , & d . Domain , & d . Active , & dkimSelector , & d . DKIMPrivateKeyEnc , & d . AcceptAll , & d . CreatedAt )
if err == sql . ErrNoRows {
return nil , ErrNotFound
}
if err != nil {
return nil , fmt . Errorf ( "get domain: %w" , err )
}
d . DKIMSelector = dkimSelector . String
return & d , nil
}
// ── Admin: users ──────────────────────────────────────────────────────────────
// ListUsers returns every user, optionally filtered to one tenant
// (tenantID == "" means all tenants — global_admin view).
func ( db * DB ) ListUsers ( tenantID string ) ([] User , error ) {
query := `SELECT id, tenant_id, domain_id, email, display_name, role, active, mfa_enabled, quota_mb, used_bytes, created_at FROM users`
args := [] any {}
if tenantID != "" {
query += ` WHERE tenant_id = ?`
args = append ( args , tenantID )
}
query += ` ORDER BY email`
rows , err := db . Query ( query , args ... )
if err != nil {
return nil , fmt . Errorf ( "list users: %w" , err )
}
defer rows . Close ()
var out [] User
for rows . Next () {
var u User
var displayName sql . NullString
if err := rows . Scan ( & u . ID , & u . TenantID , & u . DomainID , & u . Email , & displayName , & u . Role , & u . Active , & u . MFAEnabled , & u . QuotaMB , & u . UsedBytes , & u . CreatedAt ); err != nil {
continue
}
u . DisplayName = displayName . String
out = append ( out , u )
}
return out , nil
}
func ( db * DB ) CreateUser ( u * User , passwordHash string ) error {
_ , err := db . Exec ( `INSERT INTO users (id, tenant_id, domain_id, email, password_hash, display_name, role, active) VALUES (?, ?, ?, ?, ?, ?, ?, 1)` ,
u . ID , u . TenantID , u . DomainID , u . Email , passwordHash , u . DisplayName , u . Role )
return err
}
func ( db * DB ) SetUserActive ( id string , active bool ) error {
_ , err := db . Exec ( `UPDATE users SET active = ? WHERE id = ?` , active , id )
return err
}
func ( db * DB ) SetUserPassword ( id , passwordHash string ) error {
_ , err := db . Exec ( `UPDATE users SET password_hash = ? WHERE id = ?` , passwordHash , id )
return err
}
func ( db * DB ) DeleteUser ( id string ) error {
_ , err := db . Exec ( `DELETE FROM users WHERE id = ?` , id )
return err
}
func ( db * DB ) GetUser ( id string ) ( * User , error ) {
row := db . QueryRow ( `SELECT id, tenant_id, domain_id, email, password_hash, display_name, role, active,
2026-08-10 21:15:19 +01:00
mfa_enabled, totp_secret_enc, passkey_credentials_json, recovery_email FROM users WHERE id = ?` , id )
2026-08-09 18:03:09 +01:00
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 ,
2026-08-10 21:15:19 +01:00
& u . MFAEnabled , & u . TOTPSecretEnc , & u . PasskeyCredentialsJSON , & recoveryEmail )
2026-08-09 18:03:09 +01:00
if err == sql . ErrNoRows {
return nil , ErrNotFound
}
if err != nil {
return nil , fmt . Errorf ( "get user: %w" , err )
}
u . DisplayName = displayName . String
u . RecoveryEmail = recoveryEmail . String
return & u , nil
}
// ── Admin: list rules ─────────────────────────────────────────────────────────
func ( db * DB ) ListListRules ( tenantID string ) ([] ListRule , error ) {
rows , err := db . Query ( `SELECT id, tenant_id, list_type, match_type, value, note, active, created_at FROM list_rules WHERE tenant_id = ? ORDER BY created_at DESC` , tenantID )
if err != nil {
return nil , fmt . Errorf ( "list list_rules: %w" , err )
}
defer rows . Close ()
var out [] ListRule
for rows . Next () {
var r ListRule
var note sql . NullString
if err := rows . Scan ( & r . ID , & r . TenantID , & r . ListType , & r . MatchType , & r . Value , & note , & r . Active , & r . CreatedAt ); err != nil {
continue
}
r . Note = note . String
out = append ( out , r )
}
return out , nil
}
func ( db * DB ) CreateListRule ( r * ListRule ) error {
_ , err := db . Exec ( `INSERT INTO list_rules (id, tenant_id, list_type, match_type, value, note, active) VALUES (?, ?, ?, ?, ?, ?, 1)` ,
r . ID , r . TenantID , r . ListType , r . MatchType , r . Value , r . Note )
return err
}
func ( db * DB ) DeleteListRule ( id string ) error {
_ , err := db . Exec ( `DELETE FROM list_rules WHERE id = ?` , id )
return err
}
// ── Admin: outbound queue ────────────────────────────────────────────────────
func ( db * DB ) ListAllOutboundQueue () ([] OutboundQueueEntry , error ) {
rows , err := db . Query ( `SELECT id, user_id, from_address, to_address, eml_path, priority, attempts, last_error, next_attempt_at, created_at FROM outbound_queue ORDER BY created_at DESC` )
if err != nil {
return nil , fmt . Errorf ( "list outbound queue: %w" , err )
}
defer rows . Close ()
var out [] OutboundQueueEntry
for rows . Next () {
var e OutboundQueueEntry
var lastError sql . NullString
if err := rows . Scan ( & e . ID , & e . UserID , & e . FromAddress , & e . ToAddress , & e . EMLPath , & e . Priority , & e . Attempts , & lastError , & e . NextAttemptAt , & e . CreatedAt ); err != nil {
continue
}
e . LastError = lastError . String
out = append ( out , e )
}
return out , nil
}
// RetryQueueEntryNow resets a queue entry's schedule to immediately, for the
// admin "retry now" button — the queue worker's next poll picks it up.
func ( db * DB ) RetryQueueEntryNow ( id string ) error {
_ , err := db . Exec ( `UPDATE outbound_queue SET next_attempt_at = ? WHERE id = ?` , time . Now (). UTC (), id )
return err
}
// ── Admin: quarantine (global) ───────────────────────────────────────────────
// DeleteQuarantineEntry marks a held message as permanently discarded — used
// by the admin portal's global quarantine view (per-user release, as
// opposed to discard, is handled by webmail's own quarantine endpoint).
func ( db * DB ) DeleteQuarantineEntry ( id string ) error {
_ , err := db . Exec ( `UPDATE quarantine SET status = 'deleted' WHERE id = ?` , id )
return err
}
func ( db * DB ) ListAllQuarantine () ([] QuarantineEntry , error ) {
rows , err := db . Query ( `SELECT id, message_id, eml_path, status, reason, expires_at, created_at FROM quarantine WHERE status = 'held' ORDER BY created_at DESC` )
if err != nil {
return nil , fmt . Errorf ( "list quarantine: %w" , err )
}
defer rows . Close ()
var out [] QuarantineEntry
for rows . Next () {
var e QuarantineEntry
var reason sql . NullString
if err := rows . Scan ( & e . ID , & e . MessageID , & e . EMLPath , & e . Status , & reason , & e . ExpiresAt , & e . CreatedAt ); err != nil {
continue
}
e . Reason = reason . String
out = append ( out , e )
}
return out , nil
}
// ── Admin: dashboard stats ────────────────────────────────────────────────────
type Stats struct {
TotalUsers int
TotalDomains int
Messages24h int
QueueDepth int
QuarantineHeld int
}
func ( db * DB ) GetStats () ( * Stats , error ) {
s := & Stats {}
db . QueryRow ( `SELECT COUNT(*) FROM users` ). Scan ( & s . TotalUsers )
db . QueryRow ( `SELECT COUNT(*) FROM domains` ). Scan ( & s . TotalDomains )
db . QueryRow ( `SELECT COUNT(*) FROM messages WHERE created_at > ?` , time . Now (). UTC (). Add ( - 24 * time . Hour )). Scan ( & s . Messages24h )
db . QueryRow ( `SELECT COUNT(*) FROM outbound_queue` ). Scan ( & s . QueueDepth )
db . QueryRow ( `SELECT COUNT(*) FROM quarantine WHERE status = 'held'` ). Scan ( & s . QuarantineHeld )
return s , nil
}
// ── CalDAV / CardDAV ────────────────────────────────────────────────────────────
func ( db * DB ) GetOrCreateAddressbook ( ownerType OwnerType , ownerID , displayName string ) ( * Addressbook , error ) {
row := db . QueryRow ( `SELECT id, owner_type, owner_id, display_name, description, sync_token, created_at
FROM addressbooks WHERE owner_type = ? AND owner_id = ?` , ownerType , ownerID )
var a Addressbook
var desc sql . NullString
err := row . Scan ( & a . ID , & a . OwnerType , & a . OwnerID , & a . DisplayName , & desc , & a . SyncToken , & a . CreatedAt )
if err == nil {
a . Description = desc . String
return & a , nil
}
if err != sql . ErrNoRows {
return nil , fmt . Errorf ( "get addressbook: %w" , err )
}
a = Addressbook { ID : uuidNew (), OwnerType : ownerType , OwnerID : ownerID , DisplayName : displayName , SyncToken : "1" }
if _ , err := db . Exec ( `INSERT INTO addressbooks (id, owner_type, owner_id, display_name, sync_token) VALUES (?, ?, ?, ?, ?)` ,
a . ID , a . OwnerType , a . OwnerID , a . DisplayName , a . SyncToken ); err != nil {
return nil , fmt . Errorf ( "create addressbook: %w" , err )
}
return & a , nil
}
func ( db * DB ) GetOrCreateCalendar ( ownerType OwnerType , ownerID , displayName string ) ( * Calendar , error ) {
row := db . QueryRow ( `SELECT id, owner_type, owner_id, display_name, description, color, timezone, sync_token, created_at
FROM calendars WHERE owner_type = ? AND owner_id = ?` , ownerType , ownerID )
var c Calendar
var desc , color sql . NullString
err := row . Scan ( & c . ID , & c . OwnerType , & c . OwnerID , & c . DisplayName , & desc , & color , & c . Timezone , & c . SyncToken , & c . CreatedAt )
if err == nil {
c . Description = desc . String
c . Color = color . String
return & c , nil
}
if err != sql . ErrNoRows {
return nil , fmt . Errorf ( "get calendar: %w" , err )
}
c = Calendar { ID : uuidNew (), OwnerType : ownerType , OwnerID : ownerID , DisplayName : displayName , Timezone : "UTC" , SyncToken : "1" }
if _ , err := db . Exec ( `INSERT INTO calendars (id, owner_type, owner_id, display_name, timezone, sync_token) VALUES (?, ?, ?, ?, ?, ?)` ,
c . ID , c . OwnerType , c . OwnerID , c . DisplayName , c . Timezone , c . SyncToken ); err != nil {
return nil , fmt . Errorf ( "create calendar: %w" , err )
}
return & c , nil
}
func ( db * DB ) ListContacts ( addressbookID string ) ([] Contact , error ) {
rows , err := db . Query ( `SELECT id, addressbook_id, uid, vcard_enc, etag, created_at, updated_at
FROM contacts WHERE addressbook_id = ? ORDER BY uid` , addressbookID )
if err != nil {
return nil , fmt . Errorf ( "list contacts: %w" , err )
}
defer rows . Close ()
var out [] Contact
for rows . Next () {
var c Contact
if err := rows . Scan ( & c . ID , & c . AddressbookID , & c . UID , & c . VCardEnc , & c . ETag , & c . CreatedAt , & c . UpdatedAt ); err != nil {
continue
}
out = append ( out , c )
}
return out , nil
}
func ( db * DB ) GetContact ( addressbookID , uid string ) ( * Contact , error ) {
row := db . QueryRow ( `SELECT id, addressbook_id, uid, vcard_enc, etag, created_at, updated_at
FROM contacts WHERE addressbook_id = ? AND uid = ?` , addressbookID , uid )
var c Contact
err := row . Scan ( & c . ID , & c . AddressbookID , & c . UID , & c . VCardEnc , & c . ETag , & c . CreatedAt , & c . UpdatedAt )
if err == sql . ErrNoRows {
return nil , ErrNotFound
}
if err != nil {
return nil , fmt . Errorf ( "get contact: %w" , err )
}
return & c , nil
}
// UpsertContact creates or updates a contact by (addressbookID, uid), bumping
// the etag — used by PUT, which per WebDAV semantics both creates new
// resources and updates existing ones at the same URL.
func ( db * DB ) UpsertContact ( c * Contact ) error {
existing , err := db . GetContact ( c . AddressbookID , c . UID )
if err == nil {
_ , err := db . Exec ( `UPDATE contacts SET vcard_enc = ?, etag = ?, updated_at = ? WHERE id = ?` ,
c . VCardEnc , c . ETag , time . Now (). UTC (), existing . ID )
return err
}
_ , err = db . Exec ( `INSERT INTO contacts (id, addressbook_id, uid, vcard_enc, etag) VALUES (?, ?, ?, ?, ?)` ,
c . ID , c . AddressbookID , c . UID , c . VCardEnc , c . ETag )
return err
}
func ( db * DB ) DeleteContact ( addressbookID , uid string ) error {
_ , err := db . Exec ( `DELETE FROM contacts WHERE addressbook_id = ? AND uid = ?` , addressbookID , uid )
return err
}
func ( db * DB ) ListCalendarObjects ( calendarID string ) ([] CalendarObject , error ) {
rows , err := db . Query ( `SELECT id, calendar_id, uid, ical_enc, component_type, summary, dtstart, dtend, etag, created_at, updated_at
FROM calendar_objects WHERE calendar_id = ? ORDER BY dtstart` , calendarID )
if err != nil {
return nil , fmt . Errorf ( "list calendar objects: %w" , err )
}
defer rows . Close ()
var out [] CalendarObject
for rows . Next () {
var c CalendarObject
var componentType , summary sql . NullString
var dtstart , dtend sql . NullTime
if err := rows . Scan ( & c . ID , & c . CalendarID , & c . UID , & c . ICalEnc , & componentType , & summary , & dtstart , & dtend , & c . ETag , & c . CreatedAt , & c . UpdatedAt ); err != nil {
continue
}
c . ComponentType = componentType . String
c . Summary = summary . String
if dtstart . Valid {
c . DTStart = & dtstart . Time
}
if dtend . Valid {
c . DTEnd = & dtend . Time
}
out = append ( out , c )
}
return out , nil
}
func ( db * DB ) GetCalendarObject ( calendarID , uid string ) ( * CalendarObject , error ) {
row := db . QueryRow ( `SELECT id, calendar_id, uid, ical_enc, component_type, summary, dtstart, dtend, etag, created_at, updated_at
FROM calendar_objects WHERE calendar_id = ? AND uid = ?` , calendarID , uid )
var c CalendarObject
var componentType , summary sql . NullString
var dtstart , dtend sql . NullTime
err := row . Scan ( & c . ID , & c . CalendarID , & c . UID , & c . ICalEnc , & componentType , & summary , & dtstart , & dtend , & c . ETag , & c . CreatedAt , & c . UpdatedAt )
if err == sql . ErrNoRows {
return nil , ErrNotFound
}
if err != nil {
return nil , fmt . Errorf ( "get calendar object: %w" , err )
}
c . ComponentType = componentType . String
c . Summary = summary . String
if dtstart . Valid {
c . DTStart = & dtstart . Time
}
if dtend . Valid {
c . DTEnd = & dtend . Time
}
return & c , nil
}
func ( db * DB ) UpsertCalendarObject ( c * CalendarObject ) error {
existing , err := db . GetCalendarObject ( c . CalendarID , c . UID )
if err == nil {
_ , err := db . Exec ( `UPDATE calendar_objects SET ical_enc = ?, component_type = ?, summary = ?, dtstart = ?, dtend = ?, etag = ?, updated_at = ? WHERE id = ?` ,
c . ICalEnc , c . ComponentType , c . Summary , c . DTStart , c . DTEnd , c . ETag , time . Now (). UTC (), existing . ID )
return err
}
_ , err = db . Exec ( `INSERT INTO calendar_objects (id, calendar_id, uid, ical_enc, component_type, summary, dtstart, dtend, etag)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` ,
c . ID , c . CalendarID , c . UID , c . ICalEnc , c . ComponentType , c . Summary , c . DTStart , c . DTEnd , c . ETag )
return err
}
func ( db * DB ) DeleteCalendarObject ( calendarID , uid string ) error {
_ , err := db . Exec ( `DELETE FROM calendar_objects WHERE calendar_id = ? AND uid = ?` , calendarID , uid )
return err
}
// ── ManageSieve ───────────────────────────────────────────────────────────────
func ( db * DB ) ListSieveScripts ( userID string ) ([] SieveScript , error ) {
rows , err := db . Query ( `SELECT id, user_id, name, script_text, active, created_at, updated_at
FROM sieve_scripts WHERE user_id = ? ORDER BY name` , userID )
if err != nil {
return nil , fmt . Errorf ( "list sieve scripts: %w" , err )
}
defer rows . Close ()
var out [] SieveScript
for rows . Next () {
var s SieveScript
if err := rows . Scan ( & s . ID , & s . UserID , & s . Name , & s . ScriptText , & s . Active , & s . CreatedAt , & s . UpdatedAt ); err != nil {
continue
}
out = append ( out , s )
}
return out , nil
}
func ( db * DB ) GetSieveScript ( userID , name string ) ( * SieveScript , error ) {
row := db . QueryRow ( `SELECT id, user_id, name, script_text, active, created_at, updated_at
FROM sieve_scripts WHERE user_id = ? AND name = ?` , userID , name )
var s SieveScript
err := row . Scan ( & s . ID , & s . UserID , & s . Name , & s . ScriptText , & s . Active , & s . CreatedAt , & s . UpdatedAt )
if err == sql . ErrNoRows {
return nil , ErrNotFound
}
if err != nil {
return nil , fmt . Errorf ( "get sieve script: %w" , err )
}
return & s , nil
}
// GetActiveSieveScript returns the user's currently active script, if any —
// called on every inbound delivery, so kept as a single indexed lookup.
func ( db * DB ) GetActiveSieveScript ( userID string ) ( * SieveScript , error ) {
row := db . QueryRow ( `SELECT id, user_id, name, script_text, active, created_at, updated_at
FROM sieve_scripts WHERE user_id = ? AND active = 1` , userID )
var s SieveScript
err := row . Scan ( & s . ID , & s . UserID , & s . Name , & s . ScriptText , & s . Active , & s . CreatedAt , & s . UpdatedAt )
if err == sql . ErrNoRows {
return nil , ErrNotFound
}
if err != nil {
return nil , fmt . Errorf ( "get active sieve script: %w" , err )
}
return & s , nil
}
func ( db * DB ) UpsertSieveScript ( s * SieveScript ) error {
existing , err := db . GetSieveScript ( s . UserID , s . Name )
if err == nil {
_ , err := db . Exec ( `UPDATE sieve_scripts SET script_text = ?, updated_at = ? WHERE id = ?` ,
s . ScriptText , time . Now (). UTC (), existing . ID )
return err
}
_ , err = db . Exec ( `INSERT INTO sieve_scripts (id, user_id, name, script_text, active) VALUES (?, ?, ?, ?, 0)` ,
s . ID , s . UserID , s . Name , s . ScriptText )
return err
}
// SetActiveSieveScript activates the named script and deactivates every
// other script for the user — ManageSieve's SETACTIVE semantics require
// exactly zero or one active script per user at a time.
func ( db * DB ) SetActiveSieveScript ( userID , name string ) error {
tx , err := db . Begin ()
if err != nil {
return err
}
defer tx . Rollback ()
if _ , err := tx . Exec ( `UPDATE sieve_scripts SET active = 0 WHERE user_id = ?` , userID ); err != nil {
return err
}
res , err := tx . Exec ( `UPDATE sieve_scripts SET active = 1 WHERE user_id = ? AND name = ?` , userID , name )
if err != nil {
return err
}
if n , _ := res . RowsAffected (); n == 0 {
return ErrNotFound
}
return tx . Commit ()
}
func ( db * DB ) DeleteSieveScript ( userID , name string ) error {
_ , err := db . Exec ( `DELETE FROM sieve_scripts WHERE user_id = ? AND name = ?` , userID , name )
return err
}
// ── TLS certs (ACME) ────────────────────────────────────────────────────────────
func ( db * DB ) GetTLSCert ( domain string ) ( * TLSCert , error ) {
row := db . QueryRow ( `SELECT id, domain, cert_pem_enc, key_pem_enc, expires_at, acme_account_key_enc, created_at, updated_at
FROM tls_certs WHERE domain = ?` , domain )
var c TLSCert
err := row . Scan ( & c . ID , & c . Domain , & c . CertPEMEnc , & c . KeyPEMEnc , & c . ExpiresAt , & c . ACMEAccountKeyEnc , & c . CreatedAt , & c . UpdatedAt )
if err == sql . ErrNoRows {
return nil , ErrNotFound
}
if err != nil {
return nil , fmt . Errorf ( "get tls cert: %w" , err )
}
return & c , nil
}
// UpsertTLSCert creates or updates the stored certificate for a domain.
func ( db * DB ) UpsertTLSCert ( c * TLSCert ) error {
existing , err := db . GetTLSCert ( c . Domain )
if err == nil {
_ , err := db . Exec ( `UPDATE tls_certs SET cert_pem_enc = ?, key_pem_enc = ?, expires_at = ?, updated_at = ? WHERE id = ?` ,
c . CertPEMEnc , c . KeyPEMEnc , c . ExpiresAt , time . Now (). UTC (), existing . ID )
return err
}
_ , err = db . Exec ( `INSERT INTO tls_certs (id, domain, cert_pem_enc, key_pem_enc, expires_at, acme_account_key_enc) VALUES (?, ?, ?, ?, ?, ?)` ,
c . ID , c . Domain , c . CertPEMEnc , c . KeyPEMEnc , c . ExpiresAt , c . ACMEAccountKeyEnc )
return err
}
// SetACMEAccountKey stores the ACME account key separately from cert
// issuance — the account key is created once and reused across renewals,
// while cert/key rotate every renewal.
func ( db * DB ) SetACMEAccountKey ( domain string , keyEnc [] byte ) error {
existing , err := db . GetTLSCert ( domain )
if err == nil {
_ , err := db . Exec ( `UPDATE tls_certs SET acme_account_key_enc = ? WHERE id = ?` , keyEnc , existing . ID )
return err
}
_ , err = db . Exec ( `INSERT INTO tls_certs (id, domain, acme_account_key_enc) VALUES (?, ?, ?)` , uuidNew (), domain , keyEnc )
return err
}
2026-08-10 21:15:19 +01:00
// ── 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
}
2026-08-09 18:03:09 +01:00
// ── MFA ───────────────────────────────────────────────────────────────────────
// SetPendingTOTPSecret stores an encrypted TOTP secret WITHOUT enabling
// MFA yet — the user must confirm one valid code first (SetMFAEnabled),
// so a setup flow abandoned partway through never locks anyone out.
func ( db * DB ) SetPendingTOTPSecret ( userID string , secretEnc [] byte ) error {
_ , err := db . Exec ( `UPDATE users SET totp_secret_enc = ? WHERE id = ?` , secretEnc , userID )
return err
}
func ( db * DB ) SetMFAEnabled ( userID string , enabled bool ) error {
_ , err := db . Exec ( `UPDATE users SET mfa_enabled = ? WHERE id = ?` , enabled , userID )
return err
}
2026-08-10 21:15:19 +01:00
// 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.
2026-08-09 18:03:09 +01:00
func ( db * DB ) ClearTOTPSecret ( userID string ) error {
2026-08-10 21:15:19 +01:00
_ , err := db . Exec ( `UPDATE users SET totp_secret_enc = NULL WHERE id = ?` , userID )
2026-08-09 18:03:09 +01:00
return err
}
2026-08-10 21:15:19 +01:00
// 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 )
}
2026-08-09 18:03:09 +01:00
// 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.
func ( db * DB ) ReplaceBackupCodes ( userID string , codeHashes [] string ) error {
tx , err := db . Begin ()
if err != nil {
return err
}
defer tx . Rollback ()
if _ , err := tx . Exec ( `DELETE FROM mfa_backup_codes WHERE user_id = ?` , userID ); err != nil {
return err
}
for _ , hash := range codeHashes {
if _ , err := tx . Exec ( `INSERT INTO mfa_backup_codes (id, user_id, code_hash) VALUES (?, ?, ?)` , uuidNew (), userID , hash ); err != nil {
return err
}
}
return tx . Commit ()
}
// ConsumeBackupCode checks candidateCode against every unused backup code
// hash for the user and, on a match, marks that one used (one-time use) —
// returns true if a match was found and consumed.
func ( db * DB ) ConsumeBackupCode ( userID , candidateHash string ) ( bool , error ) {
rows , err := db . Query ( `SELECT id, code_hash FROM mfa_backup_codes WHERE user_id = ? AND used_at IS NULL` , userID )
if err != nil {
return false , err
}
var matchID string
for rows . Next () {
var id , hash string
if err := rows . Scan ( & id , & hash ); err != nil {
continue
}
if subtle . ConstantTimeCompare ([] byte ( hash ), [] byte ( candidateHash )) == 1 {
matchID = id
break
}
}
// Close explicitly (not just deferred) before the UPDATE below — SQLite
// is capped to a single open connection (see db.Open), so an UPDATE
// issued while these still-open rows hold that one connection would
// deadlock waiting for a connection that can't free until Close() runs.
rows . Close ()
if matchID == "" {
return false , nil
}
2026-08-10 21:15:19 +01:00
// 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 {
2026-08-09 18:03:09 +01:00
return false , err
}
2026-08-10 21:15:19 +01:00
n , err := res . RowsAffected ()
if err != nil {
return false , err
}
return n == 1 , nil
2026-08-09 18:03:09 +01:00
}
func ( db * DB ) SetRecoveryEmail ( userID , email string ) error {
_ , err := db . Exec ( `UPDATE users SET recovery_email = ? WHERE id = ?` , email , userID )
return err
}
// ── Outbound queue ───────────────────────────────────────────────────────────
// InsertOutboundQueueEntry enqueues a message for outbound delivery.
func ( db * DB ) InsertOutboundQueueEntry ( e * OutboundQueueEntry ) error {
_ , err := db . Exec ( `
INSERT INTO outbound_queue (id, user_id, from_address, to_address, eml_path, priority, next_attempt_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
` , e . ID , e . UserID , e . FromAddress , e . ToAddress , e . EMLPath , e . Priority , e . NextAttemptAt )
if err != nil {
return fmt . Errorf ( "insert outbound queue entry: %w" , err )
}
return nil
}
// DueOutboundEntries returns queue entries ready for a delivery attempt,
// ordered by priority (desc) then age (oldest first), up to limit rows.
func ( db * DB ) DueOutboundEntries ( maxAttempts , limit int ) ([] OutboundQueueEntry , error ) {
rows , err := db . Query ( `
SELECT id, user_id, from_address, to_address, eml_path, priority, attempts, last_error, next_attempt_at, created_at
FROM outbound_queue
WHERE next_attempt_at <= ? AND attempts < ?
ORDER BY priority DESC, created_at ASC
LIMIT ?
` , time . Now (). UTC (), maxAttempts , limit )
if err != nil {
return nil , fmt . Errorf ( "query due outbound entries: %w" , err )
}
defer rows . Close ()
var entries [] OutboundQueueEntry
for rows . Next () {
var e OutboundQueueEntry
var lastError sql . NullString
if err := rows . Scan ( & e . ID , & e . UserID , & e . FromAddress , & e . ToAddress , & e . EMLPath ,
& e . Priority , & e . Attempts , & lastError , & e . NextAttemptAt , & e . CreatedAt ); err != nil {
continue
}
e . LastError = lastError . String
entries = append ( entries , e )
}
return entries , nil
}
// DeleteOutboundEntry removes a queue entry after successful delivery or
// permanent failure (bounce sent).
func ( db * DB ) DeleteOutboundEntry ( id string ) error {
_ , err := db . Exec ( `DELETE FROM outbound_queue WHERE id = ?` , id )
return err
}
// RetryOutboundEntry records a failed attempt and schedules the next retry.
func ( db * DB ) RetryOutboundEntry ( id string , nextAttempt time . Time , lastError string ) error {
_ , err := db . Exec ( `
UPDATE outbound_queue SET attempts = attempts + 1, next_attempt_at = ?, last_error = ?
WHERE id = ?
` , nextAttempt , lastError , id )
return err
}
// PermanentlyFailedEntries returns entries that have exhausted their retry
// attempts — the caller should bounce these and then delete them.
func ( db * DB ) PermanentlyFailedEntries ( maxAttempts int ) ([] OutboundQueueEntry , error ) {
rows , err := db . Query ( `
SELECT id, user_id, from_address, to_address, eml_path, priority, attempts, last_error, next_attempt_at, created_at
FROM outbound_queue WHERE attempts >= ?
` , maxAttempts )
if err != nil {
return nil , fmt . Errorf ( "query permanently failed entries: %w" , err )
}
defer rows . Close ()
var entries [] OutboundQueueEntry
for rows . Next () {
var e OutboundQueueEntry
var lastError sql . NullString
if err := rows . Scan ( & e . ID , & e . UserID , & e . FromAddress , & e . ToAddress , & e . EMLPath ,
& e . Priority , & e . Attempts , & lastError , & e . NextAttemptAt , & e . CreatedAt ); err != nil {
continue
}
e . LastError = lastError . String
entries = append ( entries , e )
}
return entries , nil
}
// LookupDomainByName is a convenience alias used by the DKIM signer to find
// the sending domain's key material without also needing tenant info.
func ( db * DB ) LookupDomainByName ( domain string ) ( * Domain , error ) {
d , _ , err := db . LookupDomain ( domain )
return d , err
}