Files
gomail/internal/db/queries.go
T
2026-08-09 18:03:09 +01:00

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, &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
}
// ── 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
}