// 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
);

-- 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
);

-- 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.
-- 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}) 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.
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','mark_as_spam')),
	action_value 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
);

-- 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 '',
	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, Spam,
-- Sent, Drafts, Trash) are never stored here; they're always shown by the webui
-- regardless of this table.
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,
	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
);

-- 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 ''`,
		// conditions_json/match_type are retrofittable via ALTER TABLE, but the action
		// CHECK constraint (adding 'mark_as_spam') is not — SQLite doesn't support
		// altering a CHECK on an existing table. A dev DB created before this change
		// would need recreating to accept a mark_as_spam rule; a fresh install gets it
		// for free from the CREATE TABLE above.
		`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 ''`,
	}
	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
}
