// Package db is the SQLite data layer, mirroring email_server/models.py. It uses plain // database/sql + hand-written SQL rather than an ORM — the schema is small and fixed, // so an ORM would be an unrequested abstraction. package db import ( "database/sql" "fmt" _ "modernc.org/sqlite" ) // schema creates all esrv_* tables if missing. There is no migration framework here, // matching the Python precedent (its own migrations/ directory is a single manual SQL // patch file, never auto-applied) — CREATE TABLE IF NOT EXISTS covers the whole surface. const schema = ` CREATE TABLE IF NOT EXISTS esrv_domains ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain_name TEXT NOT NULL UNIQUE, is_active INTEGER NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, verification_token TEXT NOT NULL DEFAULT '', is_verified INTEGER NOT NULL DEFAULT 0, verified_at DATETIME, default_mailbox_quota_bytes INTEGER NOT NULL DEFAULT 5368709120, mfa_exempt INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS esrv_senders ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, domain_id INTEGER NOT NULL REFERENCES esrv_domains(id), can_send_as_domain INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, store_message_content INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS esrv_whitelisted_ips ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip_address TEXT NOT NULL, domain_id INTEGER NOT NULL REFERENCES esrv_domains(id), is_active INTEGER NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, store_message_content INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS esrv_email_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, message_id TEXT NOT NULL UNIQUE, timestamp DATETIME NOT NULL, peer_ip TEXT NOT NULL, mail_from TEXT NOT NULL, to_address TEXT NOT NULL DEFAULT '', cc_addresses TEXT DEFAULT '', bcc_addresses TEXT DEFAULT '', subject TEXT, email_headers TEXT NOT NULL, message_body TEXT, status TEXT NOT NULL, dkim_signed INTEGER NOT NULL DEFAULT 0, username TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS esrv_email_recipient_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, email_log_id INTEGER NOT NULL REFERENCES esrv_email_logs(id), recipient TEXT NOT NULL, recipient_type TEXT NOT NULL, status TEXT NOT NULL, error_code TEXT, error_message TEXT, server_response TEXT ); CREATE TABLE IF NOT EXISTS esrv_auth_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, auth_type TEXT NOT NULL, identifier TEXT NOT NULL, ip_address TEXT, success INTEGER NOT NULL, message TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS esrv_dkim_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain_id INTEGER NOT NULL REFERENCES esrv_domains(id), selector TEXT NOT NULL DEFAULT 'default', private_key TEXT NOT NULL, public_key TEXT NOT NULL, is_active INTEGER NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, replaced_at DATETIME ); CREATE TABLE IF NOT EXISTS esrv_custom_headers ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain_id INTEGER NOT NULL REFERENCES esrv_domains(id), header_name TEXT NOT NULL, header_value TEXT NOT NULL, is_active INTEGER NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS esrv_email_attachments ( id INTEGER PRIMARY KEY AUTOINCREMENT, email_log_id INTEGER NOT NULL REFERENCES esrv_email_logs(id), filename TEXT NOT NULL, content_type TEXT, file_path TEXT NOT NULL, size INTEGER, uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS esrv_admin_users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, must_change_password INTEGER NOT NULL DEFAULT 0, must_change_username INTEGER NOT NULL DEFAULT 0, totp_secret TEXT NOT NULL DEFAULT '', totp_enabled INTEGER NOT NULL DEFAULT 0, is_global_admin INTEGER NOT NULL DEFAULT 0, created_by INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- Which domains a non-global admin is allowed to see/manage. Global admins have no -- rows here at all — their access is implicit (AdminUser.IsGlobalAdmin). CREATE TABLE IF NOT EXISTS esrv_admin_domain_access ( admin_user_id INTEGER NOT NULL REFERENCES esrv_admin_users(id), domain_id INTEGER NOT NULL REFERENCES esrv_domains(id), PRIMARY KEY (admin_user_id, domain_id) ); CREATE TABLE IF NOT EXISTS esrv_admin_sessions ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES esrv_admin_users(id), mfa_verified INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME NOT NULL ); CREATE TABLE IF NOT EXISTS esrv_webauthn_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES esrv_admin_users(id), name TEXT NOT NULL DEFAULT '', credential_id TEXT NOT NULL UNIQUE, credential_data TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- Mailboxes are a distinct identity from esrv_senders: senders are relay/auth-only, -- mailboxes are real IMAP-retrievable local storage. password_hash authenticates the -- (future) self-service web portal only, never IMAP/SMTP client login — those use an -- app password instead (esrv_mailbox_app_passwords), since IMAP/SMTP AUTH has no -- interactive MFA step. dek_wrapped/dek_nonce hold this mailbox's AES-256 data -- encryption key, sealed with the server-held master key (internal/mailstore) — a -- raw DB dump alone can't decrypt stored mail without that separate key file. CREATE TABLE IF NOT EXISTS esrv_mailboxes ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, domain_id INTEGER NOT NULL REFERENCES esrv_domains(id), password_hash TEXT NOT NULL, is_active INTEGER NOT NULL DEFAULT 1, quota_bytes INTEGER NOT NULL DEFAULT 5368709120, used_bytes INTEGER NOT NULL DEFAULT 0, dek_wrapped BLOB NOT NULL, dek_nonce BLOB NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_by INTEGER REFERENCES esrv_admin_users(id), totp_secret TEXT NOT NULL DEFAULT '', totp_enabled INTEGER NOT NULL DEFAULT 0, mfa_exempt INTEGER NOT NULL DEFAULT 0 ); -- Self-service webmail portal sessions — deliberately a parallel schema to -- esrv_admin_sessions, not shared: a mailbox owner is a different actor type with no -- accessScope/domain-admin semantics of its own. CREATE TABLE IF NOT EXISTS esrv_mailbox_sessions ( token TEXT PRIMARY KEY, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), mfa_verified INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME NOT NULL ); CREATE TABLE IF NOT EXISTS esrv_mailbox_webauthn_credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), name TEXT NOT NULL DEFAULT '', credential_id TEXT NOT NULL UNIQUE, credential_data TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- App passwords are the only credential IMAP/SMTP clients (Thunderbird etc.) ever see -- for a mailbox. plaintext is shown once at creation and never stored/re-shown. CREATE TABLE IF NOT EXISTS esrv_mailbox_app_passwords ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), label TEXT NOT NULL DEFAULT '', password_hash TEXT NOT NULL, is_active INTEGER NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_used_at DATETIME, expires_at DATETIME ); -- A mailbox's receive-only (or, with can_send_as, send-as too) alternate addresses. -- Login is always the mailbox's own primary address (esrv_mailboxes.email), never an -- alias — an alias only changes which addresses can deliver here / be used as MAIL -- FROM by this mailbox once authenticated via its app password. CREATE TABLE IF NOT EXISTS esrv_mailbox_aliases ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), email TEXT NOT NULL UNIQUE, domain_id INTEGER NOT NULL REFERENCES esrv_domains(id), can_send_as INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- Per-mailbox sender allow/block list. pattern is either an exact address -- ("spam@evil.com") or a whole-domain wildcard ("@evil.com"). A single table with a -- list_type column, not two near-identical tables. CREATE TABLE IF NOT EXISTS esrv_mailbox_allowblock ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), list_type TEXT NOT NULL CHECK(list_type IN ('allow','block')), pattern TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(mailbox_id, list_type, pattern) ); -- Simple first-match-wins filter rules, evaluated in priority order (lower first) at -- delivery time, before a message is encrypted and stored — so from/to/subject -- matching works against the real message, not just the plaintext cache columns below. CREATE TABLE IF NOT EXISTS esrv_mailbox_filter_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), priority INTEGER NOT NULL DEFAULT 0, condition_field TEXT NOT NULL CHECK(condition_field IN ('from','to','subject')), condition_op TEXT NOT NULL CHECK(condition_op IN ('contains','equals','starts_with')), condition_value TEXT NOT NULL, action TEXT NOT NULL CHECK(action IN ('move_to_folder','delete','mark_read')), action_value TEXT NOT NULL DEFAULT '', is_active INTEGER NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- One row per stored message. cached_from/cached_subject are deliberately plaintext -- (a narrow, confirmed exception to "encrypted at rest") so IMAP LIST/basic SEARCH -- don't need to decrypt every message in a folder; body and every other header stay -- ciphertext-only at storage_path, decrypted solely on FETCH. CREATE TABLE IF NOT EXISTS esrv_mailbox_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), folder TEXT NOT NULL DEFAULT 'INBOX', message_id_header TEXT NOT NULL DEFAULT '', flags TEXT NOT NULL DEFAULT '', internal_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, size_bytes INTEGER NOT NULL, cached_from TEXT NOT NULL DEFAULT '', cached_subject TEXT NOT NULL DEFAULT '', storage_path TEXT NOT NULL, nonce BLOB NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); ` // migrateAddedColumns best-effort ALTER TABLEs the columns added to esrv_domains // after its first release, for dev DBs created before this feature existed. // CREATE TABLE IF NOT EXISTS doesn't retrofit columns onto an existing table, and // there's no migration framework here (see the schema comment above) — errors are // ignored since SQLite has no "ADD COLUMN IF NOT EXISTS" and a duplicate-column // error just means the column is already there. func migrateAddedColumns(db *sql.DB) { stmts := []string{ `ALTER TABLE esrv_domains ADD COLUMN verification_token TEXT NOT NULL DEFAULT ''`, `ALTER TABLE esrv_domains ADD COLUMN is_verified INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE esrv_domains ADD COLUMN verified_at DATETIME`, `ALTER TABLE esrv_admin_users ADD COLUMN is_global_admin INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE esrv_admin_users ADD COLUMN created_by INTEGER`, `ALTER TABLE esrv_domains ADD COLUMN default_mailbox_quota_bytes INTEGER NOT NULL DEFAULT 5368709120`, `ALTER TABLE esrv_mailboxes ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''`, `ALTER TABLE esrv_mailboxes ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE esrv_mailbox_app_passwords ADD COLUMN expires_at DATETIME`, `ALTER TABLE esrv_admin_users ADD COLUMN must_change_username INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE esrv_domains ADD COLUMN mfa_exempt INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE esrv_mailboxes ADD COLUMN mfa_exempt INTEGER NOT NULL DEFAULT 0`, } for _, stmt := range stmts { db.Exec(stmt) } // Backfill for installs that already have a still-pending default admin (username // "admin", never completed the forced first-login yet): must_change_username // defaults to 0 for every pre-existing row above, which would otherwise let that // account skip its username change entirely once it re-hits /first-login next. db.Exec(`UPDATE esrv_admin_users SET must_change_username = 1 WHERE username = ? AND must_change_password = 1`, DefaultAdminUsername) } // DB wraps *sql.DB with the query helpers below. type DB struct { *sql.DB } // Open opens (creating if needed) the SQLite file at path and ensures the schema exists. func Open(path string) (*DB, error) { sqlDB, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } // The web UI, SMTP server, and IMAP server all share this one *sql.DB. SQLite only // allows one writer at a time, and PRAGMAs are per-connection — database/sql's // pool can silently open a second physical connection at any time, so a PRAGMA // set via Exec here isn't guaranteed to apply to whichever connection later hits a // lock. Capping the pool to one connection is the standard fix: every access is // serialized through a single physical connection, so no connection can ever // collide with another's in-progress write. sqlDB.SetMaxOpenConns(1) if _, err := sqlDB.Exec(`PRAGMA busy_timeout = 5000`); err != nil { sqlDB.Close() return nil, fmt.Errorf("set busy_timeout: %w", err) } if _, err := sqlDB.Exec(schema); err != nil { sqlDB.Close() return nil, fmt.Errorf("create tables: %w", err) } migrateAddedColumns(sqlDB) return &DB{sqlDB}, nil }