§2 · Architecture
How a self-hosted email-marketing system actually works.
A campaign send walks through five stages. Understanding them is the prerequisite for picking platforms, picking a sending relay, and debugging deliverability when a campaign underperforms.
[Marketer's browser]
│
▼ HTTPS POST /campaigns
[Web app — Laravel/Symfony/Go] ◀── REST API also lands here
│
▼ enqueue per-recipient job
[Job queue — Redis / beanstalkd / DB]
│
▼ worker picks job, renders MIME
[Sending driver — SES/Mailgun/SMTP]
│
▼ TLS, AUTH, DATA, .
[Mail Transfer Agent (MTA)]
│
▼ SMTP to recipient MX
[Recipient mail server — Gmail/O365/Yahoo]
│
▼ open pixel · click redirect · bounce · complaint
[Webhook back to web app] ◀── reputation feedback loop
Stage 1 — campaign authoring. The marketer writes a campaign in the web UI: subject line, sender name, list selection, content editor (drag-and-drop, MJML, or raw HTML). When they hit "send," the application creates a campaign record and enqueues per-recipient jobs to a background queue. Nothing leaves the server yet.
Stage 2 — queue + worker. Background workers pull jobs at the rate the queue is configured for (typically 5–50 sends/second per worker). The worker fetches the subscriber row, merges custom fields into the template, embeds a tracking pixel keyed by message-id, and rewrites every link to a click-tracking redirect. The result is a fully personalised MIME message ready to hand to a sending driver.
Stage 3 — sending driver. The driver is the adapter between your application and whatever transport you pick: an Amazon SES API call, a Mailgun HTTPS POST, a SendGrid Web API call, or a raw SMTP AUTH PLAIN handshake to your own Postal MTA. AcelleMail's sending-driver pattern, for example, defines a 5-method contract (connect, send, getDeliveryStatus, getCapabilities, validateConfig) that every driver implements — see /developers/sending-drivers for the full driver guideline. Built-in drivers ship for Amazon SES, SendGrid, Mailgun, SparkPost, Elastic Email, Gmail SMTP, and any generic SMTP server (8 vendors live in app/SendingServers/Drivers/Vendors/).
Stage 4 — MTA → recipient. The MTA (yours or your relay's) opens an SMTP connection to the recipient's MX server, presents itself with a HELO/EHLO, negotiates STARTTLS, authenticates DKIM-signing keys, and submits the message. The recipient mail server runs spam scoring, SPF/DKIM/DMARC verification, list-unsubscribe header processing, and either inboxes or rejects.
Stage 5 — feedback loop. Three signals come back, all asynchronous. Bounces arrive over SMTP (5xx codes for hard bounces, 4xx for soft) or via the relay's webhook. Complaints (recipient hit "spam") arrive via the Mailbox Provider's Feedback Loop or, again, the relay's webhook. Engagement (open pixel hits, click-redirect requests) arrives via plain HTTP back to your web app. The application uses these to update suppression lists, retire dead addresses, and feed campaign analytics. Acelle's webhook-event catalogue lives at config/webhook_events.php — it's the canonical list of events the application emits and consumes.
The shape is the same on every self-hosted platform, with one variable: where the queue and MTA live. AcelleMail's worker runs on the same box as the web app by default; Listmonk does the same; Mautic uses Symfony Messenger and supports separate worker boxes. Mail relay can be local (Postal on the same machine, Postfix on a sister box) or remote (SES, Mailgun) — that decision is the subject of §5.