// 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" "strings" _ "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 ); -- Matches CountRecentFailedAttempts' lockout-check query. CREATE INDEX IF NOT EXISTS idx_auth_logs_lockout ON esrv_auth_logs(identifier, auth_type, created_at); -- Matches CountFailedAuthAttemptsByIP's abuse-detection query (internal/abuseguard) — -- a different access pattern than the lockout index above (by IP, not identifier). CREATE INDEX IF NOT EXISTS idx_auth_logs_by_ip ON esrv_auth_logs(ip_address, created_at); -- Temporary IP blocks, auto-created by internal/abuseguard when one IP racks up too -- many failed SMTP/IMAP auth attempts within a short window (see -- CountFailedAuthAttemptsByIP), or manually by an admin from the Blacklist page. -- offense_count drives escalating block duration on repeat offenders — see -- BlacklistIP's doc comment for the exact formula. Deliberately separate from -- esrv_whitelisted_ips (which authorizes unauthenticated relay for a domain, a -- completely different concern) and from the web login lockout in -- internal/webui/ratelimit.go (which never touches this table). CREATE TABLE IF NOT EXISTS esrv_ip_blacklist ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip_address TEXT NOT NULL UNIQUE, reason TEXT NOT NULL DEFAULT '', offense_count INTEGER NOT NULL DEFAULT 1, manual INTEGER NOT NULL DEFAULT 0, blacklisted_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME NOT NULL ); CREATE INDEX IF NOT EXISTS idx_ip_blacklist_expiry ON esrv_ip_blacklist(ip_address, expires_at); -- IPs exempt from abuse detection (internal/abuseguard never blacklists or blocks -- these) — again deliberately separate from esrv_whitelisted_ips. CREATE TABLE IF NOT EXISTS esrv_ip_abuse_whitelist ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip_address TEXT NOT NULL UNIQUE, note TEXT NOT NULL DEFAULT '', 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, -- Off by default: collapse a run of same-subject messages in a folder view into one -- expandable row. Per-mailbox, not global, since this is purely a display preference. group_messages INTEGER NOT NULL DEFAULT 0, -- Remote (http/https) images in an HTML email body are a classic tracking-pixel / -- read-receipt leak, so they're never auto-loaded — this controls when they show at -- all: 'ask' (default) strips them and offers a per-message "Show images" reveal; -- 'trusted' auto-shows only for senders on this mailbox's own -- esrv_mailbox_trusted_image_senders list; 'always' never blocks (not recommended, -- offered anyway since it's the mailbox owner's own call). See -- webmail_mail.go's loadMessageForView / stripRemoteImages. remote_images_mode TEXT NOT NULL DEFAULT 'ask' ); -- 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/junk 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 three near-identical tables. -- 'block' is admin-only (Prefix+"/mailboxes/{id}/lists") and hard-rejects at RCPT -- time (db.IsBlocked, checked in smtpserver's Rcpt()) — an anti-abuse tool, not -- something an end user self-manages. 'junk' is the mailbox owner's own self-service -- Blocklist (webmail Settings, or the message view's "Mark as Junk" action) and is a -- *soft* block instead: mail is still accepted and delivered, just straight to Junk -- (db.IsJunked, checked in smtpserver's deliverLocally, bypassing spam scoring -- entirely the same way 'allow' does) rather than bounced. Different tools for -- different jobs — collapsing them into one would either strip admins of a real hard -- reject or hand end users the ability to bounce mail on another user's behalf. 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','junk')), pattern TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(mailbox_id, list_type, pattern) ); -- A mailbox owner's own address book — deliberately separate from -- esrv_mailbox_smime_contacts/esrv_mailbox_pgp_contacts (those hold a certificate/key -- per address for signing/encryption, not a person's name/phone) and from -- SuggestRecipients' history-based autocomplete (mailbox_messages.go — reuses -- cached_to/cached_from rather than a dedicated table, so it has no name/phone either -- and can't be edited). name is required; phone is optional free text (no format -- enforced — international numbers, extensions, etc. all vary too much to validate -- usefully here). CREATE TABLE IF NOT EXISTS esrv_mailbox_contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), email TEXT NOT NULL, name TEXT NOT NULL, phone TEXT NOT NULL DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(mailbox_id, email) ); -- 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/body -- matching works against the real message, not just the plaintext cache columns below. -- condition_field/op/value are the legacy single-condition columns, kept for rows -- created before multi-condition support existed. Every rule created since then -- stores its full condition list in conditions_json (a JSON array of -- {field,op,value}, value optionally "\n"-joined to mean "any of these" — see -- mailstore.matchCondition) instead, combined per match_type ("all"=AND, "any"=OR); a -- rule with an empty conditions_json falls back to the legacy columns as a single -- condition — see MailboxFilterRule.Conditions() in mailbox_models.go. -- has_attachment/recipient_type conditions match against a synthetic "yes"/"no" or -- "to"/"cc"/"bcc" header value computed at delivery time (see smtpserver's -- deliverLocally), not a real message header. -- action_options_json holds action-specific parameters that don't apply to every -- action (currently just "forward"'s keep_copy) — see MailboxFilterRule.ActionOptions(). CREATE TABLE IF NOT EXISTS esrv_mailbox_filter_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), name TEXT NOT NULL DEFAULT '', priority INTEGER NOT NULL DEFAULT 0, condition_field TEXT NOT NULL CHECK(condition_field IN ('from','to','subject','body','has_attachment','recipient_type')), 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','mark_as_spam','forward')), action_value TEXT NOT NULL DEFAULT '', action_options_json TEXT NOT NULL DEFAULT '', is_active INTEGER NOT NULL DEFAULT 1, conditions_json TEXT NOT NULL DEFAULT '', match_type TEXT NOT NULL DEFAULT 'all', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- Senders a mailbox owner has explicitly said to always show remote images from -- (esrv_mailboxes.remote_images_mode = 'trusted') — added either from the account -- settings page or via the "always allow images from this sender" checkbox offered -- alongside the per-message "Show images" reveal. CREATE TABLE IF NOT EXISTS esrv_mailbox_trusted_image_senders ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), email TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(mailbox_id, email) ); -- 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_to TEXT NOT NULL DEFAULT '', cached_subject TEXT NOT NULL DEFAULT '', -- First ~150 characters of the plain-text body, cached in plain text (like the -- other cached_* columns) so the folder list can show a preview snippet without -- decrypting the full message just to render the list. cached_preview TEXT NOT NULL DEFAULT '', -- Snapshot of folder taken the moment a message is moved to Trash (see -- db.MoveMessageToTrash), so "Restore" (db.RestoreMessage) can put it back where -- it came from instead of just dumping it in INBOX. Empty outside of that window -- (cleared again once restored). restore_folder TEXT NOT NULL DEFAULT '', storage_path TEXT NOT NULL, nonce BLOB NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- Matches the folder view's exact WHERE mailbox_id = ? AND folder = ? ORDER BY -- internal_date pattern — the single hottest query in the whole webmail client, and -- previously unindexed (this schema had no indexes at all before this one). CREATE INDEX IF NOT EXISTS idx_mailbox_messages_folder ON esrv_mailbox_messages(mailbox_id, folder, internal_date); -- Explicit record of a mailbox's custom folders, so a freshly created (still empty) -- one shows up in the folder list — esrv_mailbox_messages.folder alone can only prove -- a folder exists once it holds at least one message. Standard folders (INBOX, Junk, -- Sent, Drafts, Trash) don't need a row to be listed — they're always shown by the -- webui regardless — but DO get one the first time they're renamed, dragged to a new -- position, or made a parent, purely to hold that metadata (see CreateMailboxFolder, -- SetFolderOrder). -- -- Custom folders form a real tree, always rooted at one of the 5 standard folders -- (webui only ever offers "New folder" under INBOX, and "delete a folder" re-parents -- it under Trash — see webmailDeleteFolder). Exactly one of parent_id/parent_root is -- ever set per row: parent_id (an id, not a name) when the parent is another custom -- folder — immune to that parent later being renamed, unlike a name-based reference — -- parent_root (one of the 5 fixed, never-renamed standard names) when the parent is a -- standard folder that may not have its own row. A row with BOTH unset (parent_id -- NULL, parent_root '') is legacy data predating this column and is treated as -- "under INBOX" (see db.FolderParentMap) — every custom folder created before this -- feature was flat/top-level anyway, so that default is exactly correct, no backfill -- migration needed. -- -- esrv_mailbox_messages.folder itself is untouched by any of this — messages are -- still tagged with a flat folder name exactly as before; the parent/child -- relationship here is purely an organizational layer for the sidebar. CREATE TABLE IF NOT EXISTS esrv_mailbox_folders ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), name TEXT NOT NULL, parent_id INTEGER REFERENCES esrv_mailbox_folders(id), parent_root TEXT NOT NULL DEFAULT '', -- Sidebar sort position among this folder's siblings (lower first), set only once -- the user drags to reorder. The app always writes an explicit value: -1 -- (db.unpositionedFolder) for a row that exists for some other reason (rename, a -- folder just created, becoming a parent) but was never dragged, so it doesn't -- look like a real "dragged to the very top" (0) — FolderPositions filters on -- position >= 0 for exactly this reason. Column default of 0 is inert (every write -- path is explicit); kept only as a harmless fallback. position INTEGER NOT NULL DEFAULT 0, -- Snapshot of parent_id/parent_root taken the moment a folder is deleted (moved -- under Trash — see db.MoveFolderToTrash), so "Restore" (db.RestoreFolder) can put -- it back where it came from instead of just dumping it at INBOX's top level. -- Empty/NULL outside of that window (cleared again once restored). restore_parent_id INTEGER REFERENCES esrv_mailbox_folders(id), restore_parent_root TEXT NOT NULL DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(mailbox_id, name) ); -- A mailbox's own S/MIME identities — a mailbox may hold several at once (e.g. one -- per external party it corresponds with, or after rotating an expiring one while -- keeping the old one around to read old mail). S/MIME is sign-only in this -- codebase (PGP handles encryption — see esrv_mailbox_pgp_identities below), so the -- private key is stored plain, same trust model as the PGP private key column: the -- server already holds everything needed to use it, with no separate -- passphrase-derived wrapper (that was tried and removed — see git history — it was -- pure friction for an asset that was never actually protecting anything a server -- compromise wouldn't already expose). -- Superseded esrv_mailbox_smime_identity (singular, one auto-unwrapped identity per -- mailbox) is left in place unused rather than migrated. CREATE TABLE IF NOT EXISTS esrv_mailbox_smime_identities ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), cert_pem TEXT NOT NULL, key_pem TEXT NOT NULL, not_after DATETIME NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- Other people's public certificates a mailbox owner has collected — added by hand -- or auto-captured off a verified incoming signature. Used to offer "Encrypt" for a -- recipient in compose and to flag a known signer on read; never chain-validated -- against a CA (see internal/smime package doc). CREATE TABLE IF NOT EXISTS esrv_mailbox_smime_contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), email TEXT NOT NULL, cert_pem TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(mailbox_id, email) ); -- A mailbox's own PGP keys — used only for encryption in this codebase (S/MIME, -- above, handles signing). A mailbox may hold several. OpenPGP's own private key -- packet format carries its own passphrase protection natively (see -- pgp.GenerateKeyPair's doc comment) — private_key_armor is stored exactly as the -- library serializes it, already passphrase-protected (unlike S/MIME's key_pem, -- which is stored plain). -- label is a free-text user note (PGP keys have no expiry to distinguish them by the -- way generated S/MIME certs do). CREATE TABLE IF NOT EXISTS esrv_mailbox_pgp_identities ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), label TEXT NOT NULL DEFAULT '', email TEXT NOT NULL, fingerprint TEXT NOT NULL, public_key_armor TEXT NOT NULL, private_key_armor TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- A mailbox's saved email signatures — HTML content (compose is HTML/Quill-based -- already; a plain-text signature is just HTML with no formatting, so one content -- column covers both instead of storing two parallel copies). A mailbox may hold -- several, e.g. one formal and one casual; is_default_new/is_default_reply pick which -- one (if any) compose pre-fills for a brand-new message vs a reply/forward — at most -- one row per mailbox should have each flag set, enforced in the db package (Go), not -- a SQL constraint, since "make this one the default" is naturally an -- update-this-then-clear-the-others operation either way. CREATE TABLE IF NOT EXISTS esrv_mailbox_signatures ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), name TEXT NOT NULL, content_html TEXT NOT NULL DEFAULT '', is_default_new INTEGER NOT NULL DEFAULT 0, is_default_reply INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_mailbox_signatures_mailbox ON esrv_mailbox_signatures(mailbox_id); -- Per-alias override of which signature is the default-for-new/default-for-reply, -- for a mailbox with one or more send-as aliases (esrv_mailbox_aliases) wanting a -- different signature depending which address they're composing as — e.g. a -- "Support" signature for support@ and a personal one for their own address. -- Purely additive on top of esrv_mailbox_signatures' own is_default_new/is_default_reply -- columns, which remain the fallback default (for_email = the mailbox's own primary -- address, or an alias with no override row here) — see GetDefaultSignature. CREATE TABLE IF NOT EXISTS esrv_mailbox_signature_alias_defaults ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), for_email TEXT NOT NULL, for_reply INTEGER NOT NULL DEFAULT 0, signature_id INTEGER NOT NULL REFERENCES esrv_mailbox_signatures(id), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(mailbox_id, for_email, for_reply) ); -- Other people's PGP public keys a mailbox owner has collected, added by hand — -- mirrors esrv_mailbox_smime_contacts. Used to offer "Encrypt (PGP)" for a -- recipient in compose. CREATE TABLE IF NOT EXISTS esrv_mailbox_pgp_contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id), email TEXT NOT NULL, label TEXT NOT NULL DEFAULT '', public_key_armor TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(mailbox_id, email) ); ` // 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`, `ALTER TABLE esrv_mailbox_messages ADD COLUMN cached_to TEXT NOT NULL DEFAULT ''`, // The action CHECK constraint (adding 'mark_as_spam') isn't retrofittable via // ALTER TABLE — see migrateFilterRulesMarkAsSpamCheck below, called at the end // of this function, which rebuilds the table for DBs that predate it. `ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN conditions_json TEXT NOT NULL DEFAULT ''`, `ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN match_type TEXT NOT NULL DEFAULT 'all'`, // key_pem replaces the old passphrase-wrapped key_ciphertext/key_nonce/key_salt // columns — a dev DB with pre-existing identities just loses their (now // unrecoverable-without-code-that-no-longer-exists) keys, same "not migrated" // treatment as the singular-table identities before them. `ALTER TABLE esrv_mailbox_smime_identities ADD COLUMN key_pem TEXT NOT NULL DEFAULT ''`, `ALTER TABLE esrv_mailboxes ADD COLUMN group_messages INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE esrv_mailbox_messages ADD COLUMN cached_preview TEXT NOT NULL DEFAULT ''`, `ALTER TABLE esrv_mailbox_folders ADD COLUMN position INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE esrv_mailbox_folders ADD COLUMN parent_id INTEGER REFERENCES esrv_mailbox_folders(id)`, `ALTER TABLE esrv_mailbox_folders ADD COLUMN parent_root TEXT NOT NULL DEFAULT ''`, `ALTER TABLE esrv_mailbox_folders ADD COLUMN restore_parent_id INTEGER REFERENCES esrv_mailbox_folders(id)`, `ALTER TABLE esrv_mailbox_folders ADD COLUMN restore_parent_root TEXT NOT NULL DEFAULT ''`, `ALTER TABLE esrv_mailbox_messages ADD COLUMN restore_folder TEXT NOT NULL DEFAULT ''`, `ALTER TABLE esrv_mailboxes ADD COLUMN remote_images_mode TEXT NOT NULL DEFAULT 'ask'`, // The condition_field/action CHECK constraints (adding body/has_attachment/ // recipient_type and forward) aren't retrofittable via ALTER TABLE either — see // migrateFilterRulesAdvancedCheck below. name/action_options_json themselves are // plain columns and migrate fine here. `ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN name TEXT NOT NULL DEFAULT ''`, `ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN action_options_json TEXT NOT NULL DEFAULT ''`, } // The three old columns above were NOT NULL with no default, so simply adding // key_pem left them behind still blocking every new insert (which only ever sets // key_pem, never these) on any DB created before this migration — confirmed live: // "NOT NULL constraint failed: esrv_mailbox_smime_identities.key_ciphertext". Needs // SQLite 3.35+ for DROP COLUMN; modernc.org/sqlite is well past that. for _, col := range []string{"key_ciphertext", "key_nonce", "key_salt"} { db.Exec(`ALTER TABLE esrv_mailbox_smime_identities DROP COLUMN ` + col) } 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) migrateSpamRenamedToJunk(db) migrateFilterRulesMarkAsSpamCheck(db) migrateFilterRulesAdvancedCheck(db) migrateAllowBlockJunkCheck(db) } // migrateAllowBlockJunkCheck rebuilds esrv_mailbox_allowblock for any DB created // before 'junk' was added to the list_type CHECK constraint (the self-service // webmail Blocklist feature) — same rename/recreate/copy/drop approach as // migrateFilterRulesMarkAsSpamCheck above. func migrateAllowBlockJunkCheck(db *sql.DB) { var tableSQL string if err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'esrv_mailbox_allowblock'`).Scan(&tableSQL); err != nil { return } if strings.Contains(tableSQL, "'junk'") { return } if _, err := db.Exec(`ALTER TABLE esrv_mailbox_allowblock RENAME TO esrv_mailbox_allowblock_old`); err != nil { return } if _, err := db.Exec(schema); err != nil { return } db.Exec(`INSERT INTO esrv_mailbox_allowblock (id, mailbox_id, list_type, pattern, created_at) SELECT id, mailbox_id, list_type, pattern, created_at FROM esrv_mailbox_allowblock_old`) db.Exec(`DROP TABLE esrv_mailbox_allowblock_old`) } // migrateFilterRulesMarkAsSpamCheck rebuilds esrv_mailbox_filter_rules for any DB // created before 'mark_as_spam' was added to the action CHECK constraint (still a // valid rule-builder action today — see the Rules section of Settings) — SQLite // can't ALTER a CHECK constraint on an existing table, so the only way to widen // it is to recreate the table under the current schema and copy the rows across. // Detects the stale constraint by inspecting sqlite_master rather than tracking a // schema-version number, so it stays a no-op forever once a DB is caught up. func migrateFilterRulesMarkAsSpamCheck(db *sql.DB) { var tableSQL string if err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'esrv_mailbox_filter_rules'`).Scan(&tableSQL); err != nil { return } if strings.Contains(tableSQL, "mark_as_spam") { return } if _, err := db.Exec(`ALTER TABLE esrv_mailbox_filter_rules RENAME TO esrv_mailbox_filter_rules_old`); err != nil { return } if _, err := db.Exec(schema); err != nil { return } db.Exec(`INSERT INTO esrv_mailbox_filter_rules (id, mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value, is_active, conditions_json, match_type, created_at) SELECT id, mailbox_id, priority, condition_field, condition_op, condition_value, action, action_value, is_active, conditions_json, match_type, created_at FROM esrv_mailbox_filter_rules_old`) db.Exec(`DROP TABLE esrv_mailbox_filter_rules_old`) } // migrateFilterRulesAdvancedCheck rebuilds esrv_mailbox_filter_rules for any DB created // before the advanced rule builder (body/has_attachment/recipient_type conditions, // forward action) widened condition_field/action's CHECK constraints — same // rename/recreate/copy/drop approach as migrateFilterRulesMarkAsSpamCheck above, and // deliberately run after it so a DB that predates both migrations gets caught up by // each in turn without either one needing to know about the other's column set. // name/action_options_json are plain columns already added by migrateAddedColumns // above by the time this runs, so the INSERT below can select them unconditionally. func migrateFilterRulesAdvancedCheck(db *sql.DB) { var tableSQL string if err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'esrv_mailbox_filter_rules'`).Scan(&tableSQL); err != nil { return } if strings.Contains(tableSQL, "'forward'") { return } if _, err := db.Exec(`ALTER TABLE esrv_mailbox_filter_rules RENAME TO esrv_mailbox_filter_rules_old`); err != nil { return } if _, err := db.Exec(schema); err != nil { return } db.Exec(`INSERT INTO esrv_mailbox_filter_rules (id, mailbox_id, name, priority, condition_field, condition_op, condition_value, action, action_value, action_options_json, is_active, conditions_json, match_type, created_at) SELECT id, mailbox_id, name, priority, condition_field, condition_op, condition_value, action, action_value, action_options_json, is_active, conditions_json, match_type, created_at FROM esrv_mailbox_filter_rules_old`) db.Exec(`DROP TABLE esrv_mailbox_filter_rules_old`) } // migrateSpamRenamedToJunk renames the standard "Spam" folder to "Junk" for mailboxes // that already had messages/records under the old name — "Junk" is what most desktop // IMAP clients look for by name (see db.StandardMailboxFolders' doc comment). Always // safe to re-run: once nothing's left named "Spam" every UPDATE here matches zero // rows. // ponytail: doesn't handle the (very unlikely) case where a mailbox already has an // unrelated custom folder literally named "Junk" before this rename — that one row's // UPDATE would fail on the UNIQUE(mailbox_id, name) constraint and get silently // skipped, same as every other best-effort statement in this function. Rename that // mailbox's pre-existing "Junk" folder first if this ever comes up in practice. func migrateSpamRenamedToJunk(db *sql.DB) { db.Exec(`UPDATE esrv_mailbox_messages SET folder = 'Junk' WHERE folder = 'Spam'`) db.Exec(`UPDATE esrv_mailbox_folders SET name = 'Junk' WHERE name = 'Spam'`) db.Exec(`UPDATE esrv_mailbox_folders SET parent_root = 'Junk' WHERE parent_root = 'Spam'`) } // 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 }