Security Model¶
How Mailyte protects itself, its tenants, and the email it handles — from authentication to encryption to threat detection. Verified against the deployed configuration as of 2026-08-30.
Authentication¶
Mailyte has several authentication paths, each scoped to a different audience.
API Keys (X-API-Key header)¶
The primary method for programmatic access:
API keys are:
- Scoped to a single organization (tenant isolation).
- Stored hash-only in MySQL (SHA-256 in
key_hash;key_idis a non-secret display id) — raw keys are never stored, since 2026-08-30 / migration0019. - Expiring:
expires_atis enforced at authentication (expired keys get 401). - Revocable at any time (
active = 0takes effect on the next request). ip_whitelistand per-keyrate_limitcolumns exist but are not yet enforced by the auth path.
Platform scope: cross-tenant operations (creating organizations, service restarts, compliance actions, the cross-tenant directory) require a credential with scope='platform'. A tenant-scoped key can never reach these endpoints, regardless of its own permission flags.
There is no X-Admin-Password anymore
Earlier versions used an X-Admin-Password header for cross-org operations. That path has no remaining consumer — it was replaced by platform-scoped API keys and real operator identities. (X-Admin-Token survives only as a pass-through to the monitoring service's own restart gate, layered on top of an admin-scoped API key.)
Operator sessions (console)¶
Platform operators sign in through POST /api/v1/platform/auth/login with mandatory TOTP MFA (backed by the totp service). Operator sessions are IP-bound, and the console itself sits behind a Traefik IP allowlist that fails closed (loopback-only unless CONSOLE_ALLOWED_IPS is set). First-boot operator creation uses a single-use bootstrap token written by the API only while no operator exists.
Browser sessions¶
For deployments where a browser talks to this API directly, POST /api/v1/auth/* exchanges credentials for a short-lived HttpOnly session cookie with CSRF protection — nothing long-lived sits in localStorage.
Webmail (mailbox holder) sessions¶
Webmail login (/api/v1/mailbox-auth) verifies the password against Dovecot over IMAP — not against a separately stored copy. (A stale password copy is exactly what broke webmail after the 2026 migration; fixed 2026-08-21.) The session then holds the SMTP credential used for sends.
Email protocol authentication¶
When clients connect via SMTP (587/465) or IMAP/POP3, Postfix delegates SASL auth to Dovecot (smtpd_sasl_type = dovecot), which checks MySQL:
- Mailbox passwords are bcrypt hashes.
- Per-mailbox SMTP credentials (live since 2026-08-27) authenticate through a dedicated Dovecot SQL passdb, so an application can send without holding the mailbox password.
reject_sender_login_mismatchon 587/465 (evaluated beforepermit_sasl_authenticated) means you can only send from addresses your login owns. It is deliberately not applied to port 25, which carries unauthenticated inbound mail.
Revocation vs. the Dovecot auth cache
Dovecot caches auth results for up to 1 hour (auth_cache_ttl). Revoking or rotating an SMTP credential through the API flushes the cache via the doveadm HTTP API (internal port 24180) so revocation is immediate, and since 2026-08-30 mailbox mutations through the API (password change, suspend/deactivate, delete — CRUD and legacy routes) flush the same way. Changes made any other way (direct SQL edits, restore scripts) keep authenticating until the cache expires — flush it or the change isn't enforced.
Encryption (TLS Everywhere)¶
Every external-facing connection uses TLS:
| Protocol | Port | TLS Mode |
|---|---|---|
| SMTP (server-to-server) | 25 | Opportunistic STARTTLS |
| SMTP (client submission) | 587 | Required STARTTLS |
| SMTP (client submission) | 465 | Implicit TLS |
| IMAP / IMAPS | 143 / 993 | STARTTLS / Implicit TLS |
| POP3 / POP3S | 110 / 995 | STARTTLS / Implicit TLS |
| ManageSieve | 4190 | STARTTLS |
| All HTTP surfaces (API, webmail, console, docs, JMAP, CalDAV, autoconfig) | 443 | TLS terminated by Traefik |
Internal service-to-service communication within the Docker network is unencrypted — it never leaves the host.
cert_manager issues and renews all certificates (ACME HTTP-01 via a shared webroot; Traefik's built-in ACME is deliberately disabled because two ACME clients fought over the same challenges in production). It also maintains per-domain SNI certificates so customers can point mail.theirdomain.com at the server, and reloads Postfix/Dovecot after rotation — through the scoped docker-proxy, never the raw Docker socket.
Secrets & Key Material¶
- Fail-closed startup: the
secrets-checkcontainer validates every security-critical secret (DB passwords, webhook/OAuth/URL-HMAC secrets, admin credentials, Grafana password) before anything else starts. A missing or known-weak value stops the whole stack — the alternative was an internet-facing mail server running on published default passwords. - File-mounted root credential: MySQL's root password comes from a 0400-mounted file, not an environment variable (env vars on a running container are readable by anything with Docker API access, for the container's whole lifetime).
- Envelope encryption: DKIM private keys (and PGP/S-MIME material) are wrapped with a KEK mounted read-only at
/run/secrets/encryption_kek— a database dump alone does not expose signing keys. - Scoped Docker access: nothing in the stack touches
/var/run/docker.sockexcept thedocker-proxycontainer, which exposes exactly list/inspect/restart — no exec, no images, no volumes — on an isolated internal-only network. - Container hardening: mail containers run with
no-new-privileges,cap_drop: ALL, and only the specific capabilities each daemon's own privilege-separation model needs.
DKIM Signing¶
Every outgoing email is signed with a per-domain DKIM signature — by Rspamd, via the milter, not by Postfix itself:
- When a domain is added via the API, Mailyte generates a DKIM keypair.
- The private key is stored envelope-encrypted in MySQL; the public key is handed back as the DNS TXT record to publish.
- Rspamd resolves the signing key per sending domain and signs headers and body.
- Receiving servers verify against the public key in DNS.
Combined with the SPF and DMARC records the API also provides (and verifies), this is what keeps mail out of spam folders.
Network Exposure¶
Locked down on 2026-08-22:
- In production, only the mail protocol ports (25, 465, 587, 143, 993, 110, 995, 4190) and Traefik's 80/443 listen on
0.0.0.0. Every internal service — including Prometheus, Qdrant, the Rspamd UI, and Kafka, all of which were previously answering the public internet without authentication — is bound to127.0.0.1. - Docker publishes ports past
ufwby writing its own iptables rules, so the bind address is the control, not the host firewall. - MySQL and Redis publish no host port at all.
- The staff console is additionally IP-allowlisted at Traefik and fails closed.
Threat Detection & Abuse Controls¶
What actually runs (the fail2ban-based mailer/intrusion_detection/ configs exist in the repo but are not deployed — no compose file includes them):
- RBLs at the edge: Postfix rejects clients listed on
zen.spamhaus.orgbefore the message body is ever accepted. - Connection limits: Postfix's own per-client connection and message rate limits.
- Rate limiting as policy: the
rate_limiterservice is consulted by Postfix at the SMTP DATA phase (inbound) and on the submission path (outbound), plus per-key API rate limits at the gateway. - Failed-auth tracking: the gateway records failed logins (
failed_auth_attempts), throttles repeated attempts, and uses constant-time dummy hashing so user enumeration by timing doesn't work. - Per-org IP access rules: enforced by Postfix's
ip_access_policyservice against theip_access_rulestable. - DLP: the
dlpservice scans for PII (Luhn-validated card numbers, SSNs, IBANs) and per-org keyword/regex policies, with block/quarantine/notify actions and violations logged to MySQL. - Geo-blocking: the
geo_blockingservice applies GeoIP country policies. - Auto-suspension hook:
log_ingestorcan auto-suspend abusive SMTP credentials (AUTO_SUSPEND_ENABLED, default off until thresholds are validated against real traffic).
Rate Limiting as Security¶
| What | Scope | Enforced where |
|---|---|---|
| Inbound mail | Per sender | Postfix DATA-phase policy → rate_limiter |
| Outbound submissions | Per authenticated sender/org | Postfix submission restriction class → rate_limiter |
| API requests | Per API key (rate_limit column) | Gateway |
| Login attempts | Per account/IP | Gateway lockout logic |
| Connections/messages | Per client IP | Postfix built-ins |
Data Isolation Per Organization¶
- API layer: every request is scoped to the org that owns the credential; cross-tenant reach requires platform scope.
- Database layer: queries include
WHERE organization_id = ?. - Worker layer: workers process per-org and never mix tenants.
- Mail layer: Dovecot's virtual mailbox layout isolates Maildirs; Postfix's sender-login maps stop cross-account sending.
See Multi-Tenant Isolation for the full breakdown.
Audit Trail¶
Significant actions land in audit_logs (who — user/API key/operator, what, which resource, when, from where), and every gateway request carries a correlation ID that is stamped on the response and folded into error bodies, so an upstream system can log it alongside its own job IDs. Operator actions are additionally recorded with the operator's email denormalised, so the trail stays readable after an operator account is renamed or removed.
Security Checklist¶
When deploying Mailyte, make sure:
-
scripts/generate-secrets.shhas been run andsecrets-checkpasses — the stack will not start on weak defaults. - API keys use the minimum permissions needed; platform scope only where genuinely required.
- TLS certificates are valid and auto-renewing (
cert_managerlogs, not just browser checks). - DKIM, SPF, and DMARC records are configured and verified for every domain.
-
docker-compose.prod.ymlis in use — it is what binds internal ports to loopback. - The console's
CONSOLE_ALLOWED_IPSis set (it fails closed without it — nobody gets in). - Rate limits are set to sane defaults.
- MySQL and Redis publish no host ports (they don't, in the shipped compose files — keep it that way).
- Backups exist, are encrypted, and at least one copy is off the mail host.