Configurar AWS SES para correo saliente, rebotes y entrante

Pensé que sería útil compartir la configuración que ideé para usar AWS SES para el correo saliente, de rebote y entrante. El servicio SES tiene matices importantes, y me llevó bastante prueba y error entender exactamente cómo funciona. Esto es más un volcado de ideas que una guía paso a paso. Debería ser innecesario, pero úsalo bajo tu propio riesgo. Y, por supuesto, siempre lee y comprende cualquier código escrito por otros que vayas a implementar.

Antecedentes:

Estoy trabajando en desplegar Discourse en AWS y aprovechar todos sus servicios posibles para garantizar fiabilidad y redundancia. Como desarrollador, me siento más cómodo con la línea de comandos y el código, y quise usar automatización de IaC. Todo mi entorno se está desplegando con Terraform, pero he intentado navegar por la consola web y alinear las cosas lo mejor posible. IAM y los documentos de política están fuera del alcance de esto, pero creo que he señalado dónde se necesitan.

Ejecutar una instancia de Postfix parece un exceso para una sola aplicación. Usar un buzón POP3 es algo muy de los 90. Así que me metí en el conejo de AWS.

Encontré algunas publicaciones extremadamente útiles que me ayudaron en mi búsqueda:

El contenedor mail-receiver también me ayudó a entender cómo Discourse digiere los mensajes:

Inicialmente esperaba que el punto de final de webhook de AWS manejara los mensajes entrantes, pero después de revisar el código me di cuenta de que no lo haría. Basé mi código de receptor lambda en el excelente ejemplo de @dltj. Opté por usar SNS para la entrega de mensajes en lugar de S3.

Prerrequisitos

  • Cuenta de AWS
  • Conocimientos prácticos de DNS y los tipos de registros relacionados con el correo electrónico
  • Un dominio (o subdominio) en el que puedas realizar cambios

Notas

  • Todo lo documentado debe crearse en la misma región de AWS
  • El texto en **negrita y cursiva como este son valores específicos de tu implementación
  • El texto en cursiva son nombres de variables, valores fijos o elementos de la interfaz de usuario

Pasos

  1. Crea una identidad de dominio de Simple Email Service (SES), tu.dominio, en una de las regiones de AWS que admitan la recepción de correo

  2. Verifica la identidad del dominio

  3. Crea un tema de Simple Notification Service (SNS), feedback-sns-topic, para notificaciones de retroalimentación
    a. Agrega el ARN del tema feedback-sns-topic a tu configuración aws_sns_topic_arn_allowlist.

  4. Configura la identidad del dominio tu.dominio
    a. Habilita el reenvío de retroalimentación de correo
    b. Configura las notificaciones de retroalimentación de rebote y queja (no de entrega) para usar el tema SNS feedback-sns-topic

  5. Crea una suscripción en el tema SNS feedback-sns-topic
    a. El protocolo es HTTPS (¿no sigues usando HTTP, verdad?)
    b. Establece el punto de final en https://tu.dominio/webhooks/aws (ver publicación sobre VERP)
    c. Asegúrate de que la entrega de mensaje en bruto esté deshabilitada

  6. Crea otro tema SNS, incoming-sns-topic, para el correo entrante

  7. Crea un conjunto de reglas de recepción de correo de SES, inbound-mail-set, si no existe uno activo. Si existe, úsalo, ya que solo puede haber un conjunto de reglas activo

  8. Crea una regla de recepción en el conjunto de reglas de recepción inbound-mail-set
    a. Establece la condición del destinatario en tu.dominio
    b. Agrega una acción para publicar en el tema SNS incoming-sns-topic, codificación Base64

  9. Crea una clave API en tu instancia de Discourse para el usuario system, otorgando la acción receive email en el recurso email

  10. Crea un secreto en Secret Manager, email-handler-secret, con las siguientes claves y sus respectivos valores:

    • api_endpoint - https://tu.dominio/admin/email/handle_mail
    • api_key - desde el paso 9
    • api_username - system, a menos que hayas usado algo diferente en el paso 9
  11. Crea una capa Lambda, lambda-receiver-layer, para el tiempo de ejecución python3.10 que contenga las bibliotecas requests y aws-lambda-powertools

  12. Crea una función lambda, email-receiver-lambda, para el tiempo de ejecución python3.10 con el código del receptor:

# Copyright (c) 2023 Derek J. Lambert
#
# Se concede permiso, de forma gratuita, a cualquier persona que obtenga una copia
# de este software y los archivos de documentación asociados (el "Software"), para tratar
# el Software sin restricción, incluyendo sin limitación los derechos
# de uso, copia, modificación, fusión, publicación, distribución, sublicencia y/o venta
# de copias del Software, y para permitir a las personas a las que se
# facilite el Software hacer lo mismo, sujeto a las siguientes condiciones:
#
# El aviso de copyright anterior y este aviso de permiso se incluirán en todos
# las copias o partes sustanciales del Software.
#
# EL SOFTWARE SE PROPORCIONA "TALES CUALES", SIN GARANTÍA DE NINGÚN TIPO, EXPRESA O
# IMPLÍCITA, INCLUIDAS, ENTRE OTRAS, LAS GARANTÍAS DE COMERCIABILIDAD,
# APTITUD PARA UN PROPÓSITO PARTICULAR Y NO INFRACCIÓN. EN NINGÚN CASO LOS
# AUTORES O TITULARES DEL COPYRIGHT SERÁN RESPONSABLES DE NINGÚN RECLAMO, DAÑO U OTRA
# RESPONSABILIDAD, YA SEA EN UNA ACCIÓN DE CONTRATO, DELITO U OTRO, DERIVADA DE,
# EN CONEXIÓN CON EL SOFTWARE O EL USO U OTROS TRATAMIENTOS DEL
# 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' está obsoleto, pero por si algo está configurado incorrectamente
    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. Configura la función lambda email-receiver-lambda:
    a. Agrega la capa lambda-receiver-layer
    b. Agrega la capa específica de la región para AWS Parameter Store
    c. Agrega la variable de entorno SECRET_NAME con el valor email-handler-secret
    d. Si deseas registrar detalles adicionales, agrega la variable de entorno POWERTOOLS_LOGGER_LOG_EVENT con el valor true

  2. Otorga a la función lambda email-receiver-lambda el permiso IAM secretsmanager:GetSecretValue para el secreto email-handler-secret

  3. Crea una suscripción en el tema SNS incoming-sns-topic
    a. El protocolo es AWS Lambda
    b. Establece el punto de final en el ARN de email-receiver-lambda

  4. Se necesitarán permisos IAM para que la suscripción de SNS en el tema incoming-sns-topic invoque email-receiver-lambda, pero creo que esto se hará automáticamente cuando se configure a través de la consola

Con fines de depuración, o para molestarte a ti mismo en general, puedes agregar una suscripción de correo a cualquiera de los temas SNS para monitorear las notificaciones.

Lo escribí en un par de sesiones, pero creo que es todo. Puedo intentar responder preguntas generales en la medida en que el tiempo lo permita.

9 Me gusta

Actualizaciones a la publicación original

Versión 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 me gusta

Gracias por esta guía @dlambert :smiley:

Iba muy bien, hasta que llegué al paso 11:

¿Dónde / cómo creo esto? :thinking:

¿Lo has conseguido?

Yo también me atasco en el paso 11. No sé qué hacer a continuación. ¿Alguien podría ayudar?

1 me gusta

No, lo siento, me rendí y deshabilitamos toda la funcionalidad de respuesta por correo electrónico, usando SES solo para correos salientes simples :cry:

Intenté seguir todos los pasos de configuración, pero al final, estoy recibiendo este error en Cloudwatch, ¿alguien puede ayudarme con esto?

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

De acuerdo, se debió a que Cloudflare lo deshabilitó y eso resolvió el problema. Quizás más tarde escribiré aquí cómo lo hice funcionar siguiendo todos los pasos. :slight_smile:

1 me gusta

Esto es lo que hice.

Instalé Python 3.10 en mi PC, después del paso 10.

Luego ejecuté estos comandos.

mkdir lambda-receiver-layer

cd lambda-receiver-layer

mkdir python

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

touch ./python/__init__.py

Como tuve problemas con urllib3

Aquí tienes pasos adicionales para que no obtengas ese error.

En tu directorio lambda-receiver-layer, crea este archivo requirements.txt

Añade la siguiente línea en este archivo requirements.txt:

urllib3<2

Luego ejecuta el siguiente comando:

pip install -r requirements.txt -t layer

Ahora se creará otra carpeta dentro del directorio lambda-receiver-layer llamada layer

Copia todo el contenido de layer a la carpeta python

Ahora, haz clic derecho en la carpeta Python y haz clic en ‘Comprimir en ZIP’, renombra este zip a lambda-receiver-layer

Ahora, regresa a la Consola de Administración de AWS, ve al servicio Lambda y navega a “Layers”. Haz clic en “Create Layer”, pon esto en el nombre lambda-receiver-layer y sube el archivo zip que creaste. En runtime añade Python 3.10 y luego haz clic en crear.

Ahora sigue desde el paso 12 de la publicación original.

Me estoy atascando en el paso 11, ¿dónde pego el código de Python?

Necesito ayuda urgente para solucionar mis rebotes SMTP en varios casos, he publicado un trabajo en Marketplace Fix AWS SNS Bounce

Estoy atascado en el punto 14, ¿alguien puede aclarar qué tengo que hacer?

Si alguien en 2025 se pregunta si la versión 2 todavía funciona, puedo confirmar que sí.\n\nAlgunos problemas que podrías encontrar:\n- Asegúrate de configurar los conjuntos de reglas en Configuración > Recepción de correo electrónico en la consola, no los conjuntos de reglas en Administrador de correo > Conjuntos de reglas. Las cosas del Administrador de correo cuestan mucho dinero, especialmente con esos puntos de conexión de entrada.\n- Necesitas un registro MX en tu DNS para recibir correos electrónicos de respuesta y enviarlos a AWS SES. Si ya tienes un registro MX para tu dominio raíz para cosas generales de correo electrónico (es decir, usar correos electrónicos de Google Workspace para cosas generales de negocios para una dirección como contact@example.com), querrás usar un subdominio para tus respuestas. En mi caso, creé un registro MX en reply.example.com para enviar las respuestas a inbound-smtp.<REGION>.amazonaws.com. Consulta esta documentación para más detalles.\n- Puedes usar CloudWatch para ver cómo funcionan las cosas. Si ves un error donde una cierta biblioteca/módulo no se carga, es probable que hayas configurado mal tu Capa Lambda o no la hayas conectado a la función. Comprueba que el archivo ZIP que subes tenga la estructura de directorios correcta que se parezca a python/lib/python3.10/site-packages/; consulta esta documentación. Recomiendo buscar tutoriales en línea sobre cómo crear una Capa Lambda.\n\nEl código todavía funciona con ARM64: solo necesitas configurar tu capa Lambda con la arquitectura correcta descargando las bibliotecas de Python basadas en ARM.\n\nCuando todo esté dicho y hecho, deberías ver los correos electrónicos recibidos en tus registros de administrador.

1 me gusta

¡Seguí esta guía usando la v2 en un nuevo despliegue hoy y funcionó perfectamente! ¡Gracias!

Y usé python 3.14, no 3.10, mayormente sin problemas. Solo necesité añadir una librería más.

Para el paso 11, mi comando se ve así para construir la capa de librerías:

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"

# Comprimirlo en la estructura requerida: zip debe contener la carpeta 'python/' de nivel superior
cd layer
zip -r ../${LAYER_NAME}.zip python
cd ..
echo "Creado: ${LAYER_NAME}.zip"

# Desplegar en 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

Acabo de configurar esto y creo que la entrega de mensajes en bruto debe estar DESABILITADA, no HABILITADA.

Con la entrega de mensajes en bruto habilitada, las notificaciones de rebot de SNS no incluyen los metadatos de SNS que Discourse necesita para validar el mensaje. Mis registros de acceso contenían entradas como esta:

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

es decir, estado HTTP 406, “No aceptable”.

Después de deshabilitar la entrega de mensajes en bruto, los registros de acceso mostraron:

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

es decir, estado HTTP 200, “OK”.