first commit

This commit is contained in:
2026-08-09 18:03:09 +01:00
commit d7ca591b76
169 changed files with 51272 additions and 0 deletions
+96
View File
@@ -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
}
+158
View File
@@ -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
)`,
}
+374
View File
@@ -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;
`,
},
},
}
+353
View File
@@ -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