Configurer AWS SES pour les emails sortants, de rebond et entrants

J’ai pensé partager la configuration que j’ai mise au point pour utiliser AWS SES pour les e-mails sortants, les rebonds et les e-mails entrants. Il y a certainement des nuances dans le service SES, et il a fallu beaucoup d’essais et d’erreurs pour comprendre exactement comment il fonctionne. Ceci est plus un déballage de cerveau qu’un guide étape par étape. Cela devrait être inutile, mais utilisez à vos propres risques. Et par tous les moyens, lisez toujours et comprenez tout code écrit par d’autres que vous implémentez.

Contexte :

Je travaille au déploiement de Discourse dans AWS et à l’utilisation de tous les services possibles pour assurer 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 voulais utiliser l’IaC automation. Mon environnement entier est déployé avec Terraform, mais j’ai essayé de cliquer à travers la console web et d’aligner les choses du mieux que j’ai pu. IAM et les documents de politique sont au-delà de la portée de ceci, mais je crois que j’ai indiqué où les choses sont nécessaires.

Faire fonctionner une instance Postfix semble excessif pour une seule application. Utiliser une boîte aux lettres POP3 est tellement des années 90. Je me suis donc plongé dans le terrier de lapin AWS.

J’ai trouvé quelques articles extrêmement utiles qui ont aidé ma quête :

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

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

Prérequis

  • Compte AWS
  • Connaissance pratique du DNS et des types d’enregistrements liés aux e-mails
  • Un domaine (ou sous-domaine) dans lequel vous pouvez apporter des modifications

Notes

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

Étapes

  1. Créez une identité de domaine Simple Email Service (SES), your.domain, 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

  4. Configurez l’identité de domaine your.domain
    a. Activez la redirection des retours d’information par e-mail
    b. Configurez les notifications de retour d’information sur les rebonds et les plaintes (pas les livraisons) 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 toujours pas HTTP, n’est-ce pas ?)
    b. Définissez le point de terminaison sur https://your.domain/webhooks/aws (voir le post VERP)
    c. Sélectionnez l’activation de la livraison de messages bruts

  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’en existe pas d’actif. Si c’est le cas, utilisez celui-ci car il ne peut y avoir qu’un seul ensemble de règles 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 your.domain
    b. Ajoutez une action pour publier sur le sujet SNS incoming-sns-topic, en encodant 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://your.domain/admin/email/handle_mail
    • api_key - de l’étape 9
    • api_username - system, sauf si vous avez utilisé quelque chose de différent à l’étape 9
  11. Créez une couche Lambda, lambda-receiver-layer, pour le runtime python3.10 contenant les bibliothèques requests et aws-lambda-powertools

  12. Créez une fonction Lambda, email-receiver-lambda, pour le runtime 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 des détails supplémentaires enregistrés, ajoutez la variable d’environnement POWERTOOLS_LOGGER_LOG_EVENT avec la valeur true

  2. Accordez à la fonction Lambda email-receiver-lambda l’autorisation 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 autorisations 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 lors de la configuration via la console

À des fins de débogage, ou pour votre propre agacement général, vous pouvez ajouter un abonnement par e-mail à l’un ou l’autre des sujets SNS pour surveiller les notifications.

J’ai mis cela par écrit en quelques sessions, mais je pense que c’est tout. Je peux essayer de répondre aux questions générales dans la mesure du temps le permet.

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 »