Files
mailgoserver/internal/db/schema.go
T
2026-08-12 12:56:22 +01:00

191 lines
6.2 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
);
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,
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
);
`
// 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`,
}
for _, stmt := range stmts {
db.Exec(stmt)
}
}
// 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)
}
if _, err := sqlDB.Exec(schema); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("create tables: %w", err)
}
migrateAddedColumns(sqlDB)
return &DB{sqlDB}, nil
}