Building a Multi-Tenant Email System for SaaS: Transactional, Marketing, and Deliverability
A multi-tenant email system rarely starts as a system. It starts as one function: somebody writes sendEmail($to, $subject, $body), wires it to the password reset flow, and moves on. Six months later the same function sends invoices, weekly digests, mention alerts, and a marketing newsletter to 40,000 addresses, all from the same domain and the same IP. Then one Monday the password resets stop arriving, support tickets pile up, and nobody can explain why a working feature broke without anyone touching it.
What broke is sender reputation, and it broke because three different kinds of email were sharing one identity. A multi-tenant email system for SaaS has to keep those kinds apart from day one, or you end up separating them later under pressure, with customers watching.
Three kinds of email in a multi-tenant email system
The first mistake is treating "email" as a single concern. In practice, a SaaS product sends three categories that have almost nothing in common besides SMTP.
Transactional email covers password resets, login codes, receipts, and account notices. The recipient asked for it, often seconds ago, and is waiting for it. It has to reach the inbox every time. Volume is low and bursty, and batching it is never acceptable. This mail deserves its own sending subdomain (something like mail.yourapp.com or tx.yourapp.com) with its own IP reputation, so nothing else you send can drag it down.
Marketing email is newsletters, drip sequences, and feature announcements. Nobody is waiting for it. It goes to large lists in scheduled batches, it generates most of your spam complaints, and it has to honor unsubscribe requests immediately and visibly. Put it on a separate sending domain (news.yourapp.com), and seriously consider a separate provider. If your marketing list gets a bad reputation, that reputation should not be able to touch the domain that delivers login codes.
Product-triggered notifications sit in between and are the category most teams forget to plan for. Activity digests, "someone mentioned you", task assignments, comment replies. This is by far your highest volume mail once you have active teams, and it is the one that annoys users if it fires on every event. It needs rate limiting and digesting per user, and it needs a preference center, because "I turned off notifications and still got twelve emails" is a churn reason.
Once you accept these are three products rather than one function, the architecture mostly follows from that.
Get email out of the request cycle
The second structural decision is that no web request should ever talk to an SMTP server. A slow mail provider turns into a slow checkout, and a provider outage turns into failed signups. In Symfony, the clean pattern is an event subscriber that reacts to domain events and dispatches a message to a queue.
final class UserRegisteredSubscriber implements EventSubscriberInterface
{
public function __construct(private MessageBusInterface $bus) {}
public static function getSubscribedEvents(): array
{
return [UserRegistered::class => 'onUserRegistered'];
}
public function onUserRegistered(UserRegistered $event): void
{
$this->bus->dispatch(new SendTransactionalEmail(
tenantId: $event->tenantId,
template: 'welcome',
recipientId: $event->userId,
payload: ['activationToken' => $event->activationToken],
));
}
}
The handler on the other side of the queue does the real work: load the tenant, resolve branding, render the template, pick the right transport, send, and record the outcome. Symfony Messenger already gives you retries with backoff and a failure transport, which is exactly what you want when a provider returns a 4xx for ten minutes.
Use separate transports (or at least separate queues) for the three categories. A marketing blast of 40,000 messages should not sit in front of a password reset in the same queue. Transactional gets its own worker pool with a small queue; notifications get a larger pool with rate limiting; marketing gets a low-priority pool that can take an hour without anybody noticing.
Tenant branding without a template per tenant
Every tenant wants their own logo, colors, from-name, and reply-to address in the mail their users receive. The temptation is to let tenants upload full templates. Do not do that. It makes rendering unsafe, breaks every time you change the layout, and turns support into a template debugging service.
Keep one set of templates per email type, owned by you, and parameterize them with a small tenant branding object:
final class TenantEmailBranding
{
public function __construct(
public readonly string $fromName,
public readonly string $fromAddress,
public readonly ?string $replyTo,
public readonly ?string $logoUrl,
public readonly string $accentColor,
public readonly ?string $footerText,
) {}
}
The renderer gets the branding, the template, and the payload, and produces the message. Tenants can change the values in a settings screen; the layout stays yours. If a tenant wants a custom sending address (noreply@theirdomain.com instead of noreply@yourapp.com), that is a separate feature with its own DNS requirements, which brings us to signing.
DKIM and SPF per sending domain
If you send from more than one domain, each domain needs its own SPF record and its own DKIM key pair. Mailbox providers check that the From domain, the DKIM signature, and the return path line up (that alignment is what DMARC evaluates), and misalignment is one of the fastest ways to end up in spam.
For your own subdomains this is a one-time DNS setup. For tenants who want to send from their own domain, you need a verification flow: generate a DKIM key pair for the tenant, show them the CNAME or TXT records to add, poll DNS until the records resolve, and only then allow sends from that domain. Until verification passes, fall back to your shared subdomain with the tenant's name in the from-name. Store the verification state on the sending domain record, and re-check it periodically, because customers do delete DNS records by accident.
In Symfony Mailer, DKIM signing is available through DkimSigner. Load the private key per sending domain from your key store, sign the message in the queue handler, and never let a signing key for one tenant near another tenant's messages.
The bounce and complaint pipeline
Sending is half the system. The other half is listening to what comes back, because sender reputation is mostly a function of how you react to bounces and complaints.
Every serious provider (Amazon SES, Postmark, SendGrid, Mailgun, Brevo) posts delivery events to a webhook: delivered, bounced, complained, opened, clicked. Your webhook endpoint should do one thing: verify the signature, write the raw event to a table, and return 200. Processing happens later from a queue, so that a burst of 5,000 bounce events after a bad list import does not time out the endpoint and lose data.
The processing rules are simple. The hard part is enforcing them in every module, because the marketing module is usually the only place anyone remembers to check:
A hard bounce (address does not exist) suppresses the address for all mail types across the tenant, immediately. A soft bounce (mailbox full, temporary failure) counts toward a threshold, say three within seven days, and then suppresses. A spam complaint suppresses the address for marketing and notifications, and you should look hard at whether the transactional mail that triggered it was actually transactional. An unsubscribe suppresses for marketing only, unless the user also turned off notifications in their preferences.
The suppression list is checked before every send, in the queue handler, not in the code that decides to send. That way a new feature that forgets to check still cannot email a bounced address.
The preference center
Notification volume is the thing that makes users hate your product's email. The fix is a preference center that is granular enough to be useful and simple enough to be used.
Model it as a table of preferences per user per notification type, with a channel and a frequency: mention via email, immediately; task_assigned via email, daily digest; comment_reply off. Add tenant-level defaults so an admin can set what new users get, and give users a one-click link in every notification email that lands them directly on the preference page, already authenticated via a signed token. Marketing mail gets a plain, unauthenticated one-click unsubscribe that works without loading your app, and the List-Unsubscribe header on top, because Gmail and Yahoo require it for bulk senders.
Digesting is where the queue design pays off again. Instead of sending on every event, the notification handler writes to a pending-notifications table. A scheduled command runs per user per frequency window, collects what is pending, renders one digest, and marks the items sent. Users who chose "immediately" skip the table and go straight to the transactional-style path with a per-user rate limit, so that a busy project does not produce 80 emails in a minute.
The data model that ties it together
To operate this across tenants you need to be able to answer "what did we send this person, and what happened to it" without reading provider dashboards. The core tables look like this:
email_message holds one row per message you attempted: tenant, recipient, category (transactional, notification, marketing), template, sending domain, provider message id, and a status that moves from queued through sent to delivered, bounced, or complained.
email_event stores every webhook event as received, linked to the message by provider id. Keep the raw payload; you will need it the first time a provider changes its format.
email_suppression is the list of addresses you will not send to, with the reason, the scope (all mail or marketing only), the tenant, and when it was added.
sending_domain records each domain a tenant sends from, its DKIM selector and key reference, and its verification status.
notification_preference is the per-user, per-type settings described above.
With these in place, a support agent can look up a user, see that their invoice bounced on Tuesday because their mailbox was full, and tell them so. That single capability saves more support hours than any other part of the system.
Where teams usually get this wrong
The most common failure we see in code audits is a single MAILER_DSN, a single from-address, and marketing and transactional mail sharing everything. The second most common is a webhook that processes events synchronously and silently drops them under load. The third is a suppression check that lives in the newsletter module only, so the product happily keeps emailing addresses that bounced weeks ago.
None of these are exotic bugs. They are the natural result of email starting as a utility function and growing without a design pass. If your product is at the stage where email is becoming a support topic, it is worth an afternoon to draw the three categories on a whiteboard and see how far your current code is from keeping them apart. It is a good candidate for a focused code review before deliverability becomes a customer-facing incident, or for a scoped rebuild as part of custom software development if the current setup is past patching.
If you want a second opinion on your email architecture, or you are planning a multi-tenant SaaS and want to get this right before the first tenant signs up, write to hello@wolf-tech.io or visit wolf-tech.io.

