Configurando o AWS SES para e-mails de saída, rejeição e recebimento

Pensei em compartilhar a configuração que elaborei para usar o AWS SES para e-mails de saída, devoluções (bounce) e e-mails recebidos. O serviço SES tem certamente algumas nuances, e foi preciso bastante tentativa e erro para entender exatamente como ele funciona. Isso é mais um despejo de ideias do que um guia passo a passo. Deveria ser desnecessário, mas use por sua conta e risco. E, de qualquer forma, sempre leia e entenda qualquer código escrito por outros que você implementar.

Contexto:

Estou trabalhando para implantar o Discourse na AWS e utilizar todos os seus serviços possíveis para garantir confiabilidade e redundância. Como desenvolvedor, me sinto mais à vontade com a linha de comando e código, e queria usar automação de IaC. Meu ambiente inteiro está sendo implantado com Terraform, mas tentei navegar pela console web e alinhar as coisas da melhor forma possível. IAM e documentos de política estão fora do escopo deste post, mas acredito que indiquei onde são necessários.

Executar uma instância do Postfix parece exagero para uma única aplicação. Usar uma caixa de correio POP3 é coisa dos anos 90. Então, fui fundo no coelho da AWS.

Encontrei alguns posts extremamente úteis que auxiliaram minha busca

O contêiner mail-receiver também me ajudou a entender como o Discourse processa as mensagens

Inicialmente, eu esperava que o endpoint de webhook da AWS lidasse com as mensagens recebidas, mas, após analisar o código, percebi que não o faria. Baseei meu código de receptor Lambda no excelente exemplo de @dltj. Optei por usar o SNS para a entrega de mensagens em vez do S3.

Pré-requisitos

  • Conta AWS
  • Conhecimento prático de DNS e dos tipos de registros relacionados a e-mail
  • Um domínio (ou subdomínio) no qual você possa fazer alterações

Notas

  • Tudo o que está documentado deve ser criado na mesma região AWS
  • O texto em **negrito e itálico como este são valores específicos da sua implementação
  • Texto em itálico são nomes de variáveis, valores fixos ou elementos de interface

Etapas

  1. Crie uma identidade de domínio do Simple Email Service (SES), your.domain, em uma das regiões AWS que suportam recebimento de e-mails

  2. Verifique a identidade do domínio

  3. Crie um tópico do Simple Notification Service (SNS), feedback-sns-topic, para notificações de feedback
    a. Adicione o ARN do tópico feedback-sns-topic à sua configuração aws_sns_topic_arn_allowlist.

  4. Configure a identidade do domínio your.domain
    a. Ative o encaminhamento de feedback de e-mail
    b. Configure as notificações de feedback de bounce e reclamação (não entrega) para usar o tópico SNS feedback-sns-topic

  5. Crie uma assinatura no tópico SNS feedback-sns-topic
    a. O protocolo é HTTPS (você não está usando HTTP, está?)
    b. Defina o endpoint como https://your.domain/webhooks/aws (veja o post sobre VERP)
    c. Certifique-se de que a entrega de mensagem bruta esteja desativada

  6. Crie outro tópico SNS, incoming-sns-topic, para e-mails recebidos

  7. Crie um conjunto de regras de recebimento de e-mail do SES, inbound-mail-set, se não houver um conjunto ativo existente. Se houver, use-o, pois só pode haver um conjunto de regras ativo

  8. Crie uma regra de recebimento no conjunto de regras de recebimento inbound-mail-set
    a. Defina a condição de destinatário como your.domain
    b. Adicione uma ação para publicar no tópico SNS incoming-sns-topic, com codificação Base64

  9. Crie uma chave de API na sua instância do Discourse para o usuário system, concedendo a ação receive email no recurso email

  10. Crie um segredo no Secret Manager, email-handler-secret, com as seguintes chaves e seus respectivos valores:

    • api_endpoint - https://your.domain/admin/email/handle_mail
    • api_key - da etapa 9
    • api_username - system, a menos que você tenha usado algo diferente na etapa 9
  11. Crie uma camada Lambda, lambda-receiver-layer, para o runtime python3.10 contendo as bibliotecas requests e aws-lambda-powertools

  12. Crie uma função Lambda, email-receiver-lambda, para o runtime python3.10 com o código do receptor:

# Copyright (c) 2023 Derek J. Lambert
#
# A permissão é hereby concedida, gratuitamente, a qualquer pessoa que obtenha uma cópia
# deste software e dos arquivos de documentação associados (o "Software"), para lidar
# com o Software sem restrição, incluindo, sem limitação, os direitos
# de usar, copiar, modificar, mesclar, publicar, distribuir, sublicenciar e/ou vender
# cópias do Software, e para permitir às pessoas a quem o Software é
# fornecido para fazer o mesmo, sujeito às seguintes condições:
#
# A notificação de copyright acima e esta notificação de permissão devem ser incluídas em todas
# as cópias ou porções substanciais do Software.
#
# O SOFTWARE É FORNECIDO "COMO ESTÁ", SEM GARANTIA DE QUALQUER TIPO, EXPRESSA OU
# IMPLÍCITA, INCLUINDO, MAS NÃO SE LIMITANDO A, AS GARANTIAS DE COMERCIALIZAÇÃO,
# ADEQUAÇÃO A UM FIM ESPECÍFICO E NÃO VIOLAÇÃO. EM NENHUM CASO OS
# AUTORES OU TITULARES DE DIREITOS AUTORAIS SERÃO RESPONSÁVEIS POR QUALQUER REIVINDICAÇÃO, DANOS OU OUTRA
# RESPONSABILIDADE, SEJA EM UMA AÇÃO DE CONTRATO, TORT OU OUTRA, DECORRENTE DE,
# EM CONEXÃO COM O SOFTWARE OU O USO OU OUTRAS NEGOCIAÇÕES NO
# 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'Variável de ambiente {e} ausente')

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'Chave {exc} ausente na mensagem')

    try:
        receipt_action = message_receipt['action']
    except KeyError as exc:
        raise RuntimeError(f'Chave {exc} ausente no recibo')

    try:
        action_encoding = receipt_action['encoding']
    except KeyError as exc:
        raise RuntimeError(f'Chave {exc} ausente na ação')

    try:
        mail_source      = message_mail['source']
        mail_destination = ','.join(message_mail['destination'])
    except KeyError as exc:
        raise RuntimeError(f'Chave {exc} ausente no e-mail')

    logger.info(f'Processando registro SNS {message_type} {sns.get_type} com MessageId {sns.message_id} de {mail_source} para {mail_destination}')

    # 'email' está obsoleto, mas apenas por precaução caso algo esteja configurado incorretamente
    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. Configure a função Lambda email-receiver-lambda:
    a. Adicione a camada lambda-receiver-layer
    b. Adicione a camada específica da região para o AWS Parameter Store
    c. Adicione a variável de ambiente SECRET_NAME com o valor email-handler-secret
    d. Se você quiser mais detalhes registrados, adicione a variável de ambiente POWERTOOLS_LOGGER_LOG_EVENT com o valor true

  2. Conceda à função Lambda email-receiver-lambda a permissão IAM secretsmanager:GetSecretValue para o segredo email-handler-secret

  3. Crie uma assinatura no tópico SNS incoming-sns-topic
    a. O protocolo é AWS Lambda
    b. Defina o endpoint como o ARN de email-receiver-lambda

  4. Serão necessárias permissões IAM para que a assinatura do SNS no tópico incoming-sns-topic possa invocar email-receiver-lambda, mas acredito que isso será feito automaticamente quando configurado através da console

Para fins de depuração ou para auto-incomodação geral, você pode adicionar uma assinatura de e-mail a qualquer um dos tópicos SNS para monitorar as notificações.

Escrevi isso em algumas sessões, mas acho que está tudo. Posso tentar responder a perguntas gerais conforme o tempo permitir.

9 curtidas

Atualizações à postagem original

Versão 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 curtida

Obrigado por este guia @dlambert :smiley:

Eu estava indo muito bem, até chegar à etapa 11:

Onde / como eu crio isso? :thinking:

Você conseguiu fazer funcionar?

Eu também fico preso na etapa 11. Não sei o que fazer a seguir. Alguém poderia ajudar?

1 curtida

Não, desculpe, desisti e desativamos toda a funcionalidade de resposta por e-mail, usando o SES apenas para e-mails de saída simples :cry:

Tentei seguir todos os passos para configurar, mas no final, estou recebendo este erro no Cloudwatch, alguém pode me ajudar com isso?

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

Ok, foi porque o Cloudflare desabilitou que o problema foi resolvido. Talvez mais tarde eu escreva aqui como fiz funcionar seguindo todos os passos. :slight_smile:

1 curtida

Este é o que eu fiz.

Instalei o Python 3.10 no meu PC, após a etapa 10.

Em seguida, executei estes comandos.

mkdir lambda-receiver-layer

cd lambda-receiver-layer

mkdir python

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

touch ./python/__init__.py

Como tive problemas com urllib3

Aqui estão etapas adicionais para que você não receba esse erro.

No seu diretório lambda-receiver-layer, crie este arquivo requirements.txt

adicione a seguinte linha neste arquivo requirements.txt:

urllib3<2

Em seguida, execute o seguinte comando:

pip install -r requirements.txt -t layer

Agora, outra pasta será criada dentro do diretório lambda-receiver-layer chamada layer

Copie todo o conteúdo de layer para a pasta python

Agora, clique com o botão direito na pasta Python e clique em ‘Compactar para ZIP’, renomeie este zip para lambda-receiver-layer

Agora, volte ao Console de Gerenciamento da AWS, vá para o serviço Lambda e navegue até “Layers”. Clique em “Create Layer”, coloque lambda-receiver-layer no nome e carregue o arquivo zip que você criou. Em runtime, adicione Python 3.10 e clique em criar.

Agora, siga de volta a partir da etapa 12 da postagem original.

Estou travado na etapa 11, onde devo colar o código Python?

Preciso de ajuda urgente para corrigir meus Bounces de SMTP em várias instâncias, postei um trabalho no Marketplace Fix AWS SNS Bounce

Estou preso no ponto 14, alguém pode esclarecer o que preciso fazer?

Se alguém em 2025 estiver se perguntando se a versão 2 ainda funciona, posso confirmar que sim.

Alguns soluços que você pode encontrar:

  • Certifique-se de que está configurando os conjuntos de regras em Configuração > Recebimento de e-mail no console, e não os conjuntos de regras em Gerenciador de E-mail > Conjuntos de regras. As coisas do Gerenciador de E-mail custam muito dinheiro, especialmente com esses pontos de extremidade de entrada.
  • Você precisa de um registro MX em seu DNS para receber e-mails de resposta para enviar para o AWS SES. Se você já possui um registro MX para seu domínio raiz para coisas gerais de e-mail (ou seja, usando e-mails do Google Workspace para coisas gerais de negócios para um endereço como contato@exemplo.com), você vai querer usar um subdomínio para suas respostas. No meu caso, criei um registro MX em resposta.exemplo.com para enviar as respostas para inbound-smtp.<REGIÃO>.amazonaws.com. Veja esta documentação para mais detalhes.
  • Você pode usar o CloudWatch para ver como as coisas estão funcionando. Se você vir um erro onde uma determinada biblioteca/módulo não está carregando, você provavelmente configurou incorretamente sua Camada Lambda ou não a conectou à função. Verifique se o arquivo ZIP que você carrega tem a estrutura de diretório correta que se parece com python/lib/python3.10/site-packages/; veja esta documentação. Recomendo apenas procurar alguns tutoriais online sobre como criar uma Camada Lambda.

O código ainda funciona com ARM64 - você só precisa configurar sua camada Lambda com a arquitetura correta baixando as bibliotecas Python baseadas em ARM.

Quando tudo estiver dito e feito, você deverá ver os e-mails recebidos em seus logs de administrador.

1 curtida

Segui este guia usando a v2 em uma nova implantação hoje e funcionou perfeitamente! Obrigado!

E usei python 3.14, não 3.10, na maior parte sem problemas. Só precisei adicionar mais uma biblioteca.

Para a etapa 11, meu comando se parece com isto para construir a camada de bibliotecas:

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"

# Compacta na estrutura necessária: o zip deve conter a pasta 'python/' de nível superior
cd layer
zip -r ../${LAYER_NAME}.zip python
cd ..
echo "Criado: ${LAYER_NAME}.zip"

# Implanta no 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

Acabei de configurar isso e acredito que a entrega de mensagem bruta deve estar DESABILITADA, não HABILITADA.

Com a entrega de mensagem bruta habilitada, as notificações de rejeição do SNS não incluem os metadados do SNS que o discourse requer para validar a mensagem. Meus logs de acesso continham entradas como esta:

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

ou seja, status HTTP 406, “Não Aceitável”.

Após desabilitar a entrega de mensagem bruta, os logs de acesso mostraram:

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

ou seja, status HTTP 200, “OK”.