first commit
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user