Transaktionale E-Mails serverless: SES und Lambda
Wie SES zum ereignisgetriebenen E-Mail-Baustein wird: ausgehende Bestätigungsmails aus einer Lambda und eingehende Receipt Rules, die aus einer Mail einen Handler auslösen.
In meinen Serverless-Schulungen kommt irgendwann fast immer die gleiche Frage: „Und wie schicken wir jetzt die Bestätigungsmail raus?“ Meist ist da schon ein Signup-Flow gebaut, vielleicht mit einer User-Verwaltung wie in Serverless-Auth mit Cognito User Pools, und der Wunsch nach einer transaktionalen Mail steht plötzlich als letzter Baustein im Raum. Genau an dieser Stelle finde ich SES – den Simple Email Service – besonders lehrreich, weil er zwei Denkweisen zusammenbringt, die im Serverless-Kontext ohnehin zentral sind: ereignisgetrieben und zustandslos.
Was viele zunächst überrascht: SES ist keine Einbahnstraße. Der Dienst kann Mails versenden, aber genauso gut eintreffende Mails als Auslöser für eine Lambda verwenden. In diesem Artikel gehe ich beide Richtungen durch – zuerst die ausgehende Bestätigungsmail aus einer Funktion, danach den eingehenden Mail-Hook, der aus einer echten E-Mail zum Beispiel ein Support-Ticket macht. Der Code ist bewusst schlank gehalten, damit der rote Faden – Ereignis rein, Ereignis raus – sichtbar bleibt.
SES als Baustein in zwei Richtungen
Bevor wir Code schreiben, lohnt sich ein Blick auf das mentale Modell. SES sitzt an der Systemgrenze zwischen deiner Anwendung und der Außenwelt der E-Mail. Nach außen: Deine App löst ein Ereignis aus – ein neuer Account, eine bezahlte Bestellung – und eine Lambda ruft SendEmail auf. Nach innen: Jemand schickt eine Mail an eine deiner Adressen, und SES verarbeitet sie über sogenannte Receipt Rules weiter, etwa an S3, an SNS oder direkt an eine Lambda.
Diese Symmetrie ist der Kern. Sobald man SES als ereignisgetriebenen Baustein begreift und nicht als reinen „Mail-Versender“, fügt er sich sauber in die übrige Serverless-Architektur ein. Die ausgehende Richtung ist dabei die häufigere und der einfachere Einstieg.
- Ausgehend:
SendEmailoderSendTemplatedEmail, ein verifizierter Absender, Sandbox und Quota im Blick, ausgelöst durch ein App-Event. - Eingehend: ein Receipt Rule Set mit Actions für S3, Lambda oder SNS, nur in bestimmten Regionen verfügbar, ausgelöst durch eine eintreffende Mail, Body über S3 nachgeladen.
Halten wir diese beiden Zeilen im Kopf – sie strukturieren den Rest des Artikels.
Die ausgehende Bestätigungsmail
Fangen wir mit dem Fall an, den fast jedes Projekt braucht. Eine Lambda wird durch ein Ereignis ausgelöst – sagen wir, ein API-Gateway-Aufruf nach erfolgreicher Registrierung – und soll eine kurze Bestätigungsmail verschicken.
Das folgende Diagramm zeigt den Weg des Ereignisses. Nichts davon ist langlebig oder zustandsbehaftet: Das Event kommt rein, die Lambda übersetzt es in einen SendEmail-Aufruf, SES kümmert sich um die Zustellung.
flowchart LR A[Event: Signup] --> B[Lambda: sendConfirmationEmail] B --> C[ses:SendEmail] C --> D[Recipient Inbox]
Der zugehörige Handler ist überschaubar. Ich nutze hier das AWS SDK für JavaScript in der Version 3, also die modularen @aws-sdk/client-ses-Pakete – die etablierte Version 2 mit new AWS.SES() funktioniert genauso, ist mir für neue Funktionen aber schlicht zu monolithisch im Import.
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
const ses = new SESClient({ region: "eu-west-1" });
export async function sendConfirmationEmail(event: {
email: string;
name: string;
}) {
const command = new SendEmailCommand({
Source: "no-reply@example.com",
Destination: {
ToAddresses: [event.email],
},
Message: {
Subject: {
Data: "Willkommen bei uns",
Charset: "UTF-8",
},
Body: {
Html: {
Data: `<p>Hallo ${event.name}, dein Konto ist aktiv.</p>`,
Charset: "UTF-8",
},
},
},
});
const result = await ses.send(command);
return { messageId: result.MessageId };
}
Drei Dinge sind hier verpflichtend und deshalb einen genaueren Blick wert. Source ist die Absender-Adresse und muss eine 7-bit-ASCII-Adresse einer verifizierten Identität sein. Destination bündelt die Empfänger nach ToAddresses, CcAddresses und BccAddresses. Message trägt Subject und Body, wobei der Body Text und HTML enthalten kann, jeweils als Objekt aus Data und Charset. Zurück kommt eine MessageId – die einzige Quittung, die du für den erfolgreichen Aufruf bekommst. Optional lässt sich noch einiges ergänzen, etwa ReplyToAddresses oder ein ConfigurationSetName, aber für die Bestätigungsmail reicht das Gezeigte.
Verifizierte Absender, Sandbox und Quota
Genau hier stolpern in meinen Schulungen die meisten – nicht am Code, sondern an drei organisatorischen Punkten, die man vorher klären muss.
Erstens die Absender-Identität. SES verschickt nur von verifizierten Absendern. Du kannst eine einzelne Adresse verifizieren oder – für die Produktion deutlich sinnvoller – eine ganze Domain über DKIM- und TXT-Einträge im DNS. Ist der Absender nicht verifiziert, quittiert SES den Aufruf mit „Email address not verified“, und die Mail geht nicht raus.
Zweitens die Sandbox. Jeder neue SES-Account startet in der Sandbox. Das bedeutet: Du darfst nur an verifizierte Empfänger senden beziehungsweise an den SES Mailbox Simulator, und die Sending-Quota ist reduziert. Die Bestätigungsmail an einen echten Kunden schlägt in der Sandbox also fehl, solange dessen Adresse nicht selbst verifiziert ist. Für den Produktionsbetrieb muss man den Produktionszugang per Support-Case beantragen.
Drittens die Quota. Jeder Empfänger einer Nachricht zählt gegen dein Sending-Kontingent. Ein SendEmail-Aufruf darf maximal 50 Empfänger über To, Cc und Bcc zusammen adressieren und die Nachricht bis zu 10 MB groß sein.
Für Demos und Tests ist der Mailbox Simulator Gold wert. Ein Aufruf an success@simulator.amazonses.com verhält sich wie eine erfolgreiche Zustellung, ohne dass du in der Sandbox anecken oder echte Postfächer belasten musst.
Templates statt String-Konkatenation
Sobald mehr als eine Mail-Art im Spiel ist, wird das Zusammenbauen von HTML im Code unübersichtlich. SES bietet dafür serverseitige Templates. Man legt einmalig ein Template mit CreateTemplate an – mit SubjectPart, TextPart und HtmlPart, in denen Platzhalter in doppelten geschweiften Klammern stehen – und verschickt dann mit SendTemplatedEmail.
import { SESClient, SendTemplatedEmailCommand } from "@aws-sdk/client-ses";
const ses = new SESClient({ region: "eu-west-1" });
export async function sendWelcome(email: string, name: string) {
const command = new SendTemplatedEmailCommand({
Source: "no-reply@example.com",
Destination: { ToAddresses: [email] },
Template: "WelcomeTemplate",
TemplateData: JSON.stringify({ name }),
});
return ses.send(command);
}
TemplateData ist ein JSON-String, dessen Schlüssel die Platzhalter im Template füllen. Der Vorteil: Der Text steht nicht mehr im Deployment-Artefakt, sondern liegt als Ressource bei SES und lässt sich unabhängig vom Code aktualisieren. Für viele Empfänger auf einmal gibt es außerdem SendBulkTemplatedEmail – im transaktionalen Kontext braucht man das aber selten, weil dort meist eine Mail an eine Person geht.
Der eingehende Mail-Hook
Jetzt drehen wir die Richtung um. Die zweite, oft übersehene Fähigkeit von SES: Es kann eintreffende E-Mails empfangen und daraus ein Ereignis in deiner Architektur machen. Das ist die Grundlage für Muster wie „aus einer Mail an support@ wird automatisch ein Ticket“.
Verwaltet wird das über ein Receipt Rule Set. Es gibt pro Account mehrere Rule Sets, aber immer nur eines ist aktiv. Innerhalb eines Rule Sets definierst du einzelne Receipt Rules, die auf bestimmte Empfänger-Adressen matchen und eine Liste von Actions abarbeiten. Als Actions stehen unter anderem zur Verfügung: die Ablage in einem S3-Bucket, das Publizieren an ein SNS-Topic, das Aufrufen einer Lambda, das Hinzufügen eines Headers, ein Bounce oder ein Stop.
Der übliche Aufbau für einen Ticket-Flow kombiniert zwei Actions: Erst wird die Roh-Mail nach S3 geschrieben, dann eine Lambda ausgelöst, die sich um die Verarbeitung kümmert.
flowchart LR A[Incoming Mail] --> B[SES Receipt Rule] B --> C[S3: raw MIME] B --> D[Lambda: handleInboundEmail] D --> E[Ticket / DB] B -.-> F[SNS Notification]
Die Receipt Rule als Infrastruktur
Die Receipt Rule selbst gehört in Infrastructure-as-Code, nicht in ein manuell geklicktes Konsolen-Setup – sonst ist sie nach dem nächsten Account-Wechsel weg. Als Skizze in CloudFormation-naher Form sieht eine Regel etwa so aus:
Resources:
SupportRule:
Type: AWS::SES::ReceiptRule
Properties:
RuleSetName: default-rule-set
Rule:
Name: support-inbound
Enabled: true
Recipients:
- support@example.com
Actions:
- S3Action:
BucketName: inbound-mail-bucket
- LambdaAction:
FunctionArn: !GetAtt InboundFn.Arn
InvocationType: Event
Zwei Details sind hier wichtig. Die Reihenfolge der Actions zählt: Erst muss die Mail in S3 liegen, dann wird die Lambda gerufen, die sie von dort liest. Und InvocationType: Event bedeutet asynchroner Aufruf – das ist die empfohlene Variante. Der synchrone RequestResponse-Aufruf existiert ebenfalls, hat aber ein hartes Timeout von 30 Sekunden und erlaubt der Lambda, den weiteren Regelverlauf per Rückgabe zu steuern, etwa mit { disposition: "STOP_RULE" }. Für einen Ticket-Handler, der ohnehin nichts am Mailfluss steuern will, ist Event die richtige Wahl.
Der Handler und der häufigste Irrtum
Kommen wir zum Handler – und zum Fallstrick, der in meinen Kursen am zuverlässigsten zuschlägt. Die naheliegende Annahme ist, dass das Lambda-Event den Mailtext enthält. Das tut es nicht. Das Event, das SES an die Lambda übergibt, trägt nur Metadaten und Header – Absender, Betreff, Message-ID –, aber nicht den Body. Der komplette MIME-Inhalt landet in S3, und von dort muss der Handler ihn nachladen.
Das Event ist um ein Records-Array herum aufgebaut, mit eventSource: "aws:ses" und den Feldern ses.receipt sowie ses.mail. Letzteres enthält unter commonHeaders die aufbereiteten Header und unter messageId den Schlüssel, unter dem die Roh-Mail in S3 liegt.
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { Readable } from "stream";
const s3 = new S3Client({ region: "eu-west-1" });
async function streamToString(stream: Readable): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf-8");
}
export async function handleInboundEmail(event: any) {
const record = event.Records[0];
const mail = record.ses.mail;
const messageId = mail.messageId;
const raw = await s3.send(
new GetObjectCommand({
Bucket: "inbound-mail-bucket",
Key: messageId,
})
);
const mime = await streamToString(raw.Body as Readable);
// parse MIME, then create a support ticket
await createTicket({
from: mail.commonHeaders.from?.[0],
subject: mail.commonHeaders.subject,
body: mime,
});
return { status: "processed" };
}
Die eigentliche MIME-Zerlegung habe ich hier bewusst ausgespart – dafür nimmt man eine Bibliothek, denn multipart-Mails mit Anhängen von Hand zu parsen ist keine gute Idee. Der Punkt ist der Ablauf: Header aus dem Event, Body aus S3, dann die fachliche Aktion. Das S3-Objekt darf dabei bis zu 40 MB groß sein.
Berechtigungen und DNS, sonst passiert stumm nichts
Der zweite Fallstrick beim Empfang ist unspektakulär und deshalb tückisch: Ohne die richtigen Berechtigungen verarbeitet SES eintreffende Mail einfach stillschweigend nicht. SES braucht eine Resource-Policy, die ihm erlaubt, in den S3-Bucket zu schreiben, an das SNS-Topic zu publizieren oder die Lambda aufzurufen. Fehlt eine dieser Erlaubnisse, gibt es keine laute Fehlermeldung – die Mail versickert.
Dazu kommt DNS. Damit Mail überhaupt bei SES ankommt, müssen die MX-Einträge deiner Domain auf inbound-smtp.<region>.amazonaws.com zeigen. Und es gibt eine harte regionale Einschränkung: E-Mail-Empfang bietet SES nur in ausgewählten Regionen an – Stand heute us-east-1, us-west-2 und eu-west-1. Lambda und SNS für den eingehenden Flow müssen in derselben Region wie der SES-Endpoint liegen. Wer seine übrige Infrastruktur in einer anderen Region betreibt, muss diesen Teil bewusst dorthin ziehen.
Wo SES in die größere Architektur passt
Die beiden Richtungen ergeben zusammen ein hübsches Bild: Lambda ist der gemeinsame Rechenkern, SES die Systemgrenze zur E-Mail-Welt. Nach außen übersetzt eine Funktion App-Ereignisse in Mails, nach innen übersetzt SES eintreffende Mails in Funktionsaufrufe.
Interessant wird es, wenn der eingehende Hook nicht das Ende, sondern der Anfang einer längeren Verarbeitung ist. Eine Support-Mail, die geparst wird, kann eine Kette von Schritten anstoßen – Klassifizierung, Zuweisung, Antwortentwurf. Genau dafür lohnt sich der Blick auf eine Orchestrierung, wie ich sie in Serverless-Workflows mit AWS Step Functions beschrieben habe: Die SES-Lambda legt das Ticket an und übergibt an eine State Machine, die den Rest koordiniert. So bleibt der Mail-Hook klein und macht genau eine Sache, während die Fachlogik ihren eigenen, nachvollziehbaren Ablauf bekommt.
Fazit
Der Aha-Moment bei SES kommt für die meisten in meinen Schulungen an derselben Stelle: wenn klar wird, dass eingehende Mail kein Sonderfall, sondern ein ganz normaler Ereignis-Trigger ist – genauso wie ein API-Aufruf oder eine Queue-Nachricht. Ausgehend ist der Dienst schnell erklärt: verifizierter Absender, SendEmail oder ein Template, Sandbox und Quota im Kopf behalten. Eingehend braucht ein bisschen mehr Sorgfalt – ein aktives Rule Set, die richtigen Berechtigungen, MX-Einträge und das Wissen, dass der Body in S3 liegt und nicht im Event.
Wer diese beiden Muster einmal sauber gebaut hat, hat einen ereignisgetriebenen E-Mail-Baustein, der sich nahtlos in die übrige Serverless-Landschaft einfügt. Und das ist am Ende der eigentliche Gewinn: Nicht „wie verschicke ich eine Mail“, sondern „E-Mail ist einfach eine weitere Ereignisquelle und -senke“ – rein wie raus.
Weiterführende Quellen
- Repository:
introduction-aws, Kapitelses.mdundexamples/email-hook - SendEmail (SES API Reference)
- SendTemplatedEmail (SES API Reference)
- Verifizierte Identitäten anlegen
- E-Mail-Empfang – Konzepte
- Invoke-Lambda-Action
- Beispiel für ein eingehendes Mail-Event
- Deliver-to-S3-Action
- Berechtigungen für den E-Mail-Empfang
Kommentare
Kommentar schreiben