Blog

Transaktionale E-Mails serverless: SES und Lambda

Wie SES zum ereignisgetriebenen E-Mail-Baustein wird: ausgehende Bestätigungsmails aus einer Lambda-Funktion und eingehende Receipt Rules, die aus einer Mail einen Handler auslösen.

≈ 11 Min. Lesezeit

Diesen Beitrag anhören (16 Min.)

MP3 herunterladen

Ein SendEmail-Aufruf trägt bis zu 50 Empfänger und 10 MB Nachricht. Ein eingehendes SES-Event trägt null Byte Mailtext: Es liefert Header und Metadaten, der Body liegt in S3. Diese zwei Zahlen sagen mehr über den Dienst als jede Feature-Liste – die ausgehende Richtung ist in einer halben Stunde erledigt, die eingehende hat eigene Regeln, die man kennen muss.

Dahinter steckt eine Entscheidung, die im Team wie Konfiguration klingt: „Und wie schicken wir jetzt die Bestätigungsmail raus?“ Häufig steht da schon ein Signup-Flow, vielleicht mit einer User-Verwaltung wie in Serverless-Auth mit Cognito User Pools, und die Mail wirkt wie der letzte, triviale Baustein. Genau da liegt der Denkfehler. Die eigentliche Frage lautet: Behandle ich E-Mail als externen Nebeneffekt, den ich ans Ende eines Flows klebe, oder als vollwertiges Domänen-Ereignis – rein wie raus –, das dieselbe Behandlung verdient wie ein API-Aufruf oder eine Queue-Nachricht?

Diese Weichenstellung entscheidet, ob der E-Mail-Teil später sauber in die Architektur passt oder als Fremdkörper Reibung erzeugt. SES – der Simple Email Service – ist deshalb lehrreich, weil er beide Richtungen mit denselben Bausteinen bedient: Lambda auf der einen Seite, Receipt Rules auf der anderen. Der Hebel liegt nicht in den API-Feldern, sondern in dieser Symmetrie. Im Folgenden 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-Funktion 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-Funktion.

Diese Symmetrie ist der Kern. Sobald SES im Entwurf als ereignisgetriebener Baustein auftaucht, fügt er sich sauber in die übrige Serverless-Architektur ein. Bleibt er ein reiner „Mail-Versender“, hängt er hinterher außen dran. Die ausgehende Richtung ist dabei die häufigere und der einfachere Einstieg.

  • Ausgehend: SendEmail oder SendTemplatedEmail, 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.

E-Mail als Domänen-Ereignis, nicht als SES-Konfiguration

Der Unterschied klingt akademisch, hat aber handfeste Konsequenzen. Wer SES als Konfigurationsaufgabe begreift, hört bei „Absender verifiziert, SendEmail läuft“ auf. Wer E-Mail als Domänen-Ereignis modelliert, fragt weiter: Welches fachliche Ereignis löst diese Mail aus, und welches Ereignis erzeugt eine eintreffende Mail in meinem System? Aus „ich versende eine Nachricht“ wird „ein Signup ist passiert“, und aus „jemand hat gemailt“ wird „ein Support-Fall ist eingegangen“. Die Mail ist dann nur noch der Transport, nicht das Ereignis selbst.

Diese Perspektive kostet etwas und bringt etwas. Der Preis ist zusätzliche Sorgfalt an der Systemgrenze: verifizierte Identitäten, ein aktives Rule Set, korrekte Berechtigungen, das Wissen, dass eingehende Bodies in S3 liegen. Der Ertrag ist Anschlussfähigkeit. Ein Ereignis lässt sich weiterreichen, orchestrieren, protokollieren und testen – eine „verschickte Mail“ dagegen ist eine Sackgasse.

Für die Frage, ob SES der richtige Baustein ist, helfen ein paar Entscheidungskriterien:

  • Kopplung und Kontrolle: SES bleibt vollständig in AWS und IAM, ohne zweiten Anbieter und ohne zusätzlichen Vertrag. Dafür trägst du die regionale Einschränkung beim Empfang und das MIME-Parsing selbst.
  • Kosten: SES rechnet pro tausend Mails ab und verursacht im Leerlauf nichts. Ein spezialisierter Transaktions-Anbieter bringt bessere Zustellbarkeits-Werkzeuge und fertige Templates mit, aber eben auch eine eigene Rechnung und ein zweites System, das jemand betreiben muss.
  • Wirkung: Erst wenn die eingehende Mail ein echtes Ereignis ist, lässt sich die Nachbearbeitung automatisieren – Klassifizierung, Zuweisung, Antwortentwurf. Genau das verkürzt die Zyklen, die sonst an manueller Triage hängen.

Solange E-Mail eine Ereignisquelle und -senke innerhalb einer AWS-Architektur ist, ist SES die reibungsärmste Wahl. Sobald Marketing-Zustellbarkeit, Bounce-Analytics und Kampagnen-Tooling im Vordergrund stehen, ist ein dedizierter Anbieter ehrlicher. Für den transaktionalen Kern – Bestätigung raus, Ticket rein – gewinnt der ereignisgetriebene SES-Baustein.

Die ausgehende Bestätigungsmail

Fangen wir mit der ausgehenden Richtung an. Eine Lambda-Funktion 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. Das Event kommt rein, die Lambda-Funktion übersetzt es in einen SendEmail-Aufruf, SES nimmt die Nachricht an. Das ist noch keine Zustellgarantie: Bounces und Complaints laufen über eigene SES-Ereignisse. Wird das auslösende Event wiederholt, kann auch die Mail doppelt versendet werden. Für einen produktiven Flow brauche ich deshalb einen stabilen Ereignisschlüssel und einen gespeicherten Versandstatus oder eine Outbox.

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

Der Code ist selten die Stolperstelle. Es sind drei organisatorische Punkte, 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-Funktion, 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-Funktion 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-Funktion 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-Funktion, 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 Projekten 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-Funktion ü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-Funktion 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 liegt in der eingehenden Richtung: wenn klar wird, dass eintreffende Mail kein Sonderfall ist, sondern ein ganz normaler Ereignis-Trigger – 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 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.

Der Gewinn dieser Perspektive ist konkret. Kürzere Zyklen, weil ein eingehender Mail-Hook Nachbearbeitung anstößt, statt sie in einem Postfach zu parken. Weniger Reibung, weil E-Mail denselben ereignisgetriebenen Regeln folgt wie der Rest der Architektur und nicht als Fremdkörper danebensteht. Höhere Qualität, weil ein modelliertes Ereignis testbar, protokollierbar und orchestrierbar ist, wo eine lose verschickte Mail eine Sackgasse bleibt. Und klare Grenzen: Sobald Zustellbarkeit im großen Stil, Bounce-Analytics oder Kampagnen im Vordergrund stehen, ist ein spezialisierter Anbieter die ehrlichere Wahl – SES glänzt im transaktionalen Kern. Wer diese beiden Muster einmal sauber gebaut hat, denkt nicht mehr „wie verschicke ich eine Mail“, sondern „E-Mail ist einfach eine weitere Ereignisquelle und -senke“ – rein wie raus.

Weiterführende Quellen

Wie fandest du diesen Beitrag?

Kommentare