Ein Multi-Tenant-E-Mail-System für SaaS aufbauen: Transaktional, Marketing und Zustellbarkeit
Ein Multi-Tenant-E-Mail-System beginnt selten als System. Es beginnt als eine einzige Funktion: Jemand schreibt sendEmail($to, $subject, $body), verdrahtet sie mit dem Passwort-Reset-Flow und macht weiter. Sechs Monate später verschickt dieselbe Funktion Rechnungen, wöchentliche Digests, Erwähnungs-Benachrichtigungen und einen Marketing-Newsletter an 40.000 Adressen, alles von derselben Domain und derselben IP aus. Dann kommen an einem Montag die Passwort-Reset-Mails nicht mehr an, die Support-Tickets stapeln sich, und niemand kann erklären, warum ein funktionierendes Feature kaputtgegangen ist, ohne dass jemand es angefasst hat.
Was da kaputtgeht, ist der Sende-Ruf (Sender Reputation), und er geht kaputt, weil sich drei verschiedene Arten von E-Mail eine Identität geteilt haben. Ein Multi-Tenant-E-Mail-System für SaaS muss diese Arten von Anfang an trennen, sonst trennst du sie später unter Druck, während Kunden zusehen.
Drei Arten von E-Mail in einem Multi-Tenant-E-Mail-System
Der erste Fehler ist, "E-Mail" als ein einziges Anliegen zu behandeln. In der Praxis verschickt ein SaaS-Produkt drei Kategorien, die außer SMTP fast nichts gemeinsam haben.
Transaktionale E-Mail umfasst Passwort-Resets, Login-Codes, Belege und Kontobenachrichtigungen. Der Empfänger hat danach gefragt, oft vor wenigen Sekunden, und wartet darauf. Sie muss jedes Mal im Posteingang ankommen. Das Volumen ist niedrig und stoßweise, und ein Batching ist hier nie akzeptabel. Diese Mail verdient eine eigene Sende-Subdomain (etwa mail.deineapp.com oder tx.deineapp.com) mit eigenem IP-Ruf, damit nichts anderes, was du verschickst, sie mit runterziehen kann.
Marketing-E-Mail sind Newsletter, Drip-Sequenzen und Feature-Ankündigungen. Niemand wartet darauf. Sie geht in geplanten Batches an große Listen, sie erzeugt den Großteil deiner Spam-Beschwerden, und sie muss Abmeldeanfragen sofort und sichtbar berücksichtigen. Leg sie auf eine separate Sende-Domain (news.deineapp.com) und erwäge ernsthaft einen separaten Anbieter. Wenn deine Marketing-Liste einen schlechten Ruf bekommt, sollte dieser Ruf nicht auf die Domain überschwappen können, die Login-Codes zustellt.
Produktausgelöste Benachrichtigungen liegen dazwischen und sind die Kategorie, die die meisten Teams zu planen vergessen. Aktivitäts-Digests, "jemand hat dich erwähnt", Aufgabenzuweisungen, Kommentarantworten. Das ist bei weiten der volumenstärkste Mail-Typ, sobald du aktive Teams hast, und es ist der Typ, der Nutzer nervt, wenn er bei jedem Ereignis feuert. Er braucht Rate-Limiting und Digesting pro Nutzer sowie ein Preference-Center, denn "ich habe Benachrichtigungen ausgeschaltet und trotzdem zwölf E-Mails bekommen" ist ein Kündigungsgrund.
Sobald man akzeptiert, dass das drei Produkte sind statt einer Funktion, ergibt sich die Architektur größtenteils von selbst.
E-Mail aus dem Request-Zyklus herausnehmen
Die zweite strukturelle Entscheidung ist, dass kein Web-Request jemals direkt mit einem SMTP-Server sprechen sollte. Ein langsamer Mail-Anbieter wird sonst zu einem langsamen Checkout, und ein Ausfall des Anbieters wird zu gescheiterten Registrierungen. In Symfony ist das saubere Muster ein Event-Subscriber, der auf Domain-Events reagiert und eine Nachricht in eine Queue schickt.
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],
));
}
}
Der Handler auf der anderen Seite der Queue erledigt die eigentliche Arbeit: den Mandanten laden, das Branding auflösen, das Template rendern, den richtigen Transport wählen, versenden und das Ergebnis protokollieren. Symfony Messenger liefert dir bereits Retries mit Backoff und einen Failure-Transport, genau das, was du brauchst, wenn ein Anbieter zehn Minuten lang mit einem 4xx antwortet.
Verwende separate Transports (oder zumindest separate Queues) für die drei Kategorien. Ein Marketing-Versand von 40.000 Nachrichten sollte nicht vor einem Passwort-Reset in derselben Queue stehen. Transaktionale Mails bekommen einen eigenen Worker-Pool mit kleiner Queue, Benachrichtigungen einen größeren Pool mit Rate-Limiting, Marketing einen niedrig priorisierten Pool, der eine Stunde brauchen darf, ohne dass es jemand merkt.
Mandanten-Branding ohne ein Template pro Mandant
Jeder Mandant will sein eigenes Logo, seine Farben, seinen Absendernamen und seine Antwortadresse in den E-Mails, die seine Nutzer erhalten. Die Versuchung ist, Mandanten komplette Templates hochladen zu lassen. Tu das nicht. Es macht das Rendern unsicher, bricht bei jeder Layoutänderung, und Support wird zum Template-Debugging-Service.
Behalte einen Satz Templates pro E-Mail-Typ, den du selbst besitzt, und parametrisiere ihn mit einem kleinen Branding-Objekt pro Mandant:
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,
) {}
}
Der Renderer bekommt das Branding, das Template und die Payload und erzeugt daraus die Nachricht. Mandanten können die Werte über einen Einstellungsbildschirm ändern, das Layout bleibt deins. Wenn ein Mandant eine eigene Absendeadresse will (noreply@ihredomain.de statt noreply@deineapp.com), ist das ein separates Feature mit eigenen DNS-Anforderungen, womit wir beim Signieren wären.
DKIM und SPF pro Sende-Domain
Wenn du von mehr als einer Domain versendest, braucht jede Domain ihren eigenen SPF-Eintrag und ihr eigenes DKIM-Schlüsselpaar. Mailbox-Anbieter prüfen, ob die From-Domain, die DKIM-Signatur und der Return-Path übereinstimmen (dieses Alignment ist es, was DMARC bewertet), und eine fehlende Übereinstimmung ist einer der schnellsten Wege in den Spam-Ordner.
Für deine eigenen Subdomains ist das ein einmaliges DNS-Setup. Für Mandanten, die von ihrer eigenen Domain versenden wollen, brauchst du einen Verifizierungs-Flow: ein DKIM-Schlüsselpaar für den Mandanten generieren, ihm die anzulegenden CNAME- oder TXT-Einträge zeigen, DNS abfragen, bis die Einträge auflösen, und erst dann Versand von dieser Domain erlauben. Bis die Verifizierung besteht, fällt man auf die geteilte Subdomain mit dem Namen des Mandanten im Absendernamen zurück. Speichere den Verifizierungsstatus am Sende-Domain-Datensatz und prüfe ihn regelmäßig erneut, denn Kunden löschen DNS-Einträge durchaus mal aus Versehen.
In Symfony Mailer ist DKIM-Signierung über DkimSigner verfügbar. Lade den privaten Schlüssel pro Sende-Domain aus deinem Key-Store, signiere die Nachricht im Queue-Handler, und lass einen Signierschlüssel eines Mandanten niemals in die Nähe der Nachrichten eines anderen Mandanten kommen.
Die Bounce- und Beschwerde-Pipeline
Versenden ist die halbe Miete. Die andere Hälfte ist, auf das zu hören, was zurückkommt, denn der Sende-Ruf hängt größtenteils davon ab, wie du auf Bounces und Beschwerden reagierst.
Jeder ernstzunehmende Anbieter (Amazon SES, Postmark, SendGrid, Mailgun, Brevo) postet Zustell-Events an einen Webhook: delivered, bounced, complained, opened, clicked. Dein Webhook-Endpunkt sollte genau eine Sache tun: die Signatur prüfen, das Rohereignis in eine Tabelle schreiben und mit 200 antworten. Die Verarbeitung passiert später aus einer Queue heraus, damit ein Schwall von 5.000 Bounce-Events nach einem schlechten Listenimport den Endpunkt nicht zum Timeout bringt und Daten verliert.
Die Verarbeitungsregeln sind einfach. Der schwierige Teil ist, sie in jedem Modul durchzusetzen, denn meist ist das Marketing-Modul der einzige Ort, an dem sich jemand daran erinnert, sie zu prüfen:
Ein Hard Bounce (Adresse existiert nicht) sperrt die Adresse sofort für alle Mail-Typen über den gesamten Mandanten hinweg. Ein Soft Bounce (Postfach voll, vorübergehender Fehler) zählt auf einen Schwellenwert, etwa drei innerhalb von sieben Tagen, und sperrt dann. Eine Spam-Beschwerde sperrt die Adresse für Marketing und Benachrichtigungen, und du solltest genau prüfen, ob die transaktionale Mail, die sie ausgelöst hat, wirklich transaktional war. Eine Abmeldung sperrt nur für Marketing, außer der Nutzer hat auch Benachrichtigungen in seinen Einstellungen ausgeschaltet.
Die Sperrliste wird vor jedem Versand geprüft, im Queue-Handler, nicht im Code, der entscheidet zu versenden. So kann selbst ein neues Feature, das die Prüfung vergisst, keine gebouncte Adresse anschreiben.
Das Preference-Center
Das Benachrichtigungsvolumen ist das, was Nutzer die E-Mails deines Produkts hassen lässt. Die Lösung ist ein Preference-Center, das granular genug ist, um nützlich zu sein, und einfach genug, um tatsächlich genutzt zu werden.
Modelliere es als Tabelle von Einstellungen pro Nutzer und Benachrichtigungstyp, mit Kanal und Frequenz: mention per E-Mail, sofort; task_assigned per E-Mail, tägliches Digest; comment_reply aus. Ergänze Mandanten-Standardwerte, damit ein Admin festlegen kann, was neue Nutzer bekommen, und gib Nutzern in jeder Benachrichtigungs-Mail einen Einklick-Link, der sie direkt und bereits über ein signiertes Token authentifiziert auf die Einstellungsseite bringt. Marketing-Mails bekommen eine einfache, nicht authentifizierte Einklick-Abmeldung, die ohne Laden deiner App funktioniert, plus den List-Unsubscribe-Header oben drauf, denn Gmail und Yahoo verlangen ihn für Massenversender.
Beim Digesting zahlt sich das Queue-Design erneut aus. Statt bei jedem Ereignis zu versenden, schreibt der Benachrichtigungs-Handler in eine Tabelle ausstehender Benachrichtigungen. Ein geplanter Befehl läuft pro Nutzer und Frequenzfenster, sammelt was ansteht, rendert ein Digest und markiert die Einträge als versendet. Nutzer, die "sofort" gewählt haben, überspringen die Tabelle und gehen direkt den transaktionsähnlichen Pfad mit einem Rate-Limit pro Nutzer, damit ein aktives Projekt nicht 80 E-Mails in einer Minute erzeugt.
Das Datenmodell, das alles zusammenhält
Um das über Mandanten hinweg zu betreiben, musst du "was haben wir dieser Person geschickt, und was ist damit passiert" beantworten können, ohne Anbieter-Dashboards zu lesen. Die Kerntabellen sehen so aus:
email_message enthält eine Zeile pro versuchter Nachricht: Mandant, Empfänger, Kategorie (transaktional, Benachrichtigung, Marketing), Template, Sende-Domain, Provider-Message-ID und einen Status, der von queued über sent zu delivered, bounced oder complained wandert.
email_event speichert jedes eingehende Webhook-Event, verknüpft über die Provider-ID mit der Nachricht. Behalte die Rohdaten; du wirst sie brauchen, sobald ein Anbieter sein Format das erste Mal ändert.
email_suppression ist die Liste der Adressen, an die du nicht versendest, mit Grund, Umfang (alle Mails oder nur Marketing), Mandant und Zeitpunkt der Aufnahme.
sending_domain verzeichnet jede Domain, von der ein Mandant versendet, ihren DKIM-Selektor und Schlüsselverweis sowie ihren Verifizierungsstatus.
notification_preference ist die oben beschriebene Einstellung pro Nutzer und Typ.
Mit diesen Tabellen kann ein Support-Mitarbeiter einen Nutzer nachschlagen, sehen, dass dessen Rechnung am Dienstag gebounct ist, weil das Postfach voll war, und ihm das mitteilen. Diese eine Fähigkeit spart mehr Support-Stunden als jeder andere Teil des Systems.
Wo Teams das typischerweise falsch machen
Der häufigste Fehler, den wir in Code-Audits sehen, ist ein einziges MAILER_DSN, eine einzige Absenderadresse, und Marketing- und transaktionale Mail, die sich alles teilen. Der zweithäufigste ist ein Webhook, der Events synchron verarbeitet und unter Last still Daten verliert. Der dritte ist eine Sperrlisten-Prüfung, die nur im Newsletter-Modul lebt, sodass das Produkt munter weiter an Adressen mailt, die vor Wochen gebounct sind.
Nichts davon sind exotische Bugs. Sie sind das natürliche Ergebnis davon, dass E-Mail als Utility-Funktion begann und wuchs, ohne je einen Architektur-Durchgang zu bekommen. Wenn dein Produkt an dem Punkt ist, an dem E-Mail zu einem Support-Thema wird, lohnt sich ein Nachmittag, um die drei Kategorien auf ein Whiteboard zu zeichnen und zu sehen, wie weit dein aktueller Code davon entfernt ist, sie auseinanderzuhalten. Das ist ein guter Kandidat für ein gezieltes Code-Review, bevor Zustellbarkeit zu einem kundensichtbaren Vorfall wird, oder für einen abgegrenzten Neubau im Rahmen individueller Softwareentwicklung, falls das aktuelle Setup nicht mehr zu flicken ist.
Wenn du eine zweite Meinung zu deiner E-Mail-Architektur willst, oder ein Multi-Tenant-SaaS planst und das von Anfang an richtig machen willst, bevor der erste Mandant sich anmeldet, schreib an hello@wolf-tech.io oder besuch wolf-tech.io.

