Configurazione di AWS SES per email in uscita, rimbalzi e in entrata

Ho pensato di condividere la configurazione che ho elaborato per utilizzare AWS SES per le email in uscita, i rimbalzi e le email in ingresso. Il servizio SES presenta sicuramente alcune sfumature e ci è voluto un bel po’ di tentativi ed errori per capire esattamente come funziona. Questo è più un “brain dump” (scarico di pensieri) che una guida passo-passo da seguire alla lettera. Dovrebbe essere superfluo, ma usatelo a vostro rischio e pericolo. E, in ogni caso, leggete sempre e capite qualsiasi codice scritto da altri che decidiate di implementare.

Contesto:

Sto lavorando per distribuire Discourse su AWS e sfruttare tutti i loro servizi possibili per garantire affidabilità e ridondanza. In quanto sviluppatore, mi sento più a mio agio con la riga di comando e il codice, e volevo utilizzare l’automazione IaC. Tutta la mia infrastruttura viene distribuita con Terraform, ma ho provato a navigare nella console web e ad allineare le cose il meglio possibile. IAM e i documenti di policy sono fuori dallo scope di questo articolo, ma credo di aver indicato dove sono necessari.

Eseguire un’istanza Postfix sembra eccessivo per una singola applicazione. Usare una casella di posta POP3 è così anni '90. Così mi sono tuffato nel coniglio di AWS.

Ho trovato alcuni post estremamente utili che mi hanno aiutato nella mia ricerca:

Il contenitore mail-receiver mi ha anche aiutato a capire come Discourse elabori i messaggi:

Inizialmente mi aspettavo che l’endpoint webhook di AWS gestisse i messaggi in ingresso, ma dopo aver esaminato il codice ho realizzato che non lo avrebbe fatto. Ho basato il mio codice del ricevitore Lambda sull’eccellente esempio di @dltj. Ho optato per utilizzare SNS per la consegna dei messaggi invece di S3.

Prerequisiti

  • Account AWS
  • Conoscenza operativa di DNS e dei tipi di record relativi alla posta elettronica
  • Un dominio (o sottodominio) in cui è possibile apportare modifiche

Note

  • Tutto ciò che è documentato deve essere creato nella stessa regione AWS
  • Il testo in **grassetto e corsivo come questo rappresenta i valori specifici della vostra implementazione
  • Il testo in corsivo rappresenta i nomi di variabili, valori fissi o elementi dell’interfaccia utente

Passaggi

  1. Creare un’identità di dominio Simple Email Service (SES), your.domain, in una delle regioni AWS che supportano la ricezione di email

  2. Verificare l’identità del dominio

  3. Creare un argomento Simple Notification Service (SNS), feedback-sns-topic, per le notifiche di feedback
    a. Aggiungere l’ARN dell’argomento feedback-sns-topic alla vostra impostazione aws_sns_topic_arn_allowlist.

  4. Configurare l’identità del dominio your.domain
    a. Abilitare l’inoltro del feedback delle email
    b. Configurare le notifiche di feedback per rimbalzi e reclami (non consegne) per utilizzare l’argomento SNS feedback-sns-topic

  5. Creare un’abbonamento sull’argomento SNS feedback-sns-topic
    a. Il protocollo è HTTPS (non state ancora usando HTTP, vero?)
    b. Impostare l’endpoint su https://your.domain/webhooks/aws (vedi post VERP)
    c. Assicurarsi che la consegna del messaggio grezzo sia disabilitata

  6. Creare un altro argomento SNS, incoming-sns-topic, per le email in ingresso

  7. Creare un set di regole di ricezione email SES, inbound-mail-set, se non ne esiste già uno attivo. Se ne esiste uno, usatelo, poiché può esserci solo un set di regole attivo alla volta

  8. Creare una regola di ricezione nel set di regole di ricezione inbound-mail-set
    a. Impostare la condizione del destinatario su your.domain
    b. Aggiungere un’azione per pubblicare sull’argomento SNS incoming-sns-topic, con codifica Base64

  9. Creare una chiave API nella vostra istanza Discourse per l’utente system, concedendo l’azione receive email sulla risorsa email

  10. Creare un segreto in Secret Manager, email-handler-secret, con le seguenti chiavi e i rispettivi valori:

    • api_endpoint - https://your.domain/admin/email/handle_mail
    • api_key - dal passaggio 9
    • api_username - system, a meno che non si sia usato qualcosa di diverso nel passaggio 9
  11. Creare un layer Lambda, lambda-receiver-layer, per il runtime python3.10 contenente le librerie requests e aws-lambda-powertools

  12. Creare una funzione Lambda, email-receiver-lambda, per il runtime python3.10 con il codice del ricevitore:

# 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. Configurare la funzione Lambda email-receiver-lambda:
    a. Aggiungere il layer lambda-receiver-layer
    b. Aggiungere il layer specifico per la regione per AWS Parameter Store
    c. Aggiungere la variabile d’ambiente SECRET_NAME con il valore email-handler-secret
    d. Se si desidera che vengano registrate informazioni aggiuntive, aggiungere la variabile d’ambiente POWERTOOLS_LOGGER_LOG_EVENT con il valore true

  2. Concedere alla funzione Lambda email-receiver-lambda il permesso IAM secretsmanager:GetSecretValue per il segreto email-handler-secret

  3. Creare un abbonamento sull’argomento SNS incoming-sns-topic
    a. Il protocollo è AWS Lambda
    b. Impostare l’endpoint sull’ARN di email-receiver-lambda

  4. Saranno necessari permessi IAM per l’abbonamento SNS sull’argomento incoming-sns-topic per invocare email-receiver-lambda, ma credo che ciò venga fatto automaticamente quando configurato tramite la console

A scopo di debug, o per semplice auto-irritazione, è possibile aggiungere un abbonamento email a uno dei due argomenti SNS per monitorare le notifiche.

Ho scritto tutto questo in un paio di sedute, ma credo che sia tutto completo. Posso provare a rispondere a domande generali quando il tempo me lo consente.

9 Mi Piace

Aggiornamenti al post originale

Versione 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 Mi Piace

Grazie per questa guida @dlambert :smiley:

Stavo andando alla grande, finché non sono arrivato al passaggio 11:

Dove / come lo creo? :thinking:

Ci sei riuscito?

Anche io mi blocco al passaggio 11. Non so cosa fare dopo. Qualcuno potrebbe aiutarmi?

1 Mi Piace

No, mi dispiace, ho rinunciato e abbiamo disabilitato tutte le funzionalità di risposta via email, utilizzando SES solo per l’invio di email in uscita :cry:

Ho provato a seguire tutti i passaggi per la configurazione ma alla fine ricevo questo errore in Cloudwatch qualcuno può aiutarmi?

[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, è stato perché Cloudflare disabilitato ha risolto il problema. Forse più tardi scriverò qui come ho fatto a farlo funzionare seguendo tutti i passaggi. :slight_smile:

1 Mi Piace

Questo è quello che ho fatto.

Ho installato Python 3.10 sul mio PC, dopo il passaggio 10.

Quindi ho eseguito questi comandi.

mkdir lambda-receiver-layer

cd lambda-receiver-layer

mkdir python

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

touch ./python/__init__.py

Poiché ho avuto problemi con urllib3

Ecco i passaggi aggiuntivi per evitare questo errore.

Nella tua directory lambda-receiver-layer crea questo file requirements.txt

aggiungi la seguente riga in questo file requirements.txt:

urllib3<2

Quindi esegui il seguente comando

pip install -r requirements.txt -t layer

Ora verrà creata un’altra cartella all’interno della directory lambda-receiver-layer chiamata layer

Copia tutto il contenuto di layer nella cartella python

Ora, fai clic con il pulsante destro del mouse sulla cartella Python e fai clic su “Comprimi in ZIP”, rinomina questo zip in lambda-receiver-layer

Ora, torna alla console di gestione di AWS, vai al servizio Lambda e naviga in “Layers”. Fai clic su “Create Layer”, inserisci questo nel nome lambda-receiver-layer e carica l’archivio zip che hai creato. In runtime aggiungi Python 3.10 quindi fai clic su crea.

Ora segui nuovamente dal passaggio 12 del post originale.

Mi blocco al passaggio 11, dove devo incollare il codice Python?

Ho bisogno di un aiuto urgente per correggere i miei rimbalzi SMTP in diverse istanze, ho pubblicato un lavoro su Marketplace Fix AWS SNS Bounce

Sono bloccato al punto 14, qualcuno può chiarire cosa devo fare?

Se qualcuno nel 2025 si stesse chiedendo se la versione 2 funziona ancora, posso confermare che lo fa.

Alcuni intoppi che potresti incontrare:

  • Assicurati di configurare i set di regole in Configurazione > Ricezione email nella console, non i set di regole in Gestione posta > Set di regole. Le cose di Gestione posta costano molti soldi, specialmente con quegli endpoint in entrata.
  • Hai bisogno di un record MX nel tuo DNS per ricevere le email di risposta da inviare ad AWS SES. Se hai già un record MX per il tuo dominio principale per cose email generali (ad esempio, l’utilizzo di email di Google Workspace per cose aziendali generali per un indirizzo come contact@example.com), vorrai usare un sottodominio per le tue risposte. Nel mio caso, ho creato un record MX su reply.example.com per inviare le risposte a inbound-smtp.<REGION>.amazonaws.com. Dai un’occhiata a questa documentazione per maggiori dettagli.
  • Puoi usare CloudWatch per vedere come funzionano le cose. Se vedi un errore in cui una certa libreria/modulo non viene caricato, probabilmente hai configurato male il tuo Lambda Layer o non l’hai collegato alla funzione. Controlla che il file ZIP che carichi abbia la struttura di directory corretta che assomiglia a python/lib/python3.10/site-packages/; vedi questa documentazione. Consiglio di cercare alcuni tutorial online sulla creazione di un Lambda Layer.

Il codice funziona ancora con ARM64: devi solo configurare il tuo Lambda Layer con l’architettura corretta scaricando le librerie Python basate su ARM.

Alla fine, dovresti vedere le email ricevute nei tuoi log di amministrazione.

1 Mi Piace

Ho seguito questa guida usando la v2 su una nuova implementazione oggi e ha funzionato perfettamente! Grazie!

E ho usato python 3.14, non 3.10, per lo più senza problemi. Ho solo dovuto aggiungere un’altra libreria.

Per il passaggio 11, il mio comando è simile a questo per creare il livello delle librerie:

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"

# Comprimilo nella struttura richiesta: lo zip deve contenere la cartella 'python/' di primo livello
cd layer
zip -r ../${LAYER_NAME}.zip python
cd ..
echo "Creato: ${LAYER_NAME}.zip"

# Distribuisci su 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

Ho appena configurato questo, e credo che la consegna di messaggi grezzi (raw message delivery) debba essere DISABILITATA, non ABILITATA.

Con la consegna di messaggi grezzi abilitata, le notifiche di rimbalzo di SNS non includono i metadati di SNS che discourse richiede per convalidare il messaggio. I miei log di accesso contenevano voci come questa:

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

ovvero stato HTTP 406, “Not Acceptable”.

Dopo aver disabilitato la consegna di messaggi grezzi, i log di accesso mostravano:

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

ovvero stato HTTP 200, “OK”.