Database Schema¶
Mailyte uses MySQL 8.0 with InnoDB and utf8mb4 / utf8mb4_unicode_ci. The schema is owned entirely by Alembic (alembic/versions/, currently 0001_baseline → 0018_mail_logs_sasl_username) and applied by the migrate compose service on every docker compose up — the frozen SQL under database/migrations/sql/ is only the historical source the baseline was generated from (and the Community Edition's first-boot path).
Primary keys are CHAR(26) ULIDs for most application tables; a minority use INT/BIGINT AUTO_INCREMENT (noted below). No migration has ever dropped or renamed a table — all 18 revisions are additive. The current head holds 83 tables plus one view (audit_log, a compatibility view over audit_logs).
Entity Relationship Diagram¶
Real foreign-key constraints only (most cross-table references are by convention + index, without FK constraints):
erDiagram
organizations ||--o{ domains : "fk_domains_org"
organizations ||--o{ email_accounts : "fk_accounts_org"
organizations ||--o{ smtp_credentials : "fk"
organizations ||--o{ idempotency_keys : "fk_idem_org"
organizations ||--o{ users : "fk_users_org"
organizations ||--o{ alert_rules : "fk_alert_rules_org"
domains ||--o{ email_accounts : "fk_accounts_domain"
domains ||--o{ aliases : "fk_aliases_domain"
domains ||--o{ dkim_keys : "fk_dkim_domain"
domains ||--o{ smtp_credentials : "fk"
domains ||--|| domain_policy : "fk_domain_policy_domain"
email_accounts ||--o{ mailbox_sessions : "fk"
email_accounts ||--|| mailbox_preferences : "fk"
users ||--o{ web_sessions : "fk_web_sessions_user"
platform_operators ||--o{ operator_sessions : "fk"
alert_rules ||--o{ alert_events : "fk_alert_events_rule" Core Tenancy Tables¶
organizations¶
Top-level tenant container. PK id CHAR(26).
| Column | Type | Description |
|---|---|---|
id | CHAR(26) PK | ULID |
parent_organization_id | VARCHAR(100) | Reseller hierarchy (null = top level) |
external_id | VARCHAR(255) UNIQUE | Mapping to an external/billing system |
name / description | VARCHAR(255) / TEXT | |
active | BOOL, default 1 | |
admin_email / admin_name | VARCHAR(255) | |
settings | JSON | Includes spam_policy consumed by the Rspamd settings sync |
rate_limits / storage_quotas | JSON | Org-level overrides |
webhook_urls / webhook_secret | JSON / VARCHAR(255) | Per-org webhook endpoints |
whitelabel_domain | VARCHAR(255) | White-label console domain |
quota_override / quota_override_at / quota_override_by | BOOL / DATETIME / VARCHAR(255) | Added in migration 0009; quota_override_by stores the operator's email |
created_at / updated_at | DATETIME |
domains¶
| Column | Type | Description |
|---|---|---|
id | CHAR(26) PK | ULID |
domain | VARCHAR(255) UNIQUE | e.g. example.com |
external_id | VARCHAR(255) UNIQUE | |
organization_id | CHAR(26) FK → organizations | ON DELETE CASCADE |
active | BOOL | Postfix accepts mail only for active = 1 |
max_quota | BIGINT, default 10737418240 | Per-mailbox cap, bytes (10 GB) |
max_users | INT, default 1000 | |
dkim_enabled / dkim_selector | BOOL / VARCHAR(100), default default | |
rate_limits / storage_quotas | JSON | |
total_storage_used, total_attachment_storage, total_email_storage | BIGINT | Maintained by storage_usage |
total_email_accounts, total_emails, total_attachments | INT / BIGINT | Counters |
rate_usage_data | JSON | |
last_storage_calculation / storage_calculation_time_ms | DATETIME / INT | |
created_at / updated_at | DATETIME |
email_accounts¶
Individual mailboxes — the table Dovecot authenticates against (status = 'active', bcrypt in password).
| Column | Type | Description |
|---|---|---|
id | CHAR(26) PK | ULID |
email | VARCHAR(255) UNIQUE | Full address |
external_id | VARCHAR(255) UNIQUE | |
local_part | VARCHAR(255) | Part before @ |
domain_id / organization_id | CHAR(26) FKs | ON DELETE CASCADE |
password | VARCHAR(255) | bcrypt (BLF-CRYPT) |
name | VARCHAR(255) | Display name |
status | ENUM active,inactive,suspended | |
mailbox_type | ENUM personal,shared | Shared mailboxes are rows here, membership in shared_mailbox_members |
storage_quota | BIGINT, default 1073741824 | Bytes (1 GB); flows into Dovecot's quota rule |
storage_used, attachment_storage_used, email_storage_used | BIGINT | |
total_files, total_attachments, total_emails | INT | |
rate_usage_data, rate_limits, storage_quotas | JSON | |
forward_enabled / forward_destination | BOOL / VARCHAR(255) | |
vacation_enabled / vacation_message | BOOL / TEXT | |
last_login / last_activity | DATETIME | |
created_at / updated_at | DATETIME |
aliases¶
| Column | Type | Description |
|---|---|---|
id | CHAR(26) PK | |
domain_id | CHAR(26) FK → domains | ON DELETE CASCADE |
organization_id | CHAR(26) | indexed, no FK |
source | VARCHAR(255) | e.g. info@example.com |
destination | TEXT | Comma-separated destinations |
active | BOOL |
SMTP Credentials (migrations 0008 / 0017 / 0018 — live since 2026-08-27)¶
smtp_credentials¶
Domain-scoped SMTP API keys, enforced by Dovecot's SMTP-only passdb.
| Column | Type | Added | Description |
|---|---|---|---|
id | CHAR(26) PK | 0008 | |
organization_id | CHAR(26) FK → organizations | 0008 | |
domain_id | CHAR(26) FK → domains | 0008 | Key may send as any address in this domain |
username | VARCHAR(255) UNIQUE | 0008 | Shape {domain-slug}-smtp-{8 hex} |
password | VARCHAR(255) | 0008 | bcrypt; the secret is shown exactly once at create/rotate |
allowed_ips | JSON | 0008 | CIDR list, emitted as Dovecot allow_nets |
active | BOOL, default 1 | 0008 | Revoked keys are active = 0 |
name / prefix / created_by | VARCHAR | 0017 | prefix = 8-char display prefix of the secret |
ip_allowlist_enabled | BOOL, default 0 | 0017 | Must have ≥1 entry when enabled |
expires_at | DATETIME NULL | 0017 | Enforced inside the Dovecot passdb query |
hourly_limit / daily_limit | INT NULL | 0017 | NULL = inherit org limits |
last_used_at | DATETIME NULL | 0017 | Written only by the log ingestor (throttled) |
created_at / updated_at | DATETIME | 0008 |
smtp_credential_events¶
Append-only audit trail — deliberately FK-free so rows survive credential and organization deletion.
| Column | Type | Description |
|---|---|---|
id | CHAR(26) PK | |
credential_id / organization_id | CHAR(26), indexed | Plain ids, no FK |
username | VARCHAR(255) | Denormalized for post-deletion display |
event | VARCHAR(32) | created, updated, rotated, revoked, enabled, deleted, suspended |
actor | VARCHAR(255) | operator:<id>, platform-api-key, api-key:<org>, log_ingestor:auto-suspend |
source_ip | VARCHAR(45) | |
detail | JSON | e.g. changed fields, auto-suspend stats |
created_at | DATETIME |
Mail Flow Tables¶
mail_logs (written by the log ingestor since 2026-08-22)¶
| Column | Type | Description |
|---|---|---|
id | CHAR(26) PK | Deterministic hash of (queue_id, recipient, status, ts) — dedupes re-reads |
timestamp | DATETIME | Log-line time |
sender / recipient | VARCHAR(255) | |
organization_id | VARCHAR(100), indexed | Resolved from the recipient/sender domain |
subject | TEXT | RFC-2047-decoded, truncated to 1000 chars |
status | ENUM queued,sending,sent,delivered,bounced,rejected,deferred | Ingestor writes only delivered/bounced/deferred/rejected |
message_id | VARCHAR(255) | RFC Message-ID |
size | INT | Bytes |
relay | VARCHAR(255) | Relay host used |
delays | VARCHAR(100) | Postfix delays= breakdown |
dsn | VARCHAR(10) | DSN status code |
bounce_reason | TEXT | Remote server response for bounced/deferred/rejected |
sasl_username | VARCHAR(255) | Authenticated sender — mailbox or SMTP credential (migration 0018); indexed with timestamp |
email_bodies (migration 0012)¶
Short-lived capture of outbound bodies, written by the tracking injector, joined into delivery webhooks, pruned by expires_at.
id VARCHAR(26) PK · message_id (indexed, non-unique) · organization_id · sender · subject · html MEDIUMTEXT · text MEDIUMTEXT · has_attachments · captured_at · expires_at
mail_queue¶
Application-level outbound queue (the queue_manager worker; distinct from Postfix's own on-disk queue).
id CHAR(26) PK · sender · recipient · organization_id · subject · body · headers JSON · priority INT default 5 · status (same enum as mail_logs, default queued) · attempts / max_attempts (default 3) · scheduled_at · processed_at · error_message · worker_id · processing_time
bounce_events / suppression_list / email_suppressions¶
bounce_events— one row per bounce:message_id,sender,recipient,bounce_typeENUM(hard,soft,complaint,unsubscribe),bounce_code,bounce_reason,domain,suppressed.suppression_list(migration 0002, owned by delivery_optimizer) — UNIQUE(organization_id,email),reasonENUM(hard_bounce,complaint,unsubscribe,manual),bounce_count.email_suppressions— the tracking-side suppression store: UNIQUE(email,organization_id,suppression_type),suppression_typeENUM(BOUNCE,COMPLAINT,UNSUBSCRIBE,MANUAL),bounce_typeENUM(HARD,SOFT,BLOCK),expires_at,active.
Auth & Security Tables¶
api_keys¶
| Column | Type | Description |
|---|---|---|
id | CHAR(26) PK | |
key_id | VARCHAR(100) UNIQUE | Public identifier |
key_hash | VARCHAR(255) | Hash of the secret |
name | VARCHAR(255) | |
permissions | JSON | |
organization_id | CHAR(26) | null only for platform-scope keys |
scope | ENUM platform,organization (migration 0005) | CHECK constraint ties scope to organization_id nullability |
active / rate_limit / last_used / expires_at / ip_whitelist / usage_count |
Operator & session tables (migrations 0006 / 0015 / 0016)¶
platform_operators— staff identities:emailUNIQUE,password_hash,role(support/operator/admin/owner, enforced in app code),mfa_required,is_active, self-FKcreated_by.operator_sessions—token_hashCHAR(64) UNIQUE, idleexpires_at+absolute_expiry,revoked_at,mfa_satisfied; FK → platform_operators CASCADE.operator_audit— append-only, FK-free:operator_id,operator_email,caller_scope,action,target_type/target_id,organization_id,request_bodyJSON,result,ip_address,correlation_id.mailbox_sessions(0015) — webmail sessions:email_account_idFK CASCADE,organization_id,token_hashUNIQUE,expires_at/absolute_expiry/revoked_at,mfa_satisfied.mailbox_preferences(0016) — one row per mailbox (UNIQUEemail_account_id, FK CASCADE):signature_html,signature_on_reply,display_density,undo_send_enabled,undo_send_seconds.users/web_sessions— tenant-dashboard admins and their sessions (usersUNIQUE(organization_id,email), FK → organizations;web_sessions.token_hashUNIQUE, FK → users).failed_auth_attempts— brute-force ledger;serviceENUM grew to (smtp,imap,pop3,api,sieve,api_key,operator,webmail) across migrations 0004/0007/0015.totp_secrets— INT PK,user_emailUNIQUE,secret,backup_codesJSON,enabled,verified.idempotency_keys— UNIQUE(organization_id,idempotency_key), request hash + cached response,state,expires_at; FK → organizations.ip_access_rules,ip_reputation,user_logins,audit_logs(+ theaudit_logview) — access rules, per-IP reputation, login history, audit trail.
Certificates & Keys¶
dkim_keys—domain_idFK CASCADE,selector(defaultdefault),public_key, and since migration 0003 the private key is envelope-encrypted:private_key_ciphertextBLOB +private_key_nonce+key_version(plaintextprivate_keycolumn retained but nullable/legacy).ssl_certificates— cert_manager's ledger:domain_id,certificate_path,private_key_path,statusENUM(active,expired,revoked,pending),valid_from/valid_until,auto_renew,last_renewed.acme_account_stats(0002) — per-ACME-account issuance counters.pgp_keys/smime_certs— user encryption keys; private material envelope-encrypted since 0003 (private_key_ciphertext/nonce/key_version;smime_certs.fingerprintUNIQUE).
Webhook Tables¶
webhook_urls— per-endpoint config:organization_id,url,event_typesJSON,service_typesJSON,webhook_secret,encryption_key,priority,timeout_seconds(default 30),retry_attempts(default 3),retry_delay_seconds,rate_limit_per_minute,custom_headers,success_count/failure_count,last_success/last_failure.webhook_delivery_logs— one row per delivery outcome:event_type,event_dataJSON (truncated at 100 KB),webhook_url,delivery_statusENUM(pending,delivered,failed,retrying,abandoned),attempts,http_status_code,request_duration_ms,error_message, timing columns.webhook_dead_letters— events that exhausted all retries:event_type,endpoint_url,payloadJSON (truncated at 1 MB),last_error,attempt_count,statusENUM(pending,retrying,resolved,abandoned).
Alerting (migration 0010)¶
alert_channels—type(email/webhook),target, envelope-encrypted signing secret,enabled,last_used_at/last_error.alert_rules—metric,comparator,threshold,for_seconds,severity(critical/warning/info),channel_idsJSON,organization_id(null = platform-wide; FK CASCADE).alert_events— firing history; FK → alert_rules SET NULL;state(firing/resolved).alerts(baseline) — the older quota/rate-limit alert table (alert_typeENUM(rate_limit,storage_quota), webhook delivery bookkeeping).
Analytics & Tracking Tables¶
email_tracking— individual events:email_id,recipient,organization_id,event_typeENUM(delivered,opened,clicked,bounced,complained,unsubscribed), UA/IP/geo columns (device_type,browser,operating_system,country,region,city),additional_dataJSON.tracking_statistics— per-email aggregates:total_count,unique_count,unique_ips,first_event/last_event.analytics_data— generic metric rows:metric_name,metric_valueDECIMAL(15,4),dimensionsJSON,period.domain_reputation— periodized scores:overall_score/deliverability_score/engagement_score(default 50), sent/delivered/bounced/complained/opened/clicked counters,period_typeENUM(HOURLY,DAILY,WEEKLY,MONTHLY),isp_dataJSON.usage_history,feedback_loops,ai_transactions(AI usage/cost ledger, UNIQUEtransaction_id).
Remaining Tables (by owner)¶
| Area | Tables |
|---|---|
| System | system_config (PK is key, JSON value), health_checks, service_metrics, queue_statistics, backup_history (+ 0013's backup_id, hostname, checksum, encrypted columns) |
| Compliance | consent_records, data_export_requests, data_erasure_requests, legal_holds, retention_policies |
| Archive | email_archive (storage_type ENUM incl. spool since 0014, content_hash, UNIQUE(message_id,recipient)) |
| Groups / shared mailboxes | distribution_groups, distribution_group_members, shared_mailbox_members |
| Security services | dlp_policies, dlp_violations, geo_policies, quarantine |
| Templates | email_templates (UNIQUE(org,name)), email_template_versions, template_render_log |
| URL protection | url_clicks, url_blocklist |
| OAuth / JMAP | oauth_clients, oauth_grants, jmap_states |
| Migration jobs | migration_jobs (PK is job_id VARCHAR(36) — there is no id column), migration_errors |
| Rate limiting | rate_limit_configs, rate_limit_alerts, api_rate_limits |
| Storage | storage_usage, storage_alerts |
| Kafka | kafka_dead_letters |
| Per-domain policy | domain_policy (PK is domain, FK → domains.domain CASCADE; columns created/modified, not *_at) |
ORM models lag the migrations
database/models/ covers only ~50 of the 83 tables and has known drift (stale failed_auth_attempts.service enum, INT-vs-ULID mismatches on several *_id columns, FKs declared that don't exist in the DB). The migrations are the source of truth — verify against alembic/versions/, not the models, when writing queries.
Useful Queries¶
-- Entity counts
SELECT
(SELECT COUNT(*) FROM organizations) AS orgs,
(SELECT COUNT(*) FROM domains) AS domains,
(SELECT COUNT(*) FROM email_accounts) AS mailboxes,
(SELECT COUNT(*) FROM aliases) AS aliases,
(SELECT COUNT(*) FROM smtp_credentials) AS smtp_keys;
-- Recent bounces
SELECT sender, recipient, bounce_reason, timestamp
FROM mail_logs WHERE status = 'bounced'
ORDER BY timestamp DESC LIMIT 10;
-- Delivery by SMTP credential (last 24 h)
SELECT sasl_username, status, COUNT(*)
FROM mail_logs
WHERE timestamp > NOW() - INTERVAL 1 DAY AND sasl_username IS NOT NULL
GROUP BY sasl_username, status;
-- Current Alembic revision
SELECT * FROM alembic_version;