336 lines
13 KiB
Go
336 lines
13 KiB
Go
// 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
|
|
}
|