381 lines
14 KiB
Go
381 lines
14 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// migration is a versioned schema change.
|
|
type migration struct {
|
|
version int
|
|
up string // SQL to apply
|
|
}
|
|
|
|
// migrations must be append-only. Never edit an applied migration.
|
|
var migrations = []migration{
|
|
{1, schemav1},
|
|
{2, schemav2},
|
|
}
|
|
|
|
// migrate applies any unapplied migrations in order.
|
|
func (d *DB) migrate() error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
defer cancel()
|
|
|
|
// Ensure migrations table exists.
|
|
_, err := d.db.ExecContext(ctx, `
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version INTEGER PRIMARY KEY,
|
|
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)`)
|
|
if err != nil {
|
|
return fmt.Errorf("create migrations table: %w", err)
|
|
}
|
|
|
|
for _, m := range migrations {
|
|
var count int
|
|
err := d.db.QueryRowContext(ctx,
|
|
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?", m.version).Scan(&count)
|
|
if err != nil {
|
|
return fmt.Errorf("check migration %d: %w", m.version, err)
|
|
}
|
|
if count > 0 {
|
|
continue // already applied
|
|
}
|
|
|
|
if err := d.WithTx(ctx, func(tx *sql.Tx) error {
|
|
if _, err := tx.ExecContext(ctx, m.up); err != nil {
|
|
return fmt.Errorf("apply migration %d: %w", m.version, err)
|
|
}
|
|
_, err := tx.ExecContext(ctx,
|
|
"INSERT INTO schema_migrations (version) VALUES (?)", m.version)
|
|
return err
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("[db] applied migration %d\n", m.version)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- Schema v1 (initial) ----
|
|
|
|
const schemav1 = `
|
|
-- Domains
|
|
CREATE TABLE IF NOT EXISTS domains (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
enabled BOOLEAN NOT NULL DEFAULT 1,
|
|
dkim_private_enc BLOB,
|
|
dkim_public TEXT,
|
|
dkim_selector TEXT NOT NULL DEFAULT 'mail',
|
|
dkim_algo TEXT NOT NULL DEFAULT 'rsa2048',
|
|
spf_policy TEXT,
|
|
dmarc_policy TEXT,
|
|
max_users INTEGER NOT NULL DEFAULT 0,
|
|
max_quota_bytes INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Users
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
username TEXT NOT NULL,
|
|
email TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
display_name TEXT NOT NULL DEFAULT '',
|
|
quota_bytes INTEGER NOT NULL DEFAULT 1073741824,
|
|
used_bytes INTEGER NOT NULL DEFAULT 0,
|
|
enabled BOOLEAN NOT NULL DEFAULT 1,
|
|
admin BOOLEAN NOT NULL DEFAULT 0,
|
|
domain_admin BOOLEAN NOT NULL DEFAULT 0,
|
|
mfa_secret_enc BLOB,
|
|
mfa_enabled BOOLEAN NOT NULL DEFAULT 0,
|
|
recovery_codes_enc BLOB,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
last_login TIMESTAMP
|
|
);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
CREATE INDEX IF NOT EXISTS idx_users_domain ON users(domain_id);
|
|
|
|
-- User aliases
|
|
CREATE TABLE IF NOT EXISTS user_aliases (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
alias_email TEXT NOT NULL UNIQUE
|
|
);
|
|
|
|
-- Mailboxes (IMAP folders)
|
|
CREATE TABLE IF NOT EXISTS mailboxes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
type TEXT NOT NULL DEFAULT 'custom',
|
|
parent_id INTEGER REFERENCES mailboxes(id) ON DELETE CASCADE,
|
|
uid_validity INTEGER NOT NULL DEFAULT 1,
|
|
uid_next INTEGER NOT NULL DEFAULT 1,
|
|
subscribed BOOLEAN NOT NULL DEFAULT 1,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, name)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_mailboxes_user ON mailboxes(user_id);
|
|
|
|
-- Messages
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
mailbox_id INTEGER NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
|
uid INTEGER NOT NULL,
|
|
message_id TEXT,
|
|
subject TEXT NOT NULL DEFAULT '',
|
|
from_email TEXT NOT NULL DEFAULT '',
|
|
from_name TEXT NOT NULL DEFAULT '',
|
|
to_list TEXT NOT NULL DEFAULT '',
|
|
cc_list TEXT NOT NULL DEFAULT '',
|
|
bcc_list TEXT NOT NULL DEFAULT '',
|
|
reply_to TEXT NOT NULL DEFAULT '',
|
|
date TIMESTAMP,
|
|
body_text_enc BLOB,
|
|
body_html_enc BLOB,
|
|
raw_enc BLOB,
|
|
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
has_attachment BOOLEAN NOT NULL DEFAULT 0,
|
|
is_read BOOLEAN NOT NULL DEFAULT 0,
|
|
is_starred BOOLEAN NOT NULL DEFAULT 0,
|
|
is_draft BOOLEAN NOT NULL DEFAULT 0,
|
|
flags TEXT NOT NULL DEFAULT '',
|
|
spam_score INTEGER NOT NULL DEFAULT 0,
|
|
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
deleted_at TIMESTAMP,
|
|
UNIQUE(mailbox_id, uid)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_mailbox ON messages(mailbox_id);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_uid ON messages(mailbox_id, uid);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_date ON messages(mailbox_id, date);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_deleted ON messages(mailbox_id, deleted_at);
|
|
|
|
-- Attachments
|
|
CREATE TABLE IF NOT EXISTS attachments (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
filename TEXT NOT NULL,
|
|
content_type TEXT NOT NULL,
|
|
size_bytes INTEGER NOT NULL DEFAULT 0,
|
|
data_enc BLOB,
|
|
data_path TEXT,
|
|
content_id TEXT,
|
|
inline BOOLEAN NOT NULL DEFAULT 0,
|
|
mime_path TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id);
|
|
|
|
-- Delivery queue
|
|
CREATE TABLE IF NOT EXISTS queue (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
domain_id INTEGER REFERENCES domains(id),
|
|
from_addr TEXT NOT NULL,
|
|
to_addr TEXT NOT NULL,
|
|
raw_enc BLOB NOT NULL,
|
|
message_id TEXT,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
last_attempt TIMESTAMP,
|
|
next_attempt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
error_log TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at TIMESTAMP NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_queue_status ON queue(status, next_attempt);
|
|
|
|
-- Delivery log
|
|
CREATE TABLE IF NOT EXISTS delivery_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
queue_id INTEGER REFERENCES queue(id) ON DELETE SET NULL,
|
|
from_addr TEXT NOT NULL,
|
|
to_addr TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
smtp_code INTEGER NOT NULL DEFAULT 0,
|
|
smtp_message TEXT NOT NULL DEFAULT '',
|
|
mx_host TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_delivery_log_created ON delivery_log(created_at);
|
|
|
|
-- Sessions
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
ip TEXT NOT NULL DEFAULT '',
|
|
user_agent TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at TIMESTAMP NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
|
|
|
|
-- IP bans
|
|
CREATE TABLE IF NOT EXISTS ip_bans (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ip TEXT NOT NULL UNIQUE,
|
|
reason TEXT NOT NULL DEFAULT '',
|
|
banned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at TIMESTAMP,
|
|
released_by TEXT NOT NULL DEFAULT ''
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_ip_bans_ip ON ip_bans(ip);
|
|
CREATE INDEX IF NOT EXISTS idx_ip_bans_expires ON ip_bans(expires_at);
|
|
|
|
-- Login attempts
|
|
CREATE TABLE IF NOT EXISTS login_attempts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ip TEXT NOT NULL,
|
|
user_email TEXT NOT NULL DEFAULT '',
|
|
success BOOLEAN NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip ON login_attempts(ip, created_at);
|
|
|
|
-- Security events
|
|
CREATE TABLE IF NOT EXISTS security_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
type TEXT NOT NULL,
|
|
ip TEXT NOT NULL DEFAULT '',
|
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
detail TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_security_events_created ON security_events(created_at);
|
|
|
|
-- External accounts (Gmail / Outlook / custom IMAP)
|
|
CREATE TABLE IF NOT EXISTS external_accounts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
provider TEXT NOT NULL,
|
|
email_address TEXT NOT NULL,
|
|
display_name TEXT NOT NULL DEFAULT '',
|
|
access_token_enc BLOB,
|
|
refresh_token_enc BLOB,
|
|
token_expiry TIMESTAMP,
|
|
imap_host TEXT NOT NULL DEFAULT '',
|
|
imap_port INTEGER NOT NULL DEFAULT 993,
|
|
smtp_host TEXT NOT NULL DEFAULT '',
|
|
smtp_port INTEGER NOT NULL DEFAULT 587,
|
|
enabled BOOLEAN NOT NULL DEFAULT 1,
|
|
sync_enabled BOOLEAN NOT NULL DEFAULT 1,
|
|
last_sync TIMESTAMP,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_ext_accounts_user ON external_accounts(user_id);
|
|
|
|
-- Address books (CardDAV)
|
|
CREATE TABLE IF NOT EXISTS address_books (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
color TEXT NOT NULL DEFAULT '#4A90E2',
|
|
sync_token INTEGER NOT NULL DEFAULT 1,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Contacts (CardDAV)
|
|
CREATE TABLE IF NOT EXISTS contacts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
address_book_id INTEGER NOT NULL REFERENCES address_books(id) ON DELETE CASCADE,
|
|
uid TEXT NOT NULL,
|
|
vcard_enc BLOB NOT NULL,
|
|
etag TEXT NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(address_book_id, uid)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_contacts_book ON contacts(address_book_id);
|
|
|
|
-- Calendars (CalDAV)
|
|
CREATE TABLE IF NOT EXISTS calendars (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
color TEXT NOT NULL DEFAULT '#4CAF50',
|
|
timezone TEXT NOT NULL DEFAULT 'UTC',
|
|
sync_token INTEGER NOT NULL DEFAULT 1,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Calendar events (CalDAV)
|
|
CREATE TABLE IF NOT EXISTS calendar_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
calendar_id INTEGER NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
|
|
uid TEXT NOT NULL,
|
|
ical_enc BLOB NOT NULL,
|
|
etag TEXT NOT NULL,
|
|
dt_start TIMESTAMP,
|
|
dt_end TIMESTAMP,
|
|
summary TEXT NOT NULL DEFAULT '',
|
|
recurring BOOLEAN NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(calendar_id, uid)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_events_calendar ON calendar_events(calendar_id);
|
|
CREATE INDEX IF NOT EXISTS idx_events_dtstart ON calendar_events(calendar_id, dt_start);
|
|
|
|
-- Spam Bayesian tokens (per-user)
|
|
CREATE TABLE IF NOT EXISTS spam_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token TEXT NOT NULL,
|
|
spam_count INTEGER NOT NULL DEFAULT 0,
|
|
ham_count INTEGER NOT NULL DEFAULT 0,
|
|
UNIQUE(user_id, token)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_spam_tokens_user ON spam_tokens(user_id, token);
|
|
`
|
|
|
|
// ---- Schema v2: DMARC monitoring ----
|
|
|
|
const schemav2 = `
|
|
-- DMARC monitoring address per domain (nullable; empty = disabled)
|
|
ALTER TABLE domains ADD COLUMN dmarc_rua TEXT;
|
|
|
|
-- DMARC aggregate reports (one row per received report)
|
|
CREATE TABLE IF NOT EXISTS dmarc_reports (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
org_name TEXT NOT NULL DEFAULT '',
|
|
org_email TEXT NOT NULL DEFAULT '',
|
|
report_id TEXT NOT NULL DEFAULT '',
|
|
date_begin INTEGER NOT NULL DEFAULT 0,
|
|
date_end INTEGER NOT NULL DEFAULT 0,
|
|
policy_domain TEXT NOT NULL DEFAULT '',
|
|
policy_adkim TEXT NOT NULL DEFAULT '',
|
|
policy_aspf TEXT NOT NULL DEFAULT '',
|
|
policy_p TEXT NOT NULL DEFAULT '',
|
|
policy_pct INTEGER NOT NULL DEFAULT 100,
|
|
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_dmarc_reports_domain ON dmarc_reports(domain_id, received_at);
|
|
|
|
-- DMARC report IP-level records (one row per source IP per report)
|
|
CREATE TABLE IF NOT EXISTS dmarc_records (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
report_id INTEGER NOT NULL REFERENCES dmarc_reports(id) ON DELETE CASCADE,
|
|
source_ip TEXT NOT NULL DEFAULT '',
|
|
count INTEGER NOT NULL DEFAULT 0,
|
|
disposition TEXT NOT NULL DEFAULT '',
|
|
dkim_result TEXT NOT NULL DEFAULT '',
|
|
spf_result TEXT NOT NULL DEFAULT '',
|
|
header_from TEXT NOT NULL DEFAULT '',
|
|
envelope_from TEXT NOT NULL DEFAULT '',
|
|
dkim_domain TEXT NOT NULL DEFAULT '',
|
|
dkim_selector TEXT NOT NULL DEFAULT '',
|
|
spf_domain TEXT NOT NULL DEFAULT ''
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_dmarc_records_report ON dmarc_records(report_id);
|
|
`
|