1183 lines
44 KiB
Go
1183 lines
44 KiB
Go
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
|
|
}
|