Configurando AWS SES para email de saída, devoluções e entrada

Eu pensei em compartilhar a configuração que criei para usar o AWS SES para e-mails de saída, de rejeição e de entrada. Definitivamente há algumas nuances no serviço SES, e levou bastante tentativa e erro para entender exatamente como ele funciona. Isso é mais um desabafo do que um guia passo a passo. Deve ser desnecessário, mas use por sua conta e risco. E, por favor, sempre leia e entenda qualquer código escrito por outras pessoas que você implementar.

Contexto:

Estou trabalhando para implantar o Discourse na AWS e utilizar todos os serviços que puder para garantir confiabilidade e redundância. Como desenvolvedor, sinto-me mais confortável com a linha de comando e o código, e queria usar automação de IaC. Todo o meu ambiente está sendo implantado com Terraform, mas tentei clicar no console da web e alinhar as coisas da melhor maneira possível. IAM e documentos de política estão fora do escopo disso, mas acredito que indiquei onde as coisas são necessárias.

Executar uma instância Postfix parece exagero para uma única aplicação. Usar uma caixa de correio POP3 é muito anos 90. Então, mergulhei no universo AWS.

Encontrei algumas postagens extremamente úteis que auxiliaram minha busca:

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

Inicialmente, esperava que o endpoint de webhook da AWS lidasse com mensagens de entrada, mas depois de analisar o código, percebi que não faria isso. Baseei meu código de receptor lambda no excelente exemplo de @dltj. Optei por usar o SNS para entrega de mensagens em vez do S3.

Pré-requisitos

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

Observações

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

Passos

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

  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

  4. Configure a identidade de domínio seu.dominio
    a. Habilite o encaminhamento de feedback de e-mail
    b. Configure notificações de feedback de rejeição e reclamação (não de entrega) para usar o tópico feedback-sns-topic do SNS

  5. Crie uma assinatura no tópico feedback-sns-topic do SNS
    a. O protocolo é HTTPS (você ainda não está usando HTTP, está?)
    b. Defina o endpoint para https://seu.dominio/webhooks/aws (veja o post VERP)
    c. Selecione habilitar entrega de mensagem bruta

  6. Crie outro tópico do SNS, incoming-sns-topic, para e-mail de entrada

  7. Crie um conjunto de regras de recebimento de e-mail do SES, inbound-mail-set, se não houver um 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 do destinatário para seu.dominio
    b. Adicione a ação de publicar no tópico SNS incoming-sns-topic, codificando em Base64

  9. Crie uma chave de API em sua instância 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://seu.dominio/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
#
# 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. 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 AWS Parameter Store
    c. Adicione a variável de ambiente SECRET_NAME com o valor email-handler-secret
    d. Se você quiser detalhes adicionais registrados, adicione a variável de ambiente POWERTOOLS_LOGGER_LOG_EVENT com o valor true

  2. Conceda à função Lambda email-receiver-lambda 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 para o ARN de email-receiver-lambda

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

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

Coloquei isso em algumas sessões, mas acho que é 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