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(` 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) 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(` SELECT id, user_id, mailbox, uid, eml_path, flags, size_bytes, received_at, internal_date 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 if err := rows.Scan(&e.ID, &e.UserID, &e.Mailbox, &e.UID, &e.EMLPath, &e.Flags, &e.SizeBytes, &e.ReceivedAt, &e.InternalDate); err != nil { 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, mfa_enabled, totp_secret_enc, 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) 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, ¬e, &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 } // ── 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 } func (db *DB) ClearTOTPSecret(userID string) error { _, err := db.Exec(`UPDATE users SET mfa_enabled = 0, totp_secret_enc = NULL WHERE id = ?`, userID) return err } // 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 } if _, err := db.Exec(`UPDATE mfa_backup_codes SET used_at = ? WHERE id = ?`, time.Now().UTC(), matchID); err != nil { return false, err } return true, nil } 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 }