973 lines
50 KiB
Go
973 lines
50 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"
|
|
"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,
|
|
-- Opt-in fallback mailbox for a recipient that matches no mailbox/alias/sub-address
|
|
-- on this domain — NULL (the default) means an unresolved recipient still bounces
|
|
-- normally. See mailstore.ResolveRecipient.
|
|
catchall_mailbox_id INTEGER REFERENCES esrv_mailboxes(id),
|
|
-- Outbound send-rate cap: NULL (default) means unlimited. Counts every SMTP DATA
|
|
-- transaction logged from this domain (esrv_email_logs) in a rolling hour — see
|
|
-- Session.domainSendRateLimited. A compromised account sending a burst should slow
|
|
-- down, not silently vanish undelivered mail; over-limit sends get a retryable
|
|
-- 450, not a hard reject.
|
|
send_rate_limit_per_hour INTEGER,
|
|
-- MTA-STS mode this domain's served policy (GET /.well-known/mta-sts.txt)
|
|
-- advertises to senders — 'testing' (default, safe: report-only, nothing this
|
|
-- server does changes based on it) or 'enforce' (senders that respect MTA-STS
|
|
-- should hard-fail rather than deliver over an unverified connection). Requires
|
|
-- the operator to actually publish the corresponding _mta-sts TXT record and
|
|
-- point mta-sts.<domain> DNS at this server — the admin UI documents both.
|
|
mta_sts_mode TEXT NOT NULL DEFAULT 'testing',
|
|
-- Per-domain CalDAV/CardDAV master switches — both off by default. A mailbox's own
|
|
-- caldav_enabled/carddav_enabled (esrv_mailboxes) only takes effect when the
|
|
-- matching switch here is also on; DAVBasicAuth checks both, so an admin opts a
|
|
-- domain in first, then each mailbox owner opts themselves in from webmail settings.
|
|
caldav_enabled INTEGER NOT NULL DEFAULT 0,
|
|
carddav_enabled INTEGER NOT NULL DEFAULT 0,
|
|
-- 'manual' (default) keeps today's copy-the-TXT-record-yourself flow; 'automatic'
|
|
-- pushes it via a DNS provider API instead, using this domain's own credentials in
|
|
-- esrv_domain_dns_credentials — see dkim.Manager.GenerateAndPublish.
|
|
dkim_dns_automation TEXT NOT NULL DEFAULT 'manual',
|
|
-- Opt-in to sign this domain's outbound mail with the one shared/global DKIM key
|
|
-- (esrv_global_dkim_key) instead of this domain's own esrv_dkim_keys key material —
|
|
-- the domain's own selector is still what's used (see dkim.Manager.GetActiveDKIMKey),
|
|
-- so its own key row is kept, just not used for signing while this is on. Lets a
|
|
-- customer CNAME once and never touch DNS again on rotation — see
|
|
-- esrv_global_dkim_key's comment for the mechanism.
|
|
use_global_dkim INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
-- One shared DKIM key, used by every domain with esrv_domains.use_global_dkim set —
|
|
-- published at "<selector>._domainkey.<[DKIM] global_dkim_hostname>" in the operator's
|
|
-- own zone (not any customer's). A customer CNAMEs their own
|
|
-- "<their selector>._domainkey.<their domain>" at that one record once; regenerating
|
|
-- this key (by hand or on [DKIM] global_dkim_rotation_days) then rotates DKIM for
|
|
-- every opted-in domain at once, with no further DNS work from any of them — this is
|
|
-- how Hornetsecurity's own CNAME delegation works. Only one is_active=1 row expected;
|
|
-- regenerating deactivates the previous one first, same convention as esrv_dkim_keys.
|
|
CREATE TABLE IF NOT EXISTS esrv_global_dkim_key (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
selector TEXT NOT NULL DEFAULT '',
|
|
private_key TEXT NOT NULL,
|
|
public_key TEXT NOT NULL,
|
|
is_active INTEGER NOT NULL DEFAULT 1,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
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
|
|
);
|
|
|
|
-- In-flight outbound relay work — one row per recipient-domain-group (matching how
|
|
-- RelayEmailAsync/EnqueueForDelivery already batch same-domain recipients into a
|
|
-- single SMTP transaction), so a slow/unreachable domain never holds a client's SMTP
|
|
-- session open (see internal/relay/queue.go). Purely transient: a row is deleted the
|
|
-- moment its group reaches a terminal outcome (success, or retries exhausted) —
|
|
-- esrv_email_recipient_logs is already the permanent historical record, this table is
|
|
-- operational state only, not a second copy of history.
|
|
CREATE TABLE IF NOT EXISTS esrv_relay_queue (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email_log_id INTEGER NOT NULL REFERENCES esrv_email_logs(id),
|
|
mail_from TEXT NOT NULL,
|
|
domain TEXT NOT NULL,
|
|
-- JSON array of {"recipient":"...","type":"to|cc|bcc"} for this domain group.
|
|
recipients_json TEXT NOT NULL,
|
|
-- The full signed message content for this group — stored directly here (not a
|
|
-- new blob-storage subsystem) matching how esrv_email_logs.message_body already
|
|
-- stores raw content directly; rows are short-lived under normal operation.
|
|
content TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending', -- pending | sending
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
next_attempt_at DATETIME NOT NULL,
|
|
last_error TEXT NOT NULL DEFAULT '',
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_relay_queue_due ON esrv_relay_queue(status, next_attempt_at);
|
|
|
|
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
|
|
);
|
|
|
|
-- Per-domain credentials for automatically publishing that domain's own DKIM TXT
|
|
-- record via a DNS provider API, instead of an admin manually copying it into their
|
|
-- DNS provider's UI — only takes effect when esrv_domains.dkim_dns_automation is
|
|
-- 'automatic'. Plaintext, matching how [LetsEncrypt]'s own DNS-01 provider credentials
|
|
-- are already stored in settings.ini for the same reason (this codebase's existing
|
|
-- norm for this class of secret). See internal/dnspublish for what each field means.
|
|
CREATE TABLE IF NOT EXISTS esrv_domain_dns_credentials (
|
|
domain_id INTEGER PRIMARY KEY REFERENCES esrv_domains(id),
|
|
provider TEXT NOT NULL DEFAULT '',
|
|
zone_name TEXT NOT NULL DEFAULT '',
|
|
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
|
route53_access_key_id TEXT NOT NULL DEFAULT '',
|
|
route53_secret_access_key TEXT NOT NULL DEFAULT '',
|
|
route53_region TEXT NOT NULL DEFAULT '',
|
|
digitalocean_api_token TEXT NOT NULL DEFAULT '',
|
|
gcloud_project TEXT NOT NULL DEFAULT '',
|
|
gcloud_service_account_json TEXT NOT NULL DEFAULT ''
|
|
);
|
|
|
|
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',
|
|
-- Persistent mailbox-level forwarding — distinct from and independent of a filter
|
|
-- rule's own "forward" action (mailstore.ApplyRules): this applies unconditionally
|
|
-- to every message, checked early in deliverLocally, not matched against
|
|
-- from/subject/etc. NULL forward_to means forwarding is off.
|
|
forward_to TEXT,
|
|
forward_keep_copy INTEGER NOT NULL DEFAULT 1,
|
|
-- This mailbox's own opt-in for CalDAV/CardDAV sync — off by default, and only
|
|
-- actually reachable when the owning domain's matching esrv_domains switch is also
|
|
-- on (DAVBasicAuth checks both).
|
|
caldav_enabled INTEGER NOT NULL DEFAULT 0,
|
|
carddav_enabled 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,
|
|
-- Best-effort, for the self-service "Active sessions" list (webmail account
|
|
-- settings) to show something recognizable per session — never security-critical
|
|
-- (session validity is the token alone), so a missing/empty value here just
|
|
-- means a blanker list row, not a functional problem.
|
|
user_agent TEXT NOT NULL DEFAULT '',
|
|
ip_address TEXT NOT NULL DEFAULT ''
|
|
);
|
|
|
|
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,
|
|
scope TEXT NOT NULL DEFAULT 'all',
|
|
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)
|
|
);
|
|
|
|
-- A mailbox owner's own calendar events — CalDAV (internal/webui/caldav.go) and the
|
|
-- webmail Calendar page (internal/webui/webmail_calendar.go) are two views onto the
|
|
-- same rows, same relationship as esrv_mailbox_contacts is to CardDAV. uid is the
|
|
-- stable CalDAV resource identity (like esrv_mailbox_contacts.uid); rrule stores the
|
|
-- raw RRULE value string (empty = non-recurring) — occurrence expansion for the
|
|
-- webmail month-view grid happens in Go via rrule-go, not SQL.
|
|
CREATE TABLE IF NOT EXISTS esrv_mailbox_calendar_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
|
uid TEXT NOT NULL,
|
|
summary TEXT NOT NULL DEFAULT '',
|
|
description TEXT NOT NULL DEFAULT '',
|
|
location TEXT NOT NULL DEFAULT '',
|
|
start_at DATETIME NOT NULL,
|
|
end_at DATETIME NOT NULL,
|
|
all_day INTEGER NOT NULL DEFAULT 0,
|
|
rrule TEXT NOT NULL DEFAULT '',
|
|
color TEXT NOT NULL DEFAULT '',
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(mailbox_id, uid)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_calendar_events_mailbox_start ON esrv_mailbox_calendar_events(mailbox_id, start_at);
|
|
|
|
-- At most one reminder per event in this pass (the webmail UI only ever offers a
|
|
-- single "remind me N minutes before") — its own table rather than a column so a
|
|
-- later multi-reminder UI won't need a schema change; the single-row invariant is
|
|
-- enforced in Go (SetEventReminder), not a constraint. No ON DELETE CASCADE here —
|
|
-- this DB never enables PRAGMA foreign_keys (same as every other REFERENCES in this
|
|
-- schema), so deleting an event explicitly deletes its reminder row too, in Go
|
|
-- (DeleteEvent/DeleteEventByUID), rather than relying on a cascade that wouldn't fire.
|
|
CREATE TABLE IF NOT EXISTS esrv_mailbox_calendar_reminders (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
event_id INTEGER NOT NULL REFERENCES esrv_mailbox_calendar_events(id),
|
|
minutes_before INTEGER NOT NULL
|
|
);
|
|
|
|
-- 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','auto_reply')),
|
|
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
|
|
);
|
|
|
|
-- Loop/storm prevention for the 'auto_reply' filter-rule action: one row per
|
|
-- (mailbox, sender) auto-reply actually sent, checked before sending another —
|
|
-- skipped if this mailbox already auto-replied to this sender within the last 24h
|
|
-- (fixed window, not admin-configurable, matching how most mainstream vacation
|
|
-- responders behave by default). Without this, two auto-responders emailing each
|
|
-- other would loop forever.
|
|
CREATE TABLE IF NOT EXISTS esrv_mailbox_autoreply_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
|
sender_addr TEXT NOT NULL,
|
|
sent_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_autoreply_log_mailbox_sender ON esrv_mailbox_autoreply_log(mailbox_id, sender_addr, sent_at);
|
|
|
|
-- 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 ''`,
|
|
// Default 'all' here is deliberate backward compatibility: every pre-existing
|
|
// allow-list entry keeps its current full-bypass behavior after this migration.
|
|
// New entries get 'spam' as their default going forward — see webmailBlocklistAdd.
|
|
`ALTER TABLE esrv_mailbox_allowblock ADD COLUMN scope TEXT NOT NULL DEFAULT 'all'`,
|
|
`ALTER TABLE esrv_mailbox_contacts ADD COLUMN uid TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE esrv_mailbox_contacts ADD COLUMN given_name TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE esrv_mailbox_contacts ADD COLUMN family_name TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE esrv_mailbox_contacts ADD COLUMN org TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE esrv_mailbox_contacts ADD COLUMN updated_at DATETIME`,
|
|
`ALTER TABLE esrv_domains ADD COLUMN catchall_mailbox_id INTEGER REFERENCES esrv_mailboxes(id)`,
|
|
`ALTER TABLE esrv_domains ADD COLUMN send_rate_limit_per_hour INTEGER`,
|
|
`ALTER TABLE esrv_mailbox_sessions ADD COLUMN user_agent TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE esrv_mailbox_sessions ADD COLUMN ip_address TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE esrv_domains ADD COLUMN mta_sts_mode TEXT NOT NULL DEFAULT 'testing'`,
|
|
`ALTER TABLE esrv_mailboxes ADD COLUMN forward_to TEXT`,
|
|
`ALTER TABLE esrv_mailboxes ADD COLUMN forward_keep_copy INTEGER NOT NULL DEFAULT 1`,
|
|
`ALTER TABLE esrv_domains ADD COLUMN caldav_enabled INTEGER NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE esrv_domains ADD COLUMN carddav_enabled INTEGER NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE esrv_mailboxes ADD COLUMN caldav_enabled INTEGER NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE esrv_mailboxes ADD COLUMN carddav_enabled INTEGER NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE esrv_domains ADD COLUMN dkim_dns_automation TEXT NOT NULL DEFAULT 'manual'`,
|
|
`ALTER TABLE esrv_domains ADD COLUMN use_global_dkim INTEGER NOT NULL DEFAULT 0`,
|
|
}
|
|
// 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)
|
|
migrateFilterRulesAutoReplyCheck(db)
|
|
migrateAllowBlockJunkCheck(db)
|
|
migrateContactUIDs(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, scope, created_at)
|
|
SELECT id, mailbox_id, list_type, pattern, scope, 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`)
|
|
}
|
|
|
|
// migrateFilterRulesAutoReplyCheck rebuilds esrv_mailbox_filter_rules for any DB
|
|
// created before 'auto_reply' was added to the action CHECK constraint — same
|
|
// rename/recreate/copy/drop approach as migrateFilterRulesAdvancedCheck above,
|
|
// deliberately run after it for the same reason (a DB that predates every migration
|
|
// gets caught up by each in turn without any of them needing to know about the
|
|
// others' column set).
|
|
func migrateFilterRulesAutoReplyCheck(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, "'auto_reply'") {
|
|
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'`)
|
|
}
|
|
|
|
// migrateContactUIDs backfills a stable CardDAV resource UID (and updated_at) for any
|
|
// contact row created before the CardDAV feature existed — new rows get theirs at
|
|
// insert time (see crud_mailbox_contacts.go), so this only ever touches leftover rows
|
|
// with the ALTER-added column's zero value.
|
|
func migrateContactUIDs(db *sql.DB) {
|
|
rows, err := db.Query(`SELECT id FROM esrv_mailbox_contacts WHERE uid = ''`)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var ids []int64
|
|
for rows.Next() {
|
|
var id int64
|
|
if rows.Scan(&id) == nil {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
rows.Close()
|
|
for _, id := range ids {
|
|
db.Exec(`UPDATE esrv_mailbox_contacts SET uid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, generateContactUID(), id)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|