first commit
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"gomail/internal/crypto"
|
||||
"gomail/internal/dkim"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Bootstrap creates an initial tenant, domain, and global admin user on first
|
||||
// run — detected by the absence of any global_admin row. Safe to call on
|
||||
// every startup; it's a no-op once bootstrapped.
|
||||
func (db *DB) Bootstrap(hostname, initPassword string, bcryptCost int, mk *crypto.MasterKey) error {
|
||||
var count int
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'global_admin'").Scan(&count)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking existing admins: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return nil // already bootstrapped
|
||||
}
|
||||
|
||||
if initPassword == "" {
|
||||
initPassword = "ChangeMe123!"
|
||||
slog.Warn("no admin exists and GOMAIL_ADMIN_INIT_PASSWORD not set — using default, CHANGE IMMEDIATELY",
|
||||
"password", initPassword, "email", "admin@"+hostname)
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
tenantID := uuid.NewString()
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO tenants (id, name, display_name) VALUES (?, ?, ?)`,
|
||||
tenantID, "default", "Default Tenant",
|
||||
); err != nil {
|
||||
return fmt.Errorf("creating default tenant: %w", err)
|
||||
}
|
||||
|
||||
domainID := uuid.NewString()
|
||||
dkimSelector := "mail"
|
||||
|
||||
// Generate a DKIM key pair so outbound mail can be signed immediately —
|
||||
// without this, every message this instance sends would be unsigned
|
||||
// until an admin manually configures one (Phase 8's admin portal will
|
||||
// add key rotation/regeneration; this just ensures a working default).
|
||||
var dkimKeyEnc []byte
|
||||
kp, kpErr := dkim.GenerateKeyPair()
|
||||
if kpErr != nil {
|
||||
slog.Warn("failed to generate DKIM key during bootstrap — outbound mail will be unsigned until one is configured", "err", kpErr)
|
||||
} else {
|
||||
encrypted, encErr := crypto.Encrypt(mk, domainID, "dkim-key", kp.PrivateKeyPEM)
|
||||
if encErr != nil {
|
||||
slog.Warn("failed to encrypt DKIM key during bootstrap", "err", encErr)
|
||||
} else {
|
||||
dkimKeyEnc = encrypted
|
||||
slog.Info("DKIM key generated for default domain — publish this DNS TXT record",
|
||||
"record_name", dkimSelector+"._domainkey."+hostname,
|
||||
"record_value", kp.DNSRecordValue)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO domains (id, tenant_id, domain, active, accept_all, dkim_selector, dkim_private_key_enc) VALUES (?, ?, ?, 1, 1, ?, ?)`,
|
||||
domainID, tenantID, hostname, dkimSelector, dkimKeyEnc,
|
||||
); err != nil {
|
||||
return fmt.Errorf("creating default domain: %w", err)
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(initPassword), bcryptCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hashing admin password: %w", err)
|
||||
}
|
||||
|
||||
adminEmail := "admin@" + hostname
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO users (id, tenant_id, domain_id, email, password_hash, display_name, role, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'global_admin', 1)`,
|
||||
uuid.NewString(), tenantID, domainID, adminEmail, string(hash), "Global Admin",
|
||||
); err != nil {
|
||||
return fmt.Errorf("creating admin user: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("committing bootstrap: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("bootstrap complete", "admin_email", adminEmail, "tenant", "default", "domain", hostname)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Package db wraps database/sql with the gomail schema. No ORM — raw SQL with
|
||||
// prepared statements only, per the project's minimal-dependency principle.
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// DB wraps *sql.DB with the driver name (some queries need driver-specific SQL,
|
||||
// e.g. placeholder syntax differs between sqlite/postgres/mysql).
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
Driver string
|
||||
}
|
||||
|
||||
// Open connects to the database using the configured driver.
|
||||
// SQLite is always available; postgres and mysql require build tags:
|
||||
//
|
||||
// go build -tags postgres .
|
||||
// go build -tags mysql .
|
||||
func Open(driver, dsn string) (*DB, error) {
|
||||
d := strings.ToLower(driver)
|
||||
|
||||
var sqlDriverName string
|
||||
switch d {
|
||||
case "sqlite", "":
|
||||
sqlDriverName = "sqlite3"
|
||||
d = "sqlite"
|
||||
default:
|
||||
name, ok := driverRegistry[d]
|
||||
if !ok {
|
||||
available := []string{"sqlite"}
|
||||
for k := range driverRegistry {
|
||||
available = append(available, k)
|
||||
}
|
||||
return nil, fmt.Errorf("driver %q not compiled in; rebuild with -tags %s. Available: %v", driver, driver, available)
|
||||
}
|
||||
sqlDriverName = name
|
||||
}
|
||||
|
||||
sqlDB, err := sql.Open(sqlDriverName, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening database: %w", err)
|
||||
}
|
||||
|
||||
if d == "sqlite" {
|
||||
// SQLite doesn't handle concurrent writers well — serialize via single conn.
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
return nil, fmt.Errorf("enabling WAL mode: %w", err)
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||
return nil, fmt.Errorf("enabling foreign keys: %w", err)
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA busy_timeout=5000"); err != nil {
|
||||
return nil, fmt.Errorf("setting busy timeout: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
return &DB{DB: sqlDB, Driver: d}, nil
|
||||
}
|
||||
|
||||
// driverRegistry is populated by build-tag-gated driver_*.go files
|
||||
// (driver_postgres.go, driver_mysql.go) via init().
|
||||
var driverRegistry = map[string]string{}
|
||||
|
||||
func registerDriver(name, sqlDriverName string) {
|
||||
driverRegistry[strings.ToLower(name)] = sqlDriverName
|
||||
}
|
||||
|
||||
// Migrate runs all pending schema migrations in order. Migrations are
|
||||
// idempotent (CREATE TABLE IF NOT EXISTS) so this is always safe to call at
|
||||
// startup.
|
||||
func (db *DB) Migrate() error {
|
||||
slog.Info("running database migrations")
|
||||
|
||||
if _, err := db.Exec(migrationsTableSQL[db.Driver]); err != nil {
|
||||
return fmt.Errorf("creating migrations table: %w", err)
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
applied, err := db.migrationApplied(m.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking migration %s: %w", m.name, err)
|
||||
}
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
|
||||
stmt := m.sql[db.Driver]
|
||||
if stmt == "" {
|
||||
stmt = m.sql["sqlite"] // fall back — most DDL is portable enough via driver quirks handled per-migration
|
||||
}
|
||||
|
||||
slog.Info("applying migration", "name", m.name)
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx for %s: %w", m.name, err)
|
||||
}
|
||||
if _, err := tx.Exec(stmt); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("applying migration %s: %w", m.name, err)
|
||||
}
|
||||
if _, err := tx.Exec(db.insertMigrationSQL(), m.name); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("recording migration %s: %w", m.name, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", m.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("migrations complete", "count", len(migrations))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) migrationApplied(name string) (bool, error) {
|
||||
var count int
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE name = "+db.placeholder(1), name).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (db *DB) insertMigrationSQL() string {
|
||||
return "INSERT INTO schema_migrations (name, applied_at) VALUES (" + db.placeholder(1) + ", CURRENT_TIMESTAMP)"
|
||||
}
|
||||
|
||||
// placeholder returns the driver-appropriate positional parameter syntax.
|
||||
// sqlite/mysql use "?", postgres uses "$1", "$2", ...
|
||||
func (db *DB) placeholder(n int) string {
|
||||
if db.Driver == "postgres" {
|
||||
return fmt.Sprintf("$%d", n)
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
var migrationsTableSQL = map[string]string{
|
||||
"sqlite": `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL
|
||||
)`,
|
||||
"postgres": `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL
|
||||
)`,
|
||||
"mysql": `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name VARCHAR(255) PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL
|
||||
)`,
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package db
|
||||
|
||||
// migration is a single named schema change with driver-specific SQL variants.
|
||||
// SQLite is the reference dialect (required); postgres/mysql variants are
|
||||
// filled in as those build-tagged drivers are added — until then the sqlite
|
||||
// SQL is close enough to run in most cases (TEXT/BLOB/DATETIME map cleanly).
|
||||
type migration struct {
|
||||
name string
|
||||
sql map[string]string
|
||||
}
|
||||
|
||||
var migrations = []migration{
|
||||
{
|
||||
name: "0001_tenants_domains",
|
||||
sql: map[string]string{
|
||||
"sqlite": `
|
||||
CREATE TABLE tenants (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT,
|
||||
digest_interval_mins INTEGER NOT NULL DEFAULT 60,
|
||||
max_accounts INTEGER NOT NULL DEFAULT 0,
|
||||
quota_mb_per_user INTEGER NOT NULL DEFAULT 2048,
|
||||
settings_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE domains (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
domain TEXT UNIQUE NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
dkim_selector TEXT,
|
||||
dkim_private_key_enc BLOB,
|
||||
accept_all INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_domains_tenant ON domains(tenant_id);
|
||||
`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "0002_users_auth",
|
||||
sql: map[string]string{
|
||||
"sqlite": `
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
mfa_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
totp_secret_enc BLOB,
|
||||
passkey_credentials_json TEXT NOT NULL DEFAULT '[]',
|
||||
quota_mb INTEGER NOT NULL DEFAULT 2048,
|
||||
used_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
digest_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
digest_interval_mins INTEGER NOT NULL DEFAULT 0,
|
||||
last_digest_at DATETIME,
|
||||
last_login_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_users_tenant ON users(tenant_id);
|
||||
CREATE INDEX idx_users_domain ON users(domain_id);
|
||||
|
||||
CREATE TABLE app_passwords (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL DEFAULT 'smtp,imap',
|
||||
last_used_at DATETIME,
|
||||
expires_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_app_passwords_user ON app_passwords(user_id);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
jti TEXT UNIQUE NOT NULL,
|
||||
user_agent TEXT,
|
||||
ip TEXT,
|
||||
expires_at DATETIME NOT NULL,
|
||||
revoked_at DATETIME
|
||||
);
|
||||
CREATE INDEX idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX idx_sessions_jti ON sessions(jti);
|
||||
|
||||
CREATE TABLE aliases (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
from_address TEXT UNIQUE NOT NULL,
|
||||
to_user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
to_external TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX idx_aliases_tenant ON aliases(tenant_id);
|
||||
`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "0003_list_rules",
|
||||
sql: map[string]string{
|
||||
"sqlite": `
|
||||
CREATE TABLE list_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
list_type TEXT NOT NULL,
|
||||
match_type TEXT NOT NULL DEFAULT 'email',
|
||||
value TEXT NOT NULL,
|
||||
note TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_list_rules_tenant ON list_rules(tenant_id);
|
||||
CREATE INDEX idx_list_rules_value ON list_rules(value);
|
||||
`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "0004_messages_mailbox",
|
||||
sql: map[string]string{
|
||||
"sqlite": `
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
from_address TEXT NOT NULL,
|
||||
to_address TEXT NOT NULL,
|
||||
subject TEXT,
|
||||
message_id_hdr TEXT,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
verdict TEXT NOT NULL DEFAULT 'clean',
|
||||
total_score REAL NOT NULL DEFAULT 0,
|
||||
sender_ip TEXT,
|
||||
relayed_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_messages_tenant ON messages(tenant_id);
|
||||
CREATE INDEX idx_messages_to ON messages(to_address);
|
||||
CREATE INDEX idx_messages_created ON messages(created_at);
|
||||
|
||||
CREATE TABLE message_checks (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
stage TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
score REAL NOT NULL DEFAULT 0,
|
||||
detail TEXT,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX idx_message_checks_message ON message_checks(message_id);
|
||||
|
||||
CREATE TABLE mailbox_index (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mailbox TEXT NOT NULL DEFAULT 'INBOX',
|
||||
uid INTEGER NOT NULL,
|
||||
eml_path TEXT NOT NULL,
|
||||
flags TEXT NOT NULL DEFAULT '',
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
internal_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, mailbox, uid)
|
||||
);
|
||||
CREATE INDEX idx_mailbox_index_user ON mailbox_index(user_id, mailbox);
|
||||
|
||||
CREATE TABLE mailbox_uid_counters (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mailbox TEXT NOT NULL,
|
||||
next_uid INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (user_id, mailbox)
|
||||
);
|
||||
`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "0005_outbound_queue",
|
||||
sql: map[string]string{
|
||||
"sqlite": `
|
||||
CREATE TABLE outbound_queue (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
from_address TEXT NOT NULL,
|
||||
to_address TEXT NOT NULL,
|
||||
eml_path TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
next_attempt_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_outbound_queue_next ON outbound_queue(next_attempt_at);
|
||||
CREATE INDEX idx_outbound_queue_user ON outbound_queue(user_id);
|
||||
`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "0006_quarantine",
|
||||
sql: map[string]string{
|
||||
"sqlite": `
|
||||
CREATE TABLE quarantine (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
eml_path TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'held',
|
||||
reason TEXT,
|
||||
released_by TEXT,
|
||||
released_at DATETIME,
|
||||
expires_at DATETIME NOT NULL,
|
||||
notified_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_quarantine_status ON quarantine(status);
|
||||
CREATE INDEX idx_quarantine_message ON quarantine(message_id);
|
||||
|
||||
CREATE TABLE release_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
quarantine_id TEXT NOT NULL REFERENCES quarantine(id) ON DELETE CASCADE,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
email TEXT,
|
||||
used_at DATETIME,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_release_tokens_token ON release_tokens(token);
|
||||
`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "0007_linked_accounts",
|
||||
sql: map[string]string{
|
||||
"sqlite": `
|
||||
CREATE TABLE linked_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
email_address TEXT NOT NULL,
|
||||
auth_type TEXT NOT NULL,
|
||||
imap_host TEXT,
|
||||
imap_port INTEGER,
|
||||
imap_tls TEXT,
|
||||
smtp_host TEXT,
|
||||
smtp_port INTEGER,
|
||||
smtp_tls TEXT,
|
||||
credential_enc BLOB,
|
||||
oauth_expires_at DATETIME,
|
||||
sync_state TEXT,
|
||||
cache_retention_days INTEGER,
|
||||
last_sync_at DATETIME,
|
||||
last_sync_error TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_linked_accounts_user ON linked_accounts(user_id);
|
||||
`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "0008_dav_contacts_calendars",
|
||||
sql: map[string]string{
|
||||
"sqlite": `
|
||||
CREATE TABLE addressbooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_type TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
description TEXT,
|
||||
sync_token TEXT NOT NULL DEFAULT '1',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_addressbooks_owner ON addressbooks(owner_type, owner_id);
|
||||
|
||||
CREATE TABLE contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
addressbook_id TEXT NOT NULL REFERENCES addressbooks(id) ON DELETE CASCADE,
|
||||
uid TEXT NOT NULL,
|
||||
vcard_enc BLOB NOT NULL,
|
||||
etag TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(addressbook_id, uid)
|
||||
);
|
||||
CREATE INDEX idx_contacts_addressbook ON contacts(addressbook_id);
|
||||
|
||||
CREATE TABLE calendars (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_type TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
description TEXT,
|
||||
color TEXT,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
sync_token TEXT NOT NULL DEFAULT '1',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_calendars_owner ON calendars(owner_type, owner_id);
|
||||
|
||||
CREATE TABLE calendar_objects (
|
||||
id TEXT PRIMARY KEY,
|
||||
calendar_id TEXT NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
|
||||
uid TEXT NOT NULL,
|
||||
ical_enc BLOB NOT NULL,
|
||||
component_type TEXT,
|
||||
summary TEXT,
|
||||
dtstart DATETIME,
|
||||
dtend DATETIME,
|
||||
etag TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(calendar_id, uid)
|
||||
);
|
||||
CREATE INDEX idx_calendar_objects_calendar ON calendar_objects(calendar_id);
|
||||
`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "0009_sieve_scripts",
|
||||
sql: map[string]string{
|
||||
"sqlite": `
|
||||
CREATE TABLE sieve_scripts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
script_text TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
CREATE INDEX idx_sieve_scripts_user ON sieve_scripts(user_id);
|
||||
`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "0010_tls_certs",
|
||||
sql: map[string]string{
|
||||
"sqlite": `
|
||||
CREATE TABLE tls_certs (
|
||||
id TEXT PRIMARY KEY,
|
||||
domain TEXT UNIQUE NOT NULL,
|
||||
cert_pem_enc BLOB,
|
||||
key_pem_enc BLOB,
|
||||
expires_at DATETIME,
|
||||
acme_account_key_enc BLOB,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_tls_certs_domain ON tls_certs(domain);
|
||||
`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "0011_mfa_and_recovery",
|
||||
sql: map[string]string{
|
||||
"sqlite": `
|
||||
CREATE TABLE mfa_backup_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
used_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_mfa_backup_codes_user ON mfa_backup_codes(user_id);
|
||||
|
||||
ALTER TABLE users ADD COLUMN recovery_email TEXT;
|
||||
`,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
// ── Tenants & domains ──────────────────────────────────────────────────────────
|
||||
|
||||
type Tenant struct {
|
||||
ID string
|
||||
Name string
|
||||
DisplayName string
|
||||
DigestIntervalMins int
|
||||
MaxAccounts int // 0 = unlimited
|
||||
QuotaMBPerUser int
|
||||
SettingsJSON string // pipeline thresholds, check toggles (parsed by pipeline pkg)
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Domain struct {
|
||||
ID string
|
||||
TenantID string
|
||||
Domain string
|
||||
Active bool
|
||||
DKIMSelector string
|
||||
DKIMPrivateKeyEnc []byte // AES-256-GCM encrypted PEM
|
||||
AcceptAll bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ── Users & auth ──────────────────────────────────────────────────────────────
|
||||
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleUser UserRole = "user"
|
||||
RoleTenantAdmin UserRole = "tenant_admin"
|
||||
RoleGlobalAdmin UserRole = "global_admin"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
TenantID string
|
||||
DomainID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
DisplayName string
|
||||
Role UserRole
|
||||
Active bool
|
||||
MFAEnabled bool
|
||||
TOTPSecretEnc []byte // AES-256-GCM encrypted
|
||||
PasskeyCredentialsJSON string // JSON array of WebAuthn credentials
|
||||
RecoveryEmail string // external address for password-reset delivery (see Phase 12 notes)
|
||||
QuotaMB int
|
||||
UsedBytes int64
|
||||
DigestEnabled bool
|
||||
DigestIntervalMins int // 0 = use tenant default
|
||||
LastDigestAt *time.Time
|
||||
LastLoginAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type AppPassword struct {
|
||||
ID string
|
||||
UserID string
|
||||
Label string
|
||||
PasswordHash string // bcrypt of a 32-char random token
|
||||
Scopes string // comma-separated: smtp,imap,caldav,carddav,pop3
|
||||
LastUsedAt *time.Time
|
||||
ExpiresAt *time.Time // nil = never expires
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ID string
|
||||
UserID string
|
||||
JTI string // JWT ID, for revocation lookups
|
||||
UserAgent string
|
||||
IP string
|
||||
ExpiresAt time.Time
|
||||
RevokedAt *time.Time
|
||||
}
|
||||
|
||||
type Alias struct {
|
||||
ID string
|
||||
TenantID string
|
||||
FromAddress string
|
||||
ToUserID *string // nil if forwarding externally
|
||||
ToExternal *string // nil if local
|
||||
Active bool
|
||||
}
|
||||
|
||||
// ── List rules (allow/block, per tenant) ───────────────────────────────────────
|
||||
|
||||
type ListRuleAction string
|
||||
|
||||
const (
|
||||
ListActionAllow ListRuleAction = "allow"
|
||||
ListActionBlock ListRuleAction = "block"
|
||||
)
|
||||
|
||||
type ListRule struct {
|
||||
ID string
|
||||
TenantID string
|
||||
ListType ListRuleAction // allow | block
|
||||
MatchType string // email | domain
|
||||
Value string
|
||||
Note string
|
||||
Active bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ── Messages (audit log) & mailbox index ────────────────────────────────────────
|
||||
|
||||
type MessageVerdict string
|
||||
|
||||
const (
|
||||
VerdictClean MessageVerdict = "clean"
|
||||
VerdictFlagged MessageVerdict = "flagged"
|
||||
VerdictQuarantine MessageVerdict = "quarantine"
|
||||
VerdictBlocked MessageVerdict = "blocked"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
ID string
|
||||
TenantID string
|
||||
FromAddress string
|
||||
ToAddress string
|
||||
Subject string
|
||||
MessageIDHdr string
|
||||
SizeBytes int64
|
||||
Verdict MessageVerdict
|
||||
TotalScore float64
|
||||
SenderIP string
|
||||
RelayedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type MailboxEntry struct {
|
||||
ID string
|
||||
UserID string
|
||||
Mailbox string // INBOX, Sent, Trash, Junk, custom...
|
||||
UID int
|
||||
EMLPath string // path to encrypted .eml.enc on disk
|
||||
Flags string // \Seen \Flagged \Answered \Deleted \Draft
|
||||
SizeBytes int64
|
||||
ReceivedAt time.Time
|
||||
InternalDate time.Time
|
||||
}
|
||||
|
||||
// ── Outbound queue ───────────────────────────────────────────────────────────
|
||||
|
||||
type OutboundQueueEntry struct {
|
||||
ID string
|
||||
UserID string
|
||||
FromAddress string
|
||||
ToAddress string
|
||||
EMLPath string
|
||||
Priority int
|
||||
Attempts int
|
||||
LastError string
|
||||
NextAttemptAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ── Pipeline check results ──────────────────────────────────────────────────────
|
||||
|
||||
// CheckResult is the outcome of a single pipeline stage (SPF, DKIM, etc.).
|
||||
type CheckResult string
|
||||
|
||||
const (
|
||||
CheckPass CheckResult = "pass"
|
||||
CheckWarn CheckResult = "warn"
|
||||
CheckFail CheckResult = "fail"
|
||||
CheckSkipped CheckResult = "skipped"
|
||||
CheckError CheckResult = "error"
|
||||
)
|
||||
|
||||
type MessageCheck struct {
|
||||
ID string
|
||||
MessageID string
|
||||
Stage string
|
||||
Result CheckResult
|
||||
Score float64
|
||||
Detail string
|
||||
DurationMs int64
|
||||
}
|
||||
|
||||
// ── Quarantine ────────────────────────────────────────────────────────────────
|
||||
|
||||
type QuarantineStatus string
|
||||
|
||||
const (
|
||||
QuarantineHeld QuarantineStatus = "held"
|
||||
QuarantineReleased QuarantineStatus = "released"
|
||||
QuarantineDeleted QuarantineStatus = "deleted"
|
||||
)
|
||||
|
||||
type QuarantineEntry struct {
|
||||
ID string
|
||||
MessageID string
|
||||
EMLPath string
|
||||
Status QuarantineStatus
|
||||
Reason string
|
||||
ReleasedBy string
|
||||
ReleasedAt *time.Time
|
||||
ExpiresAt time.Time
|
||||
NotifiedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ReleaseToken struct {
|
||||
ID string
|
||||
QuarantineID string
|
||||
Token string
|
||||
Email string
|
||||
UsedAt *time.Time
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ── Linked accounts (multi-account webmail — Part B of the plan) ──────────────
|
||||
|
||||
type LinkedAccountProvider string
|
||||
|
||||
const (
|
||||
ProviderGoMail LinkedAccountProvider = "gomail"
|
||||
ProviderIMAP LinkedAccountProvider = "imap"
|
||||
ProviderGmail LinkedAccountProvider = "gmail" // Phase 10
|
||||
ProviderM365 LinkedAccountProvider = "m365" // Phase 10
|
||||
)
|
||||
|
||||
type LinkedAccountAuthType string
|
||||
|
||||
const (
|
||||
AuthTypeSession LinkedAccountAuthType = "session" // gomail local account, already logged in
|
||||
AuthTypePassword LinkedAccountAuthType = "password" // generic IMAP/SMTP
|
||||
AuthTypeOAuth2 LinkedAccountAuthType = "oauth2" // Phase 10
|
||||
)
|
||||
|
||||
type LinkedAccount struct {
|
||||
ID string
|
||||
UserID string
|
||||
Provider LinkedAccountProvider
|
||||
DisplayName string
|
||||
EmailAddress string
|
||||
AuthType LinkedAccountAuthType
|
||||
IMAPHost string
|
||||
IMAPPort int
|
||||
IMAPTLS string // "starttls" | "implicit" | "off"
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
SMTPTLS string
|
||||
CredentialEnc []byte // encrypted password or OAuth2 tokens (JSON)
|
||||
OAuthExpiresAt *time.Time
|
||||
SyncState string
|
||||
CacheRetentionDays int // 0 = use instance default
|
||||
LastSyncAt *time.Time
|
||||
LastSyncError string
|
||||
Active bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ── CalDAV / CardDAV ────────────────────────────────────────────────────────────
|
||||
|
||||
// OwnerType distinguishes a personal (per-user) collection from a shared
|
||||
// tenant-wide one — both addressbooks and calendars support both scopes per
|
||||
// the plan (tenant addressbook + per-user addressbook, same for calendars).
|
||||
type OwnerType string
|
||||
|
||||
const (
|
||||
OwnerUser OwnerType = "user"
|
||||
OwnerTenant OwnerType = "tenant"
|
||||
)
|
||||
|
||||
type Addressbook struct {
|
||||
ID string
|
||||
OwnerType OwnerType
|
||||
OwnerID string
|
||||
DisplayName string
|
||||
Description string
|
||||
SyncToken string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Contact struct {
|
||||
ID string
|
||||
AddressbookID string
|
||||
UID string
|
||||
VCardEnc []byte // AES-256-GCM encrypted vCard text
|
||||
ETag string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Calendar struct {
|
||||
ID string
|
||||
OwnerType OwnerType
|
||||
OwnerID string
|
||||
DisplayName string
|
||||
Description string
|
||||
Color string
|
||||
Timezone string
|
||||
SyncToken string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type CalendarObject struct {
|
||||
ID string
|
||||
CalendarID string
|
||||
UID string
|
||||
ICalEnc []byte // AES-256-GCM encrypted iCal text
|
||||
ComponentType string // VEVENT | VTODO | VJOURNAL
|
||||
Summary string
|
||||
DTStart *time.Time
|
||||
DTEnd *time.Time
|
||||
ETag string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ── ManageSieve ───────────────────────────────────────────────────────────────
|
||||
|
||||
type SieveScript struct {
|
||||
ID string
|
||||
UserID string
|
||||
Name string
|
||||
ScriptText string
|
||||
Active bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ── TLS certs (ACME) ────────────────────────────────────────────────────────────
|
||||
|
||||
type TLSCert struct {
|
||||
ID string
|
||||
Domain string
|
||||
CertPEMEnc []byte
|
||||
KeyPEMEnc []byte
|
||||
ExpiresAt *time.Time
|
||||
ACMEAccountKeyEnc []byte
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ── MFA ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
type MFABackupCode struct {
|
||||
ID string
|
||||
UserID string
|
||||
CodeHash string
|
||||
UsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user