Transactional and marketing e-mail: why we run two platforms, not one
One shared sending platform means your invoice inherits your campaign's spam score — but reputation is only the argument people already know. The queue that starves password resets behind 80,000 newsletters, the suppression list that must never be shared, and the consent boundary a platform should enforce rather than trust you to remember.
The call always comes on a Tuesday afternoon in November. A shop sent its Black Friday teaser to 84,000 subscribers at 14:00, and by 14:20 customers were phoning about password reset mails that never arrived. Nothing was down. The reset mails existed, they were queued, they were even accepted — they just came out the far end of a shared sending queue somewhere around 15:40, long after the customer gave up and called.
Nobody designed that. It's what you get when the mail that must arrive and the mail that may arrive share one platform, and it is the least interesting of the four reasons we separate them.
The reputation argument is the one everybody has already heard: a campaign to a stale segment collects complaints, and the complaints drag the invoices down with them. It's true, and we've written about how to split sending domains so a bad campaign week doesn't take the password resets with it. But DNS separation is the cheap half. Two subdomains on one platform still share a queue, still share a suppression list, and still share whatever consent model that platform decided to have. Those are the parts that break in ways your monitoring won't show you.
The two kinds of mail have opposite requirements
Put the two workloads side by side and almost nothing matches.
| Transactional | Marketing | |
|---|---|---|
| Trigger | A person did something, seconds ago | Someone in the business pressed send |
| Volume shape | Steady, proportional to traffic | Idle, then 80,000 in ten minutes |
| Latency that matters | Seconds. A reset mail at +30 min is a support ticket | Hours. Nobody notices a newsletter landing at 09:40 instead of 09:15 |
| On failure | Retry hard, alert a human, it is a bug | Suppress and move on, it is a list-hygiene fact |
| Legal basis | Contract or legitimate interest | Consent, with proof of when |
| Unsubscribe | Must not exist | Must be one click, honoured in 48h |
| Who edits the template | A developer, in git, reviewed | Marketing, this afternoon, in a WYSIWYG |
| Retention of the body | Searchable for months — "where did my invoice go?" | The campaign, not the 84,000 renderings |
A platform tuned for one of those columns is mistuned for the other. Bulk senders batch aggressively, throttle themselves per receiving domain, and treat a delayed send as normal. Transactional senders keep concurrency high, retry on a tight schedule, and treat a delay as an incident. You cannot set one queue policy that is correct for both, and a vendor selling you "one platform for all your e-mail" has quietly picked one column and let you assume it picked yours.
The queue is the argument nobody makes
Here is the mechanic behind the November phone calls, and it is worth understanding because it explains why "we have plenty of capacity" is not a defence.
Mailbox providers rate-limit per sending IP per receiving domain. Gmail will accept a certain number of concurrent connections and messages per hour from you, and that ceiling is a property of your reputation, not of your hardware. So when 84,000 campaign mails enter the queue, the platform cannot simply send them faster. It can only work through them at the pace Gmail allows — and if there is one queue, the reset mail generated at 14:19 sits behind however many campaign mails were enqueued at 14:00.
That's classic head-of-line blocking, and the two properties that make it nasty are both invisible from the dashboard: it only happens during campaigns, which is exactly when everyone is watching revenue rather than latency, and every individual mail eventually reports as delivered. Your delivery rate is 99.7%. Your customers are on the phone.
Separate platforms give you separate queues by construction. Separate queues on separate IP pools also mean separate rate-limit budgets with each receiver — the campaign is not spending the allowance your order confirmations need. You can approximate this inside one platform if it exposes per-stream queues and per-stream IPs, and some do. The question to ask a vendor is not "do you support transactional and marketing" but "what happens to a message enqueued at position 84,001, and can I see that number". If nobody can answer, you have one queue.
The transactional service we run does around 33 million messages a month and has measured 100% uptime over the past six years, and neither of those numbers would mean much if a client's own newsletter could sit in front of their invoices. It can't, because the newsletters go through a different platform entirely.
Suppression lists are the trap
This is the one that costs money quietly, and we have never seen a merged setup get it right.
Every serious platform keeps a suppression list: addresses it refuses to send to, because they hard-bounced, marked you as spam, or unsubscribed. That list is a safety feature. It is also, if shared across both streams, a bug with legal consequences in both directions.
Downward: a newsletter unsubscribe silently kills the invoices. Someone clicks unsubscribe on your Black Friday mail. If both streams resolve against one suppression list, that address is now suppressed — and the next order confirmation, password reset, and invoice reminder is dropped at the platform. No bounce, no error in your application, nothing in your logs, because from your side the API returned 200 and the platform did as it was told. The customer's mail stops arriving and neither of you finds out until a payment reminder becomes a debt collection letter. That customer, by the way, never asked to stop receiving invoices; they asked to stop receiving marketing, and you were legally obliged to keep sending the invoice.
Upward: a transactional hard bounce shouldn't necessarily kill the campaign — but a complaint absolutely should. These need to be reasoned about separately. An address that hard-bounces on a receipt is dead everywhere and belongs on both lists. An address that hits your spam complaint button on a newsletter must never receive another newsletter, and must keep receiving their invoices. One list cannot encode a distinction the law makes.
So the rule we implement on every project: two suppression lists, with a deliberate, one-directional sync.
for (const event of await claimBatch("mail.event", 200)) {
const { stream, type, email } = event.payload
// A dead mailbox is dead for everyone. Sync it.
if (type === "hard_bounce") {
await marketing.suppressions.add({ email, reason: "hard_bounce" })
await transactional.suppressions.add({ email, reason: "hard_bounce" })
}
// A complaint on a campaign ends campaigns. It does not end invoices.
if (type === "complaint" && stream === "marketing") {
await marketing.suppressions.add({ email, reason: "complaint" })
}
// An unsubscribe is a marketing-scope decision, full stop.
if (type === "unsubscribe") {
await marketing.contacts.optOut({ email })
}
await ack(event)
}Note what is not in there: no path from an unsubscribe or a marketing complaint to the transactional platform. That absence is the whole point, and on a single platform it is the default you have to actively fight.
We push this into the platform rather than the application for the same reason we prefer consent enforced by the system rather than by the campaign: a rule that lives in a flow gets forgotten by whoever builds the next flow. Two platforms make the boundary structural. There is no API call that could accidentally cross it, because the credential for one doesn't authenticate against the other.
Consent is a different shape, not a stricter version of the same shape
Transactional mail rides on the contract: the customer ordered something, and the confirmation is part of delivering it. Under GDPR that is Article 6(1)(b), performance of a contract, and no separate consent is needed or wanted. Asking for it would be worse than not asking, because it implies the customer could withdraw it and stop their own invoices.
Marketing mail is consent, and consent has to be evidenced: when, from which form, with what wording, and — in Belgium and most of the EU — from a double opt-in you can reproduce two years later when someone complains to the DPA. That evidence is a data model. It has fields, a timestamp, a source, and an audit trail, and it belongs on the platform that sends the campaigns.
The place this goes wrong is the seam between them, and it's almost always the checkout form. An address typed into checkout is given to you for that order. Adding it to the newsletter needs a separate, unticked box on that page — and when someone does tick it, the address is created on the marketing platform with the consent record attached, not copied over by a nightly script that loses the provenance. We wrote about the same boundary from the other side in cart recovery flows: recovery mail is service mail about a specific transaction, not a back door onto the list.
There is also a retention difference that catches people out. You will want transactional message bodies searchable for months, because "the customer says the invoice never arrived" is a weekly support question and the only satisfying answer is the delivery log. You very much do not want 84,000 rendered copies of a campaign sitting around for the same period. Different retention policies, different data volumes, different DPA paragraphs — easier to defend when they are different systems.
What the split looks like in an application
Two platforms sounds like twice the integration. In practice it is one extra environment variable and a router that is deliberately boring.
import { MailingClient } from "@codeagency/mailing"
import { SmtpClient } from "@codeagency/transactional"
const transactional = new SmtpClient({
endpoint: env.TRANSACTIONAL_ENDPOINT, // mail.example.be
apiKey: env.TRANSACTIONAL_API_KEY,
})
const marketing = new MailingClient({
endpoint: env.MAILING_ENDPOINT, // news.example.be
apiKey: env.MAILING_API_KEY,
})
/** Transactional: triggered by one person's action, no unsubscribe, retried hard. */
export function sendTransactional(message: TransactionalMessage) {
return transactional.send({ ...message, stream: "transactional" })
}
/** Marketing: requires a contact with a consent record. There is no ad-hoc send. */
export function enqueueCampaign(campaign: CampaignRef, segment: SegmentRef) {
return marketing.campaigns.send({ campaign, segment })
}The asymmetry in that file is intentional. sendTransactional takes a message; there is no campaign equivalent that takes a list of addresses, because "send this to these people" is exactly the call that lets someone bypass the consent model at 17:00 on a deadline. If marketing needs to reach a group, the group is a segment on the marketing platform, and the platform checks the opt-in status of everyone in it at send time.
On the Odoo side the same split is configuration rather than code. Odoo supports multiple outgoing mail servers and lets a mail template pin one, so the invoice and quotation templates go out through the transactional relay while mass_mailing sends through the marketing platform:
Settings › Technical › Outgoing Mail Servers
transactional smtp.mail.example.be:587 priority 5 ← invoices, quotes, portal
marketing smtp.news.example.be:587 priority 10 ← mass mailing only
Mail templates: set mail_server_id explicitly on the invoice, quotation and
portal templates. A template with no server set falls to the lowest priority
number — which is why transactional is 5, not 10.That last line matters more than it looks. Odoo picks the lowest-priority-number server when nothing is pinned, so the default should be the stream where an accidental send is harmless. A stray mail through the transactional relay is a mail. A stray mail through the bulk relay is a mail from an unexpected domain with no consent record behind it.
The DNS half, briefly
Both platforms need their own sending identity, which means a subdomain each with its own SPF record and DKIM selector, and a root domain that publishes p=reject and sends nothing but person-to-person mail. That part is covered properly in the deliverability post and we won't repeat it here, except for one point that only makes sense once you're running two platforms:
a selector per platform is what makes leaving one of them a DNS change instead of a project. Marketing tools churn — a client will switch campaign platforms at some point, and you want that to be a new selector, a new subdomain, and a suppression export, with the invoices entirely untouched. When both streams share one identity, changing anything about one of them is a change to all of your mail.
The honest costs
We are not going to pretend the split is free.
Two dashboards, two sets of credentials, two places to look. When someone asks "did the customer get the mail", the first question is now "which kind". In practice this is less painful than it sounds because the answer is nearly always transactional and that's one dashboard — but it is real, and it argues for putting both behind one support channel rather than two vendors who each blame the other.
The suppression sync is code you own. It's the worker above, it needs a dead-letter path, and it needs someone to notice when it stops. A shared list is genuinely simpler; it is simpler in the way that one database user for every application is simpler.
Two reputations to warm, not one. Splitting an existing sender means both subdomains start from a colder position than the combined one did. Plan for a warmup window rather than a cutover weekend, and move transactional first — it is high-engagement mail that builds reputation quickly.
Volume gets split across pricing tiers. If either platform prices by volume bands, two smaller streams can land worse than one larger one. Worth ten minutes with a calculator before you commit, and worth asking about explicitly.
Where one platform is genuinely fine
- Under a few thousand mails a month, all of it transactional. No campaigns, no list — there is nothing to separate. Send it well from one place and revisit when marketing asks for a newsletter.
- A newsletter that goes to a few hundred engaged subscribers. The queue argument needs volume to bite and the reputation argument needs complaints. Neither is likely here. Keep the streams on separate subdomains anyway; it costs one DNS record and it means the eventual split is a migration of one system rather than two.
- When there is genuinely no transactional mail. A brochure site with a contact form has one stream. Don't invent the other one.
- When nobody will own the suppression sync. A split with a broken sync is worse than a shared list, because the failure is silent in the direction that keeps mailing people who said no. If the team can't own it, don't build it.
The threshold we use in practice: split as soon as there is a real campaign programme — a list in the thousands, sending more than monthly, run by someone in marketing rather than someone in engineering. That combination is when the queue, the suppression list and the consent model all start mattering in the same quarter.
The short version
Reputation is the argument people know, and it is the least of it. One platform means one queue, so your campaign decides how long a password reset waits. It means one suppression list, so an unsubscribe from a newsletter can silently stop invoices, which you were legally required to send. And it means one consent model applied to two kinds of mail that have completely different legal bases and completely different retention needs.
Two platforms, two subdomains, two DKIM selectors, two suppression lists with a one-directional sync for hard bounces only. Transactional as the default outgoing server so an unpinned template fails safe. Consent records created on the marketing platform at the moment of opt-in, never copied. And a router in your application that makes "send this campaign to these addresses" an API that doesn't exist.
That is what we run for clients on transactional and mailing — on our own infrastructure in the EU or the US, on the same clusters everything else is hosted on, wired into the applications we build and the Odoo installations behind them. Two platforms is not redundancy. It is the mail that must arrive not being able to queue behind the mail that may.
Want us to publish something specific?
Tell us what you'd like to read and we'll add it to our writing queue.
Get the next one in your inbox
New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.