added IMAP, LetsEncrypt, update layout

This commit is contained in:
2026-08-12 21:14:19 +01:00
parent 6e103959b0
commit 70fa1a5f2c
222 changed files with 42947 additions and 14038 deletions
+133 -1
View File
@@ -21,7 +21,8 @@ CREATE TABLE IF NOT EXISTS esrv_domains (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
verification_token TEXT NOT NULL DEFAULT '',
is_verified INTEGER NOT NULL DEFAULT 0,
verified_at DATETIME
verified_at DATETIME,
default_mailbox_quota_bytes INTEGER NOT NULL DEFAULT 5368709120
);
CREATE TABLE IF NOT EXISTS esrv_senders (
@@ -149,6 +150,122 @@ CREATE TABLE IF NOT EXISTS esrv_webauthn_credentials (
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
);
-- 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
);
-- 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
@@ -164,6 +281,9 @@ func migrateAddedColumns(db *sql.DB) {
`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`,
}
for _, stmt := range stmts {
db.Exec(stmt)
@@ -181,6 +301,18 @@ func Open(path string) (*DB, error) {
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)