Files
mailgosend/internal/db/migrate.go
T
2026-05-24 17:15:48 +00:00

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);
`