AWS SES für ausgehende, Bounce- und eingehende E-Mails konfigurieren

Ich möchte die Konfiguration teilen, die ich ausgearbeitet habe, um AWS SES für ausgehende, abgelehnte (Bounce) und eingehende E-Mails zu nutzen. Der SES-Dienst hat durchaus seine Tücken, und es hat viel Ausprobieren gebraucht, um genau zu verstehen, wie er funktioniert. Dies ist eher ein Brain-Dump als eine Schritt-für-Schritt-Anleitung. Es sollte eigentlich nicht nötig sein, aber: Nutzung auf eigenes Risiko. Und lies auf keinen Fall niemals Code von anderen ungeprüft, den du implementierst.

Hintergrund:

Ich arbeite daran, Discourse in AWS zu deployen und so viele ihrer Dienste wie möglich zu nutzen, um Zuverlässigkeit und Redundanz sicherzustellen. Als Entwickler bin ich mit der Kommandozeile und Code wohler und wollte IaC-Automatisierung verwenden. Meine gesamte Umgebung wird mit Terraform deployed, aber ich habe versucht, im Web-Console durchzuklicken und die Dinge so gut wie möglich zu ordnen. IAM und Policy-Dokumente liegen außerhalb des Rahmens dieses Beitrags, aber ich habe an den Stellen hingewiesen, an denen sie benötigt werden.

Eine Postfix-Instanz zu betreiben, scheint für eine einzelne Anwendung übertrieben. Eine POP3-Postfachnutzung ist so sehr in den 90ern. Also bin ich dem AWS-Kaninchenbau gefolgt.

Ich habe einige extrem nützliche Beiträge gefunden, die mir bei meiner Suche geholfen haben:

Der mail-receiver-Container hat mir auch geholfen zu verstehen, wie Discourse Nachrichten verarbeitet:

Anfangs hatte ich erwartet, dass der AWS-Webhook-Endpoint eingehende Nachrichten verarbeiten würde, aber nach dem Durcharbeiten des Codes stellte sich heraus, dass dies nicht der Fall ist. Ich habe meinen Lambda-Empfängercode auf dem hervorragenden Beispiel von @dltj basierend. Ich habe mich entschieden, für die Zustellung von Nachrichten SNS anstelle von S3 zu verwenden.

Voraussetzungen

  • AWS-Konto
  • Grundlegende Kenntnisse in DNS und den e-mail-bezogenen Record-Typen
  • Eine Domain (oder Subdomain), in der du Änderungen vornehmen kannst

Hinweise

  • Alles, was dokumentiert ist, muss in derselben AWS-Region erstellt werden
  • Fett kursivierter Text wie dieser sind deine implementierungsspezifischen Werte
  • Kursivierter Text sind Namen von Variablen, festen Werten oder UI-Elementen

Schritte

  1. Erstelle eine Simple Email Service (SES) Domain-Identität, your.domain, in einer der AWS-Regionen, die das Empfangen von E-Mails unterstützt

  2. Verifiziere die Domain-Identität

  3. Erstelle ein Simple Notification Service (SNS) Thema, feedback-sns-topic, für Feedback-Benachrichtigungen
    a. Füge die ARN des feedback-sns-topic Themas zu deiner aws_sns_topic_arn_allowlist-Einstellung hinzu.

  4. Konfiguriere die your.domain Domain-Identität
    a. Aktiviere das Weiterleiten von E-Mail-Feedback
    b. Konfiguriere Bounce- und Complaint-Feedback-Benachrichtigungen (nicht Delivery) so, dass sie das SNS-Thema feedback-sns-topic verwenden

  5. Erstelle eine Abonnement auf dem SNS-Thema feedback-sns-topic
    a. Protokoll ist HTTPS (du nutzt hoffentlich nicht noch immer HTTP, oder?)
    b. Setze den Endpoint auf https://your.domain/webhooks/aws (siehe VERP-Beitrag)
    c. Stelle sicher, dass Raw Message Delivery deaktiviert ist

  6. Erstelle ein weiteres SNS-Thema, incoming-sns-topic, für eingehende E-Mails

  7. Erstelle eine SES E-Mail-Empfangsregel-Sammlung, inbound-mail-set, falls es keine bestehende aktive gibt. Falls es eine gibt, nutze diese, da es nur eine aktive Regel-Sammlung geben kann

  8. Erstelle eine Empfangsregel in der inbound-mail-set Empfangsregel-Sammlung
    a. Setze die Empfängerbedingung auf your.domain
    b. Füge eine Aktion hinzu, um an das SNS-Thema incoming-sns-topic zu veröffentlichen, mit Base64-Kodierung

  9. Erstelle einen API-Schlüssel in deiner Discourse-Instanz für den Benutzer system und gewähre die receive email-Aktion auf der email-Ressource

  10. Erstelle ein Geheimnis in Secret Manager, email-handler-secret, mit den folgenden Schlüsseln und ihren jeweiligen Werten:

    • api_endpoint - https://your.domain/admin/email/handle_mail
    • api_key - aus Schritt 9
    • api_username - system, es sei denn, du hast in Schritt 9 etwas anderes verwendet
  11. Erstelle eine Lambda-Schicht, lambda-receiver-layer, für die python3.10-Laufzeitumgebung, die die Bibliotheken requests und aws-lambda-powertools enthält

  12. Erstelle eine Lambda-Funktion, email-receiver-lambda, für die python3.10-Laufzeitumgebung mit dem Empfängercode:

# Copyright (c) 2023 Derek J. Lambert
#
# Es wird hiermit jeder Person, die eine Kopie dieses Softwareprodukts und der zugehörigen Dokumentationsdateien (die "Software") erhält, die Erlaubnis erteilt, kostenlos mit der Software umzugehen, einschließlich der Rechte zur Nutzung, Vervielfältigung, Änderung, Zusammenführung, Veröffentlichung, Verteilung, Unterlizenzierung und/oder des Verkaufs von Kopien der Software, sowie Personen, denen die Software zur Verfügung gestellt wird, dies zu tun, vorbehaltlich der folgenden Bedingungen:
#
# Der obige Urheberrechtsvermerk und dieser Erlaubnisvermerk müssen in allen Kopien oder wesentlichen Teilen der Software enthalten sein.
#
# DIE SOFTWARE WIRD "WIE BEKAMMEN
9 „Gefällt mir“

Aktualisierungen des ursprünglichen Beitrags

  • Ich habe kürzlich festgestellt, dass die Lambda Powertools die spektakuläre Pydantic-Bibliothek unterstützen, und das Skript entsprechend aktualisiert. In der Lambda-Schicht, lambda-receiver-layer, muss für aws-lambda-powertools die parser-Erweiterung enthalten sein (d. h. aws-lambda-powertools[parser]).

  • Ich habe auch festgestellt, dass ich die AWS Parameters and Secrets Lambda Extension nicht tatsächlich verwende, um die Anmeldeinformationen abzurufen, sondern Funktionalität von Powertools (die nicht zwischen Aufrufen zwischengespeichert wird).

  • Vorerst, wenn die Version der requests-Bibliothek in der Lambda-Schicht, lambda-receiver-layer, größer als 2.29.0 ist, müssen Sie die urllib3-Bibliothek auf Version 1.x (d. h. urllib3<2) festlegen. Neuere Versionen von requests installieren Version 2 von urllib3, was derzeit mit der boto3-Bibliothek in Konflikt steht.

Version 2

# Copyright (c) 2023 Derek J. Lambert
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import os
from enum import Enum
from typing import Literal, Optional

import requests
from aws_lambda_powertools import Logger
from aws_lambda_powertools.logging import utils
from aws_lambda_powertools.utilities.parser import BaseModel, event_parser
from aws_lambda_powertools.utilities.parser.models import SnsModel, SesMessage, SnsRecordModel, SesMail, SesReceipt, SesMailCommonHeaders
from aws_lambda_powertools.utilities.typing import LambdaContext


class Secret(BaseModel):
    api_endpoint: str
    api_username: str
    api_key:      str


class SnsSesActionEncoding(str, Enum):
    BASE64 = 'BASE64'
    UTF8   = 'UTF8'


class SnsSesReceiptAction(BaseModel):
    type:     Literal['SNS']
    encoding: SnsSesActionEncoding
    topicArn: str


class SnsSesReceipt(SesReceipt):
    action: SnsSesReceiptAction


class SnsSesMailCommonHeaders(SesMailCommonHeaders):
    returnPath: Optional[str]


class SnsSesMail(SesMail):
    commonHeaders: SnsSesMailCommonHeaders


class SnsSesMessage(SesMessage):
    notificationType: str  # TODO: Are there other values besides 'Received'?
    content:          str
    mail:             SnsSesMail
    receipt:          SnsSesReceipt


try:
    SECRET_NAME       = os.environ['SECRET_NAME']
    AWS_SESSION_TOKEN = os.environ['AWS_SESSION_TOKEN']
except KeyError as e:
    raise RuntimeError(f'Missing {e} environment variable')

AWS_EXTENSION_PORT = os.getenv('PARAMETERS_SECRETS_EXTENSION_HTTP_PORT', 2773)

logger = Logger(service=os.getenv('AWS_LAMBDA_FUNCTION_NAME'), log_uncaught_exceptions=True, use_rfc3339=True)

utils.copy_config_to_registered_loggers(source_logger=logger)


def get_secret() -> Secret:
    # AWS Parameters and Secrets Lambda Extension
    # https://docs.aws.amazon.com/secretsmanager/latest/userguide/retrieving-secrets_lambda.html

    response = requests.get(
        url=f'http://localhost:{AWS_EXTENSION_PORT}/secretsmanager/get?secretId={SECRET_NAME}',
        headers={
            'X-Aws-Parameters-Secrets-Token': AWS_SESSION_TOKEN
        }
    )

    try:
        response.raise_for_status()
    except Exception:
        logger.critical(response.text)
        raise

    return Secret.parse_raw(response.json()['SecretString'])


def handle_record(record: SnsRecordModel):
    sns_record       = record.Sns
    sns_ses_message  = SnsSesMessage.parse_raw(record.Sns.Message)
    mail_destination = ','.join(sns_ses_message.mail.destination)

    logger.info(f'Processing SNS {sns_ses_message.notificationType} notification record with MessageId {sns_record.MessageId} from {sns_ses_message.mail.source} to {mail_destination}')

    # 'email' is deprecated, but just in case something is configured incorrectly
    body_key = 'email_encoded' if sns_ses_message.receipt.action.encoding is SnsSesActionEncoding.BASE64 else 'email'
    secret   = get_secret()

    response = requests.post(
        url=secret.api_endpoint,
        headers={
            'Api-Username': secret.api_username,
            'Api-Key':      secret.api_key,
        },
        json={
            body_key: sns_ses_message.content
        }
    )

    try:
        response.raise_for_status()
    except Exception:
        logger.critical(response.text)
        raise

    logger.info(f'Endpoint response: {response.text}')


@event_parser(model=SnsModel)
@logger.inject_lambda_context
def lambda_handler(event: SnsModel, context: LambdaContext):
    for record in event.Records:
        handle_record(record)
1 „Gefällt mir“

Danke für diesen Leitfaden @dlambert :smiley:

Ich kam gut voran, bis ich zu Schritt 11 kam:

Wo / wie erstelle ich diese? :thinking:

Hat es funktioniert?

Ich bleibe auch bei Schritt 11 hängen. Weiß nicht, was ich als nächstes tun soll. Kann mir jemand helfen?

1 „Gefällt mir“

Nein, tut mir leid, ich habe aufgegeben und wir haben die gesamte E-Mail-Antwort-Funktionalität deaktiviert und verwenden SES nur für einfache ausgehende E-Mails :cry:

Ich habe versucht, alle Schritte zur Einrichtung zu befolgen, aber am Ende erhalte ich diesen Fehler in Cloudwatch, kann mir jemand dabei helfen?

[ERROR] HTTPError: 403 Client Error: Forbidden for url: https://forum.siteurl.com/admin/email/handle_mail
Traceback (most recent call last):
  File "/opt/python/aws_lambda_powertools/middleware_factory/factory.py", line 135, in wrapper
    response = middleware()
  File "/opt/python/aws_lambda_powertools/utilities/data_classes/event_source.py", line 39, in event_source
    return handler(data_class(event), context)
  File "/opt/python/aws_lambda_powertools/logging/logger.py", line 453, in decorate
    return lambda_handler(event, context, *args, **kwargs)
  File "/var/task/lambda_function.py", line 107, in lambda_handler
    handle_record(record)
  File "/var/task/lambda_function.py", line 100, in handle_record
    response.raise_for_status()
  File "/opt/python/requests/models.py", line 1021, in raise_for_status
    raise HTTPError(http_error_msg, response=self)

Okay, es lag daran, dass Cloudflare deaktiviert wurde, was das Problem behoben hat. Vielleicht schreibe ich später hier, wie ich es geschafft habe, indem ich alle Schritte befolgt habe. :slight_smile:

1 „Gefällt mir“

Das habe ich getan.

Ich habe Python 3.10 auf meinem PC installiert, nach Schritt 10.

Dann habe ich diese Befehle ausgeführt.

mkdir lambda-receiver-layer

cd lambda-receiver-layer

mkdir python

pip install requests aws-lambda-powertools -t ./python

touch ./python/__init__.py

Da ich Probleme mit urllib3 hatte.

Hier sind zusätzliche Schritte, damit Sie diesen Fehler nicht erhalten.

Erstellen Sie in Ihrem Verzeichnis lambda-receiver-layer diese Datei requirements.txt.

Fügen Sie diese Zeile in diese Datei requirements.txt ein:

urllib3<2

Führen Sie dann den folgenden Befehl aus:

pip install -r requirements.txt -t layer

Nun wird ein weiterer Ordner im Verzeichnis lambda-receiver-layer namens layer erstellt.

Kopieren Sie den gesamten Inhalt von layer in den Ordner python.

Klicken Sie nun mit der rechten Maustaste auf den Ordner Python und wählen Sie “In ZIP komprimieren”. Benennen Sie diese ZIP-Datei in lambda-receiver-layer um.

Gehen Sie nun zurück zur AWS Management Console, navigieren Sie zum Dienst Lambda und dann zu “Layers”. Klicken Sie auf “Layer erstellen”, geben Sie als Namen lambda-receiver-layer ein und laden Sie das erstellte ZIP-Archiv hoch. Fügen Sie unter Runtime Python 3.10 hinzu und klicken Sie dann auf Erstellen.

Folgen Sie nun wieder Schritt 12 des ursprünglichen Beitrags.

Ich bleibe bei Schritt 11 stecken. Wohin füge ich den Python-Code ein?

Ich benötige dringend Hilfe bei der Behebung meiner SMTP-Bounces in mehreren Fällen. Ich habe einen #marketplace-Job gepostet: Fix AWS SNS Bounce

Ich hänge bei Punkt 14 fest, kann mir jemand erklären, was ich tun muss?

Falls sich im Jahr 2025 jemand fragt, ob Version 2 noch funktioniert, kann ich bestätigen, dass dies der Fall ist.

Einige Stolpersteine, auf die Sie stoßen könnten:

  • Stellen Sie sicher, dass Sie die Regelsätze in Konfiguration > E-Mail-Empfang in der Konsole konfigurieren und nicht die Regelsätze in Mail Manager > Regelsätze. Die Dinge im Mail Manager kosten viel Geld, besonders mit diesen Eingangsendpunkten.
  • Sie benötigen einen MX-Eintrag in Ihrem DNS, um Antwort-E-Mails an AWS SES zu senden. Wenn Sie bereits einen MX-Eintrag für Ihre Root-Domain für allgemeine E-Mail-Zwecke haben (z. B. Google Workspace-E-Mails für allgemeine Geschäftsangelegenheiten für eine Adresse wie kontakt@example.com), sollten Sie eine Subdomain für Ihre Antworten verwenden. In meinem Fall habe ich einen MX-Eintrag für antwort.example.com erstellt, um die Antworten an inbound-smtp.<REGION>.amazonaws.com zu senden. Weitere Details finden Sie in dieser Dokumentation.
  • Sie können CloudWatch verwenden, um zu sehen, wie die Dinge funktionieren. Wenn Sie einen Fehler sehen, bei dem eine bestimmte Bibliothek/ein bestimmtes Modul nicht geladen wird, haben Sie wahrscheinlich Ihre Lambda-Ebene falsch konfiguriert oder sie nicht mit der Funktion verbunden. Überprüfen Sie, ob die ZIP-Datei, die Sie hochladen, die richtige Verzeichnisstruktur aufweist, die wie python/lib/python3.10/site-packages/ aussieht. Sehen Sie sich diese Dokumentation an. Ich empfehle, sich einige Online-Tutorials zur Erstellung einer Lambda-Ebene anzusehen.

Der Code funktioniert weiterhin mit ARM64 – Sie müssen lediglich Ihre Lambda-Ebene mit der richtigen Architektur konfigurieren, indem Sie die ARM-basierten Python-Bibliotheken herunterladen.

Wenn alles erledigt ist, sollten Sie die empfangenen E-Mails in Ihren Admin-Protokollen sehen.

1 „Gefällt mir“

Ich habe diese Anleitung mit v2 bei einer neuen Bereitstellung heute befolgt und es hat einwandfrei funktioniert! Danke!

Und ich habe Python 3.14 anstelle von 3.10 verwendet, größtenteils ohne Probleme. Musste nur noch eine Bibliothek hinzufügen.

Für Schritt 11 sieht mein Befehl zum Erstellen der Bibliotheks-Ebene wie folgt aus:

LAYER_NAME=lambda-receiver-layer
PYVER=3.14
mkdir -p layer/python

docker run --rm -v "$PWD":/var/task public.ecr.aws/sam/build-python${PYVER}:latest \
  /bin/bash -lc "pip install -U pip && pip install -t layer/python \
  requests aws-lambda-powertools 'urllib3<2' pydantic"

# In die erforderliche Struktur zippen: zip muss einen 'python/' Ordner auf oberster Ebene enthalten
cd layer
zip -r ../${LAYER_NAME}.zip python
cd ..
echo "Erstellt: ${LAYER_NAME}.zip"

# Bereitstellen für AWS Lambda:
aws lambda publish-layer-version \
  --layer-name lambda-receiver-layer \
  --zip-file fileb://lambda-receiver-layer.zip \
  --compatible-runtimes python3.14 \
  --compatible-architectures arm64

Ich habe dies gerade eingerichtet und bin der Meinung, dass die Rohnachrichtenübermittlung DEAKTIVIERT sein muss, nicht AKTIVIERT.

Wenn die Rohnachrichtenübermittlung aktiviert ist, enthalten die SNS-Bounce-Benachrichtigungen nicht die SNS-Metadaten, die Discourse benötigt, um die Nachricht zu validieren. Meine Zugriffslogs enthielten Einträge wie diesen:

"POST /webhooks/aws HTTP/1.1" "Amazon Simple Notification Service Agent" "-" 406 414 "-" 0.008 0.008 "-" "-" "-" "-" "-" "-" "-"

also HTTP-Status 406, “Not Acceptable”.

Nach dem Deaktivieren der Rohnachrichtenübermittlung zeigten die Zugriffslogs:

"POST /webhooks/aws HTTP/1.1" "Amazon Simple Notification Service Agent" "-" 200 402 "-" 0.022 0.022 "-" "-" "-" "-" "-" "-" "-"

also HTTP-Status 200, “OK”.