Configuration d’AWS SES pour les e-mails sortants, de rebond et entrants

Je pensais partager la configuration que j’ai élaborée pour utiliser AWS SES pour les e-mails sortants, les rebonds et les e-mails entrants. Le service SES comporte assurément quelques subtilités, et il m’a fallu beaucoup d’essais et d’erreurs pour comprendre exactement comment il fonctionne. Il s’agit davantage d’un déballage de mes notes que d’un guide pas à pas à suivre à la lettre. Ce document ne devrait pas être nécessaire, mais utilisez-le sous votre propre responsabilité. Et, bien sûr, lisez toujours et comprenez tout code écrit par d’autres que vous décidez d’implémenter.

Contexte :

Je travaille au déploiement de Discourse sur AWS et j’utilise autant de leurs services que possible pour garantir la fiabilité et la redondance. En tant que développeur, je suis plus à l’aise avec la ligne de commande et le code, et je souhaitais utiliser l’automatisation IaC. Mon environnement complet est déployé avec Terraform, mais j’ai essayé de naviguer dans la console web et d’aligner les éléments au mieux. IAM et les documents de politique sortent du cadre de cet article, mais je pense avoir indiqué où des éléments sont nécessaires.

Faire tourner une instance Postfix semble être une surcharge pour une seule application. Utiliser une boîte aux lettres POP3, c’est vraiment daté des années 90. Alors, je suis parti explorer le labyrinthe d’AWS.

J’ai trouvé quelques publications extrêmement utiles qui m’ont aidé dans ma quête

Le conteneur mail-receiver m’a également aidé à comprendre comment Discourse traite les messages

Au départ, je m’attendais à ce que le point de terminaison de webhook AWS gère les messages entrants, mais après avoir parcouru le code, j’ai réalisé que ce n’était pas le cas. J’ai basé mon code de récepteur lambda sur l’excellent exemple de @dltj. J’ai opté pour l’utilisation de SNS pour la livraison des messages au lieu de S3.

Prérequis

  • Compte AWS
  • Connaissances opérationnelles du DNS et des types d’enregistrements liés aux e-mails
  • Un domaine (ou sous-domaine) sur lequel vous pouvez effectuer des modifications

Notes

  • Tout ce qui est documenté doit être créé dans la même région AWS
  • Le texte en gras italique comme ceci correspond à vos valeurs spécifiques à votre implémentation
  • Le texte en italique correspond aux noms de variables, aux valeurs fixes ou aux éléments de l’interface utilisateur

Étapes

  1. Créez une identité de domaine Simple Email Service (SES), votre.domaine, dans l’une des régions AWS prenant en charge la réception d’e-mails

  2. Vérifiez l’identité du domaine

  3. Créez un sujet Simple Notification Service (SNS), feedback-sns-topic, pour les notifications de retour d’information
    a. Ajoutez l’ARN du sujet feedback-sns-topic à votre paramètre aws_sns_topic_arn_allowlist.

  4. Configurez l’identité du domaine votre.domaine
    a. Activez la redirection des retours d’information sur les e-mails
    b. Configurez les notifications de retour d’information pour les rebonds et les plaintes (et non la livraison) pour utiliser le sujet SNS feedback-sns-topic

  5. Créez un abonnement sur le sujet SNS feedback-sns-topic
    a. Le protocole est HTTPS (vous n’utilisez plus HTTP, j’espère ?)
    b. Définissez le point de terminaison sur https://votre.domaine/webhooks/aws (voir la publication sur VERP)
    c. Assurez-vous que la livraison de message brut est désactivée

  6. Créez un autre sujet SNS, incoming-sns-topic, pour les e-mails entrants

  7. Créez un ensemble de règles de réception d’e-mails SES, inbound-mail-set, s’il n’existe pas déjà un ensemble actif. Sinon, utilisez celui-ci, car il ne peut y en avoir qu’un seul actif

  8. Créez une règle de réception dans l’ensemble de règles de réception inbound-mail-set
    a. Définissez la condition du destinataire sur votre.domaine
    b. Ajoutez une action pour publier sur le sujet SNS incoming-sns-topic, avec l’encodage Base64

  9. Créez une clé API dans votre instance Discourse pour l’utilisateur system, en accordant l’action receive email sur la ressource email

  10. Créez un secret dans Secret Manager, email-handler-secret, avec les clés suivantes et leurs valeurs respectives :

    • api_endpoint - https://votre.domaine/admin/email/handle_mail
    • api_key - depuis l’étape 9
    • api_username - system, sauf si vous avez utilisé autre chose à l’étape 9
  11. Créez une couche Lambda, lambda-receiver-layer, pour l’environnement d’exécution python3.10 contenant les bibliothèques requests et aws-lambda-powertools

  12. Créez une fonction lambda, email-receiver-lambda, pour l’environnement d’exécution python3.10 avec le code du récepteur :

# 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. Configurez la fonction lambda email-receiver-lambda :
    a. Ajoutez la couche lambda-receiver-layer
    b. Ajoutez la couche spécifique à la région pour AWS Parameter Store
    c. Ajoutez la variable d’environnement SECRET_NAME avec la valeur email-handler-secret
    d. Si vous souhaitez que des détails supplémentaires soient journalisés, ajoutez la variable d’environnement POWERTOOLS_LOGGER_LOG_EVENT avec la valeur true

  2. Accordez à la fonction lambda email-receiver-lambda la permission IAM secretsmanager:GetSecretValue pour le secret email-handler-secret

  3. Créez un abonnement sur le sujet SNS incoming-sns-topic
    a. Le protocole est AWS Lambda
    b. Définissez le point de terminaison sur l’ARN de email-receiver-lambda

  4. Des permissions IAM seront nécessaires pour que l’abonnement SNS sur le sujet incoming-sns-topic puisse invoquer email-receiver-lambda, mais je pense que cela sera fait automatiquement lorsqu’il est configuré via la console

À des fins de débogage, ou pour vous agacer un peu, vous pouvez ajouter un abonnement par e-mail à l’un des sujets SNS pour surveiller les notifications.

J’ai rédigé cela en quelques sessions, mais je pense que c’est complet. Je peux essayer de répondre aux questions générales dans la mesure du temps disponible.

9 « J'aime »

Mises à jour du message original

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 « J'aime »

Merci pour ce guide @dlambert :smiley:

J’allais très bien, jusqu’à ce que j’arrive à l’étape 11 :

Où / comment est-ce que je crée ceci ? :thinking:

Est-ce que tu y arrives ?

J’ai aussi bloqué à l’étape 11. Je ne sais pas quoi faire ensuite. Quelqu’un pourrait m’aider ?

1 « J'aime »

Non, désolé, j’ai abandonné et nous avons désactivé toutes les fonctionnalités de réponse par e-mail, en utilisant SES uniquement pour les e-mails sortants simples :cry:

J’ai essayé de suivre toutes les étapes de configuration, mais à la fin, j’obtiens cette erreur dans Cloudwatch, quelqu’un peut-il m’aider ?

[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)

D’accord, c’était parce que Cloudflare désactivé a résolu le problème. Peut-être que plus tard j’écrirai ici comment j’ai réussi en suivant toutes les étapes. :slight_smile:

1 « J'aime »

Voici ce que j’ai fait.

J’ai installé Python 3.10 sur mon PC, après l’étape 10.

Ensuite, j’ai exécuté ces commandes.

mkdir lambda-receiver-layer

cd lambda-receiver-layer

mkdir python

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

touch ./python/__init__.py

Comme j’avais des problèmes avec urllib3

Voici des étapes supplémentaires pour éviter cette erreur.

Dans votre répertoire lambda-receiver-layer, créez ce fichier requirements.txt

Ajoutez la ligne suivante dans ce fichier requirements.txt :

urllib3<2

Exécutez ensuite la commande suivante :

pip install -r requirements.txt -t layer

Maintenant, un autre dossier sera créé dans le répertoire lambda-receiver-layer nommé layer

Copiez tout le contenu de layer dans le dossier python

Maintenant, faites un clic droit sur le dossier Python et cliquez sur “Compresser en ZIP”, renommez ce zip en lambda-receiver-layer

Maintenant, retournez dans la console de gestion AWS, allez dans le service Lambda et naviguez jusqu’à “Layers”. Cliquez sur “Create Layer”, mettez lambda-receiver-layer dans le nom et téléchargez l’archive zip que vous avez créée. Dans le runtime, ajoutez Python 3.10, puis cliquez sur créer.

Suivez ensuite à nouveau à partir de l’étape 12 de la publication originale.

Je suis bloqué à l’étape 11, où dois-je coller le code Python ?

J’ai besoin d’aide urgente pour corriger mes rebonds SMTP dans plusieurs cas, j’ai publié une annonce sur le Marketplace Fix AWS SNS Bounce

Je suis bloqué au point 14, quelqu’un peut-il clarifier ce que je dois faire ?

Si quelqu’un en 2025 se demande si la version 2 fonctionne toujours, je peux confirmer que c’est le cas.

Quelques problèmes que vous pourriez rencontrer :

  • Assurez-vous de configurer les ensembles de règles dans Configuration > Réception des e-mails dans la console, et non les ensembles de règles dans Gestionnaire de courrier > Ensembles de règles. Les éléments du Gestionnaire de courrier coûtent très cher, surtout avec ces points de terminaison d'entrée.
  • Vous avez besoin d’un enregistrement MX dans votre DNS pour recevoir les e-mails de réponse à envoyer à AWS SES. Si vous avez déjà un enregistrement MX pour votre domaine racine pour des choses générales d’e-mail (c’est-à-dire, utiliser les e-mails Google Workspace pour des choses générales d’affaires pour une adresse comme contact@example.com), vous voudrez utiliser un sous-domaine pour vos réponses. Dans mon cas, j’ai créé un enregistrement MX sur reply.example.com pour envoyer les réponses à inbound-smtp.<REGION>.amazonaws.com. Consultez cette documentation pour plus de détails.
  • Vous pouvez utiliser CloudWatch pour voir comment les choses fonctionnent. Si vous voyez une erreur où une certaine bibliothèque/module ne se charge pas, vous avez probablement mal configuré votre couche Lambda ou ne l’avez pas connectée à la fonction. Vérifiez que le fichier ZIP que vous téléchargez a la bonne structure de répertoire qui ressemble à python/lib/python3.10/site-packages/; consultez cette documentation. Je recommande de rechercher des tutoriels en ligne sur la création d’une couche Lambda.

Le code fonctionne toujours avec ARM64 - il vous suffit de configurer votre couche Lambda avec la bonne architecture en téléchargeant les bibliothèques Python basées sur ARM.

Une fois que tout est terminé, vous devriez voir les e-mails reçus dans vos journaux d’administration.

1 « J'aime »

J’ai suivi ce guide en utilisant la v2 sur un nouveau déploiement aujourd’hui et cela a fonctionné parfaitement ! Merci !

Et j’ai utilisé python 3.14, pas 3.10, presque sans problème. J’ai juste eu besoin d’ajouter une bibliothèque supplémentaire.

Pour l’étape 11, ma commande ressemble à ceci pour construire la couche des bibliothèques :

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"

# Zipper dans la structure requise : zip doit contenir le dossier 'python/' de niveau supérieur
cd layer
zip -r ../${LAYER_NAME}.zip python
cd ..
echo "Créé : ${LAYER_NAME}.zip"

# Déployer sur 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

Je viens de configurer cela et je pense que la livraison brute des messages (raw message delivery) doit être DÉSACTIVÉE, et non ACTIVÉE.

Avec la livraison brute des messages activée, les notifications de rebond de SNS n’incluent pas les métadonnées SNS que Discourse requiert pour valider le message. Mes journaux d’accès contenaient des entrées comme celle-ci :

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

soit le statut HTTP 406, « Non acceptable » (Not Acceptable).

Après avoir désactivé la livraison brute des messages, les journaux d’accès affichaient :

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

soit le statut HTTP 200, « OK ».