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

Ich dachte, ich teile die Konfiguration, die ich für die Verwendung von AWS SES für ausgehende, Bounces und eingehende E-Mails erstellt habe. Es gibt definitiv einige Nuancen beim SES-Dienst, und es hat eine ganze Menge Ausprobieren gebraucht, um genau zu verstehen, wie er funktioniert. Dies ist eher ein Brain-Dump als eine Schritt-für-Schritt-Anleitung. Es sollte unnötig sein, aber auf eigene Gefahr verwenden. Und auf jeden Fall immer den Code, den andere geschrieben haben und den Sie implementieren, durchlesen und verstehen.

Hintergrund:

Ich arbeite daran, Discourse in AWS bereitzustellen und alle verfügbaren Dienste zu nutzen, um Zuverlässigkeit und Redundanz zu gewährleisten. Als Entwickler bin ich mit der Kommandozeile und dem Code vertrauter und wollte Automatisierung für Infrastructure as Code verwenden. Meine gesamte Umgebung wird mit Terraform bereitgestellt, aber ich habe versucht, die Webkonsole zu durchklicken und die Dinge so gut wie möglich abzustimmen. IAM und Richtliniendokumente sind hier nicht das Thema, aber ich glaube, ich habe darauf hingewiesen, wo Dinge benötigt werden.

Das Betreiben einer Postfix-Instanz scheint für eine einzelne Anwendung übertrieben zu sein. Die Verwendung eines POP3-Mailbox ist so sehr 90er Jahre. Also ging ich den AWS-Hasenbau hinunter.

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

Der Mail-Receiver-Container half mir auch zu verstehen, wie Discourse Nachrichten verarbeitet:

Anfangs erwartete ich, dass der AWS-Webhook-Endpunkt eingehende Nachrichten verarbeiten würde, aber nachdem ich den Code durchgegangen war, erkannte ich, dass dies nicht der Fall sein würde. Ich habe meinen Lambda-Receiver-Code auf dem ausgezeichneten Beispiel von @dltj basiert. Ich habe mich entschieden, SNS für die Nachrichtenlieferung anstelle von S3 zu verwenden.

Voraussetzungen

  • AWS-Konto
  • Kenntnisse in DNS und den E-Mail-bezogenen Aufzeichnungstypen
  • Eine Domain (oder Subdomain), in der Sie Änderungen vornehmen können

Hinweise

  • Alles Dokumentierte muss in derselben AWS-Region erstellt werden
  • Fett und kursiv geschriebener Text wie dieser sind Ihre implementierungsspezifischen Werte
  • Kursiv geschriebener Text sind Namen von Variablen, feste Werte oder UI-Elemente

Schritte

  1. Erstellen Sie eine SES (Simple Email Service) Domain-Identität, ihre.domain, in einer der AWS-Regionen, die den E-Mail-Empfang unterstützen

  2. Domain-Identität verifizieren

  3. Erstellen Sie ein SNS (Simple Notification Service) Topic, feedback-sns-topic, für Feedback-Benachrichtigungen

  4. Konfigurieren Sie die ihre.domain Domain-Identität
    a. Aktivieren Sie die E-Mail-Feedback-Weiterleitung
    b. Konfigurieren Sie Bounce- und Beschwerde- (nicht Zustellungs-) Feedback-Benachrichtigungen so, dass sie das SNS feedback-sns-topic Topic verwenden

  5. Erstellen Sie ein Abonnement für das SNS feedback-sns-topic Topic
    a. Protokoll ist HTTPS (Sie verwenden doch nicht etwa noch HTTP?)
    b. Setzen Sie den Endpunkt auf https://ihre.domain/webhooks/aws (siehe VERP-Post)
    c. Wählen Sie “Rohnachrichtenlieferung aktivieren”

  6. Erstellen Sie ein weiteres SNS Topic, incoming-sns-topic, für eingehende E-Mails

  7. Erstellen Sie einen SES E-Mail-Empfangsregel-Satz, inbound-mail-set, falls noch kein aktiver vorhanden ist. Wenn ja, verwenden Sie diesen, da nur ein aktiver Regel-Satz existieren kann

  8. Erstellen Sie eine Empfangsregel im inbound-mail-set Regel-Satz
    a. Setzen Sie die Empfängerbedingung auf ihre.domain
    b. Fügen Sie die Aktion hinzu, um zum SNS Topic incoming-sns-topic zu veröffentlichen, Kodierung Base64

  9. Erstellen Sie einen API-Schlüssel in Ihrer Discourse-Instanz für den Benutzer system, der die Aktion receive email für die Ressource email gewährt

  10. Erstellen Sie ein Secret im Secret Manager, email-handler-secret, mit den folgenden Schlüsseln und ihren jeweiligen Werten:

    • api_endpoint - https://ihre.domain/admin/email/handle_mail
    • api_key - aus Schritt 9
    • api_username - system, es sei denn, Sie haben in Schritt 9 etwas anderes verwendet
  11. Erstellen Sie eine Lambda-Layer, lambda-receiver-layer, für die Laufzeit python3.10, die die Bibliotheken requests und aws-lambda-powertools enthält

  12. Erstellen Sie eine Lambda-Funktion, email-receiver-lambda, für die Laufzeit python3.10 mit dem Receiver-Code:

# 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 json
import os
from typing import TypedDict

import requests
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities import parameters
from aws_lambda_powertools.utilities.data_classes import event_source
from aws_lambda_powertools.utilities.data_classes.sns_event import SNSEvent, SNSEventRecord
from aws_lambda_powertools.utilities.typing import LambdaContext


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


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

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

AWS_EXTENSION_PORT = os.getenv('PARAMETERS_SECRETS_EXTENSION_HTTP_PORT', 2773)
EXTENSION_ENDPOINT = f'http://localhost:{AWS_EXTENSION_PORT}/secretsmanager/get?secretId={SECRET_NAME}'


def get_secret() -> Secret:
    return parameters.get_secret(SECRET_NAME, transform='json')


def handle_record(record: SNSEventRecord):
    sns         = record.sns
    sns_message = json.loads(sns.message)

    try:
        message_type    = sns_message['notificationType']
        message_mail    = sns_message['mail']
        message_content = sns_message['content']
        message_receipt = sns_message['receipt']
    except KeyError as exc:
        raise RuntimeError(f'Key {exc} missing from message')

    try:
        receipt_action = message_receipt['action']
    except KeyError as exc:
        raise RuntimeError(f'Key {exc} missing from receipt')

    try:
        action_encoding = receipt_action['encoding']
    except KeyError as exc:
        raise RuntimeError(f'Key {exc} missing from action')

    try:
        mail_source      = message_mail['source']
        mail_destination = ','.join(message_mail['destination'])
    except KeyError as exc:
        raise RuntimeError(f'Key {exc} missing from mail')

    logger.info(f'Processing SNS {message_type} {sns.get_type} record with MessageId {sns.message_id} from {mail_source} to {mail_destination}')

    # 'email' is deprecated, but just in case something is configured incorrectly
    body_key = 'email_encoded' if action_encoding == 'BASE64' else 'email'

    request_body = {
        body_key: message_content
    }

    secret  = get_secret()
    headers = {
        'Api-Username': secret['api_username'],
        'Api-Key':      secret['api_key'],
    }

    response = requests.post(url=secret['api_endpoint'], headers=headers, json=request_body)

    logger.info(response.text)
    response.raise_for_status()


@event_source(data_class=SNSEvent)
@logger.inject_lambda_context
def lambda_handler(event: SNSEvent, context: LambdaContext):
    for record in event.records:
        handle_record(record)
  1. Konfigurieren Sie die Lambda-Funktion email-receiver-lambda:
    a. Fügen Sie den Layer lambda-receiver-layer hinzu
    b. Fügen Sie den regionsspezifischen Layer für AWS Parameter Store hinzu
    c. Fügen Sie die Umgebungsvariable SECRET_NAME mit dem Wert email-handler-secret hinzu
    d. Wenn Sie zusätzliche Details protokollieren möchten, fügen Sie die Umgebungsvariable POWERTOOLS_LOGGER_LOG_EVENT mit dem Wert true hinzu

  2. Erteilen Sie der Lambda-Funktion email-receiver-lambda die IAM-Berechtigung secretsmanager:GetSecretValue für das Secret email-handler-secret

  3. Erstellen Sie ein Abonnement für das SNS Topic incoming-sns-topic
    a. Protokoll ist AWS Lambda
    b. Setzen Sie den Endpunkt auf die ARN von email-receiver-lambda

  4. IAM-Berechtigungen werden für das SNS-Abonnement des incoming-sns-topic Topics benötigt, um email-receiver-lambda aufzurufen, aber ich glaube, dies geschieht automatisch, wenn es über die Konsole konfiguriert wird

Zu Debugging-Zwecken oder zur allgemeinen Selbstquälerei können Sie ein E-Mail-Abonnement für eines der SNS-Topics hinzufügen, um die Benachrichtigungen zu überwachen.

Ich habe das in ein paar Sitzungen niedergeschrieben, aber ich denke, es ist alles. Ich kann versuchen, allgemeine Fragen zu beantworten, wenn die Zeit es erlaubt.

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“