Database Design¶
The database schema, how the models relate to each other, and why things are structured this way. Verified against database/models/ as of 2026-08-30.
Overview¶
Mailyte uses SQLAlchemy ORM models organized into logical groups. Each group lives in its own file under database/models/. MySQL (8.0.35) is the primary database, with Redis for caching and Qdrant for vector storage.
Two structural facts shape everything:
- ULID primary keys. Every core table uses a 26-character ULID (
CHAR(26)) primary key generated byshared/ulid_utils.py, not an auto-increment integer. IDs are globally unique, time-sortable, and safe to expose in URLs. (See ULID Migration.) - Alembic owns the schema. Migrations live in
alembic/versions/(baseline0001through, currently,0010_alert_rules_channels_events) and run in the dedicatedmigratecontainer before any schema-touching service starts. A failed migration stops the deploy. The frozen SQL files underdatabase/migrations/sql/are the historical source the baseline was generated from — nothing executes them in this edition anymore.
The schema follows a strict multi-tenant hierarchy: Organization → Domain → EmailAccount. Almost every table carries an organization_id foreign key, which is what makes tenant isolation a simple WHERE clause.
Model Files at a Glance¶
| File | What it covers | Key models |
|---|---|---|
core.py | The multi-tenant hierarchy + SMTP API keys | Organization, Domain, EmailAccount, Alias, SmtpCredential, SmtpCredentialEvent |
mail.py | Email processing | MailQueue, MailLog |
authentication.py | Auth and API access | APIKey, User, UserSession, WebSession, MailboxSession |
security.py | Threat tracking & policy | IPAccessRule, AuditLog, FailedAuthAttempt, IPReputation, DLPPolicy, DLPViolation, TOTPSecret, GeoPolicy |
tracking.py | Email engagement | EmailTracking, TrackingStatistics |
webhooks.py | Event callbacks | WebhookURL, WebhookDeliveryLog |
analytics.py | Aggregated metrics | AnalyticsData |
ai.py | AI usage | AITransaction |
reputation.py | Sender reputation & suppression | EmailSuppression, DomainReputation, FeedbackLoop |
certificates.py | TLS & DKIM key material | SSLCertificate, DKIMKey |
collaboration.py | Shared mailboxes, groups, rules | SharedMailboxMember, DistributionGroup, DistributionGroupMember, TransportRule, Quarantine |
alerts.py | Operator alerting & usage | Alert, UsageHistory |
infrastructure.py | Protocol/ops state | JMAPState, MigrationJob, MigrationError, KafkaDeadLetter, ConsentRecord, DataExportRequest, DataErasureRequest, OAuthClient, OAuthGrant |
system.py | Server-level state | SystemConfig, HealthCheck, ServiceMetrics |
enums.py | Shared enums | MailStatus, EventType, … |
Entity Relationship Diagram¶
The core of the schema (fields abbreviated; all id columns are CHAR(26) ULIDs):
erDiagram
Organization ||--o{ Domain : "owns"
Organization ||--o{ EmailAccount : "owns"
Organization ||--o{ Alias : "owns"
Organization ||--o{ APIKey : "has"
Organization ||--o{ MailLog : "attributed to"
Domain ||--o{ EmailAccount : "contains"
Domain ||--o{ Alias : "contains"
Domain ||--o{ DKIMKey : "signs with"
Domain ||--o{ SSLCertificate : "secured by"
EmailAccount ||--o{ SmtpCredential : "issues"
SmtpCredential ||--o{ SmtpCredentialEvent : "logs"
Organization {
string id PK "ULID CHAR(26)"
string external_id UK
string name
json rate_limits
json storage_quotas
json webhook_urls
boolean quota_override
boolean active
datetime created_at
}
Domain {
string id PK "ULID"
string organization_id FK
string domain UK
bigint max_quota
json rate_limits
json storage_quotas
boolean active
}
EmailAccount {
string id PK "ULID"
string domain_id FK
string organization_id FK
string email UK
string local_part
string password "bcrypt hash"
string status
bigint storage_quota
bigint storage_used
}
Alias {
string id PK "ULID"
string domain_id FK
string organization_id FK
string source
text destination
boolean active
}
APIKey {
string id PK "ULID"
string key_id UK
string key_hash
string organization_id FK
json permissions
json ip_whitelist
int rate_limit
datetime expires_at
boolean active
}
MailLog {
string id PK "ULID"
string organization_id FK
string sender
string recipient
string status "MailStatus enum"
string message_id
string relay
string delays
string dsn
float spam_score
string sasl_username
datetime timestamp
} Core Models (core.py)¶
Organization¶
The top-level tenant.
external_id— A unique identifier that maps to the upstream system (mailyte-api's customer ID). This is how the two systems stay loosely coupled.rate_limits/storage_quotas— JSON blobs holding the org's default limits (there is no flatsending_rate_limitcolumn; limits are structured).webhook_urls/webhook_secret— the org's callback configuration.quota_override(+_at,_by) — set when a human operator edits quotas through the console; the upstream plan sync then refuses to overwrite them withoutforce=true.
Domain¶
A mail domain owned by an organization. Carries verified/verification state, max_quota, and its own rate_limits/storage_quotas JSON that can override the org defaults.
EmailAccount¶
A mailbox under a domain. Notable columns:
password— bcrypt hash, checked by Dovecot for IMAP/POP3/SMTP auth.status— active/suspended/… (Dovecot's SQL queries filter on it).storage_quota/storage_used(+ attachment/email breakdowns) — quota accounting; usage is measured by thestorage_usageworker over IMAP QUOTA.
Alias¶
source → destination forwarding, no mailbox of its own. destination is Text — one-to-many forwarding is expressed as multiple destinations.
SmtpCredential / SmtpCredentialEvent¶
Per-mailbox SMTP API keys (live since 2026-08-27): scoped credentials that authenticate on the submission ports through a dedicated Dovecot SQL passdb. Lifecycle operations (rotate, revoke, suspend) flush Dovecot's auth cache via the doveadm HTTP API so they take effect immediately, and SmtpCredentialEvent is the audit/usage trail behind the credential reports endpoints.
Mail Models (mail.py)¶
MailLog¶
A per-message record of every delivery attempt, inbound or outbound. Its columns — relay, delays, dsn, status, sasl_username, spam_score — map one-for-one onto a Postfix delivery log line, which is what the schema was designed around.
Producer: mailer/log_ingestor (added 2026-08-22), which tails Postfix's log. Before it existed, nothing wrote this table at all.
MailQueue¶
A queue-shaped table (status = queued/sending/sent/bounced/…, attempts, scheduled_at, worker_id).
MailQueue is not the send path
The live outbound queue is Postfix's own spool (a named Docker volume), managed by the queue_manager service via postqueue. API- and webmail-originated mail is submitted straight to Postfix's internal submission listener. Don't build features that assume rows flow through mail_queue.
Authentication Models (authentication.py)¶
- APIKey — programmatic keys, sent as
X-API-Key. Stored hash-only:key_hash(SHA-256) is the verification value andkey_ida non-secret display id (raw keys redacted by migration0019, 2026-08-30), with JSONpermissions, optionalip_whitelist(not yet enforced), per-keyrate_limit(not yet enforced), andexpires_at(enforced at auth). Platform-scoped keys (scope='platform', added in migration0005) are required for cross-tenant operations. - User / UserSession / WebSession — browser-session auth for deployments where a browser talks to this API directly: credentials in, short-lived HttpOnly cookie out.
- MailboxSession — the webmail's mailbox-holder sessions. Login is verified against Dovecot over IMAP (not against a copied password hash — a stale-copy bug fixed 2026-08-21), and the session holds the SMTP credential used for sends.
Security Models (security.py)¶
- IPAccessRule — per-org IP allow/deny, enforced by Postfix's
ip_access_policyservice. - AuditLog — who did what, when, from where. API mutations land here.
- FailedAuthAttempt — failed logins, backing lockout/throttling logic in the gateway.
- IPReputation — per-IP reputation tracking.
- DLPPolicy / DLPViolation — the DLP service's per-org policies and recorded violations.
- TOTPSecret — TOTP enrollment for operator MFA (the
totpservice). - GeoPolicy — geo-blocking rules for the
geo_blockingservice.
Tracking Models (tracking.py)¶
- EmailTracking — one row per tracked message: pixel ID, rewritten links, open/click state, recipient metadata.
- TrackingStatistics — per-domain/per-period rollups.
Webhook Models (webhooks.py)¶
- WebhookURL — registered callback endpoints.
- WebhookDeliveryLog — the record of each attempted delivery. Permanently failed events additionally land in the
webhook_dead_letterstable written byshared/webhook_dispatcher.py.
Analytics (analytics.py), AI (ai.py), Reputation (reputation.py)¶
- AnalyticsData — pre-aggregated, time-bucketed metrics per org/domain (the
analyticsworker writes these; dashboards read them instead of scanning raw events). - AITransaction — usage records for AI-backed features.
- EmailSuppression — suppression list (bounces, complaints, unsubscribes) consulted on the send path.
- DomainReputation / FeedbackLoop — deliverability reputation per domain and inbound FBL processing.
Certificates (certificates.py)¶
- SSLCertificate — per-hostname TLS certificates managed by
cert_manager, with expiry tracking; backs the SNI maps Postfix/Dovecot/Traefik read. - DKIMKey — per-domain DKIM key pairs. Private keys are envelope-encrypted: wrapped with the KEK mounted at
/run/secrets/encryption_kek(migration0003_encrypt_private_keys), so a database dump alone does not expose signing keys.
Collaboration (collaboration.py), Alerts (alerts.py), Infrastructure (infrastructure.py), System (system.py)¶
- Shared mailbox membership, distribution groups, TransportRule (mail-routing rules — enforcement is gated behind
TRANSPORT_RULES_ENABLED, off by default), and Quarantine. - Alert rules/channels/events (migration
0010) evaluated by the gateway on an interval, plus UsageHistory rollups. - JMAPState (JMAP sync state), MigrationJob/MigrationError (mailbox migration), OAuthClient/OAuthGrant (XOAUTH2), GDPR-shaped ConsentRecord / DataExportRequest / DataErasureRequest, and KafkaDeadLetter (schema support for the provisioned-but-unused Kafka broker).
- SystemConfig, HealthCheck, ServiceMetrics — server-level state.
Design Decisions¶
Why organization_id on almost everything? Tenant isolation. Every query can be scoped to an org with a simple WHERE organization_id = ?.
Why ULID primary keys? Globally unique and time-sortable without coordination, safe to expose in URLs and to sync to the upstream Laravel system, and index-friendly (unlike random UUIDv4).
Why external_id on Organization? So the upstream system doesn't have to store Mailyte's IDs — each side keeps its own ULIDs and maps through external_id.
Why separate MailQueue and MailLog? Different lifecycles — queue records are transient, log records are the permanent audit trail. (And in the deployed system, MailLog is the one that actually gets written — by log_ingestor.)
Why store DKIM keys in the database? So the API can provision new domains without touching config files — Rspamd's signing path resolves keys per domain. Keys are envelope-encrypted with a KEK that never lives in the database.