Data Flow¶
How an email actually travels through Mailyte — from the moment it hits the wire to the moment it lands in a mailbox (or leaves one). Verified against mailer/postfix/config/main.cf, master.cf, and the compose files, current as of 2026-08-30.
Inbound Email (Receiving)¶
When someone on the internet sends an email to one of your hosted domains:
- DNS lookup: The sender's mail server looks up your domain's MX record, which points to your Mailyte instance.
- SMTP connection: The remote server connects to Postfix on port 25. Client restrictions run first — RBL checks (
zen.spamhaus.org), pipelining rejection. - Recipient validation: Postfix queries MySQL live — is this domain in
domains? Is the recipient a real mailbox or an alias? Then the per-org IP access policy (ip_access_policy.py, a Unix policy service inside the Postfix container) runs. - Rspamd filtering: The milter (
inet:rspamd:11332) runs SPF, DKIM and DMARC verification, Bayesian scoring, and custom rules.milter_default_action = accept— if Rspamd is down, mail flows unscanned rather than deferring. (ClamAV antivirus is present in the Rspamd config but disabled — no ClamAV container is deployed.) - Rate limiting at DATA: The rate-limit policy service (
rate_limit_policy.py, which calls therate_limiterservice over HTTP) runs at the SMTP DATA phase and nowhere else on the inbound path — it once ran in three restriction lists at once, triple-counted every message, and deferred essentially all inbound mail. Over-limit senders get a450. - Local delivery over LMTP: Postfix hands the message to Dovecot via LMTP (
virtual_transport = lmtp:inet:dovecot:24), which stores it in the recipient's Maildir under/var/mail/vhosts. - Archive on delivery: Dovecot's global Sieve pipes every delivered message to
archive-message, which POSTs it to thearchiverservice (age-encrypted, written to S3, spooled locally if S3 is unreachable). - Logging & webhooks, after the fact:
log_ingestortails Postfix's log and writes per-message rows tomail_logs, dispatchingemail.delivered/email.bounced/email.deferred/email.rejectedwebhooks as delivery lines appear.
sequenceDiagram
participant Sender as Remote MTA
participant Postfix as Postfix (SMTP :25)
participant Rspamd as Rspamd (milter)
participant RL as rate_limiter (via policy service)
participant Dovecot as Dovecot (LMTP :24)
participant Archiver as Archiver
participant LI as log_ingestor
participant MySQL as MySQL
participant Webhooks as Webhook dispatch
Sender->>Postfix: SMTP connection (RBL, recipient checks)
Postfix->>Rspamd: milter: scan message
Rspamd-->>Postfix: score + action (default: accept)
Postfix->>RL: DATA-phase policy check
RL-->>Postfix: OK / 450 rate limited
Postfix->>Dovecot: LMTP delivery
Dovecot->>Dovecot: Store in Maildir
Dovecot->>Archiver: sieve: archive-message copy
LI->>MySQL: tail mail.log → mail_logs row
LI->>Webhooks: email.delivered event No sender-login check on port 25 — on purpose
reject_sender_login_mismatch is enforced only on the authenticated submission ports (587/465). Adding it to the global sender restrictions once rejected all inbound mail from any sender who owned a local mailbox (production outage, fixed 2026-08-22). Port 25 carries unauthenticated mail by definition.
Outbound Email (Sending)¶
When a user's email client sends through Mailyte:
- Authentication: The client connects to Postfix on 587 (STARTTLS) or 465 (implicit TLS). Postfix delegates SASL auth to Dovecot (
smtpd_sasl_type = dovecot), which checks MySQL — mailbox passwords or per-key SMTP credentials. - Sender ownership:
reject_sender_login_mismatchruns beforepermit_sasl_authenticatedon 587/465 — you cannot authenticate as one account and send as an address you don't own. - Rate limiting: The submission service applies the same rate-limit policy check via a named restriction class.
- Tracking content filter: Submission ports carry
content_filter=tracking-filter:—tracking_injector.py(inside the Postfix container) calls thetrackingservice to inject the open pixel and rewrite links, consultsdelivery_optimizer, hands the archiver an outbound copy, then reinjects the message on the loopback listener (10026). - DKIM signing: Rspamd (not Postfix) signs the message with the sending domain's private key via the milter.
- Queue & delivery: The message enters Postfix's spool (a named volume — queued mail survives container recreation), the MX is resolved, and delivery is attempted with normal Postfix retry/backoff.
- Logging & webhooks:
log_ingestorturns the delivery/bounce/deferral log lines intomail_logsrows and webhook events.
sequenceDiagram
participant Client as Email Client
participant Postfix as Postfix (:587/:465)
participant Dovecot as Dovecot (SASL)
participant TI as tracking_injector (content filter)
participant Tracking as tracking service
participant DO as delivery_optimizer
participant Rspamd as Rspamd (DKIM sign)
participant Remote as Remote MTA
participant LI as log_ingestor
Client->>Postfix: SMTP AUTH + submit
Postfix->>Dovecot: verify credentials (MySQL-backed)
Dovecot-->>Postfix: auth OK
Note over Postfix: reject_sender_login_mismatch<br/>+ rate-limit policy class
Postfix->>TI: content_filter=tracking-filter
TI->>Tracking: inject pixel, rewrite links
TI->>DO: throttling/warming advice
TI-->>Postfix: reinject on :10026
Postfix->>Rspamd: milter: sign with domain DKIM key
Postfix->>Remote: SMTP delivery (queue + retry)
Remote-->>Postfix: 250 OK / bounce
LI->>LI: mail.log line → mail_logs + email.delivered/bounced webhook API / Webmail-Initiated Email¶
When the webmail or an API client sends through the gateway:
- API request:
POSTto the FastAPI gateway (bind port 8080;api.${DOMAIN}behind Traefik in production) with the caller's credential (API key or webmail session). - Validation: The gateway validates the credential and that the sending mailbox belongs to the authenticated context.
- SMTP submission to the internal listener: The gateway submits the message over SMTP to
postfix:10587— Postfix's internal submission listener, which carries the tracking content filter. It deliberately does not use port 25, because 25 has no tracking filter (it must never touch inbound mail); sending on 25 was why no webmail message was ever tracked before this was fixed. - From there, the normal outbound flow applies — tracking injection, DKIM signing by Rspamd, Postfix queue, delivery, log ingestion, webhooks.
There is no separate application mail queue
The outbound queue is Postfix's spool. The queue_manager service does not dequeue messages from MySQL and feed them to Postfix — it manages Postfix's own queue (postqueue -p, flush, hold, release, delete) over the shared spool volume. The mail_queue table exists in the schema but is not a send path.
Tracking Events¶
After an email is delivered, two things generate tracking events:
- Open tracking: The injected pixel points at
https://api.${DOMAIN}/api/v1/tracking/pixel/{id}. The gateway proxies the request to thetrackingservice, which records the open. - Click tracking: Rewritten links pass through the gateway's tracking redirect endpoint; the tracking service records the click and 302-redirects to the original URL.
Both are stored in MySQL (email_tracking, tracking_statistics) and can fire tracking.open / tracking.click webhooks. The tracking base URL must be a hostname that actually resolves and routes to the gateway — it is configured via TRACKING_BASE_URL.
RAG Pipeline (AI Search)¶
For organizations with RAG enabled:
- Email content is embedded by the
ragworker and stored in Qdrant. - Search queries arrive via the gateway's
/api/v1/ragmodule, get embedded, and are matched against stored vectors.
This runs asynchronously and doesn't affect mail delivery.
What Gets Logged¶
| What | Where | Producer |
|---|---|---|
| Per-message delivery records | MySQL mail_logs | log_ingestor (tails Postfix's log; added 2026-08-22 — before that the table had no producer) |
| Raw SMTP transactions | logs/mailer/postfix/mail.log | Postfix |
| Spam scores and actions | Rspamd logs; score also lands on the mail_logs row | Rspamd / log_ingestor |
| Open/click events | MySQL email_tracking, tracking_statistics | tracking service |
| Webhook deliveries & failures | MySQL webhook_delivery_logs, webhook_dead_letters | shared webhook dispatcher |
| API requests / mutations | Gateway logs (with per-request correlation IDs), MySQL audit_logs | api |
| Auth failures | MySQL failed_auth_attempts | api |