Configurare AWS SES per email in uscita, bounce e in arrivo

Ho pensato di condividere la configurazione che ho trovato per utilizzare AWS SES per le email in uscita, i bounce e quelle in entrata. Ci sono sicuramente delle sfumature nel servizio SES, e ci è voluto un bel po’ di tentativi ed errori per capire esattamente come funziona. Questo è più uno sfogo che una guida passo passo. Dovrebbe essere superfluo, ma usalo a tuo rischio e pericolo. E, per carità, leggi e comprendi sempre qualsiasi codice scritto da altri che implementi.

Contesto:

Sto lavorando per distribuire Discourse in AWS e utilizzare tutti i servizi possibili per garantire affidabilità e ridondanza. Come sviluppatore, sono più a mio agio con la riga di comando e il codice, e volevo usare IaC automation. Tutto il mio ambiente viene distribuito con Terraform, ma ho cercato di fare clic attraverso la console web e allineare le cose al meglio delle mie capacità. IAM e i documenti delle policy sono al di fuori dello scopo di questo, ma credo di aver indicato dove sono necessarie le cose.

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

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

Il container mail-receiver mi ha anche aiutato a capire come Discourse elabora i messaggi:

Inizialmente mi aspettavo che l’endpoint webhook di AWS gestisse i messaggi in arrivo, ma dopo aver esaminato il codice ho capito che non lo avrebbe fatto. Ho basato il mio codice di ricezione lambda sull’eccellente esempio di @dltj. Ho scelto di utilizzare SNS per la consegna dei messaggi invece di S3.

Prerequisiti

  • Account AWS
  • Conoscenza pratica del DNS e dei tipi di record relativi alle email
  • 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 corsivo come questo sono i tuoi valori specifici di implementazione
  • Il testo in corsivo sono nomi di variabili, valori fissi o elementi dell’interfaccia utente

Passaggi

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

  2. Verifica l’identità del dominio

  3. Crea un argomento Simple Notification Service (SNS), feedback-sns-topic, per le notifiche di feedback

  4. Configura l’identità del dominio tuo.dominio
    a. Abilita l’inoltro del feedback email
    b. Configura le notifiche di feedback di bounce e reclamo (non di consegna) per utilizzare l’argomento SNS feedback-sns-topic

  5. Crea una sottoscrizione sull’argomento SNS feedback-sns-topic
    a. Il protocollo è HTTPS (non stai ancora usando HTTP, vero?)
    b. Imposta l’endpoint su https://tuo.dominio/webhooks/aws (vedi post VERP)
    c. Seleziona abilita la consegna del messaggio grezzo

  6. Crea un altro argomento SNS, incoming-sns-topic, per le email in arrivo

  7. Crea un set di regole di ricezione email SES, inbound-mail-set, se non ne esiste già uno attivo. In caso affermativo, usalo poiché può esserci un solo set di regole attivo

  8. Crea una regola di ricezione nel set di regole di ricezione inbound-mail-set
    a. Imposta la condizione del destinatario su tuo.dominio
    b. Aggiungi l’azione per pubblicare sull’argomento SNS incoming-sns-topic, codificando Base64

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

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

    • api_endpoint - https://tuo.dominio/admin/email/handle_mail
    • api_key - dal passaggio 9
    • api_username - system, a meno che tu non ne abbia usato uno diverso nel passaggio 9
  11. Crea un layer Lambda, lambda-receiver-layer, per il runtime python3.10 contenente le librerie requests e aws-lambda-powertools

  12. Crea 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. Configura la funzione lambda email-receiver-lambda:
    a. Aggiungi il layer lambda-receiver-layer
    b. Aggiungi il layer specifico della regione per AWS Parameter Store
    c. Aggiungi la variabile d’ambiente SECRET_NAME con il valore email-handler-secret
    d. Se desideri dettagli aggiuntivi registrati, aggiungi la variabile d’ambiente POWERTOOLS_LOGGER_LOG_EVENT con valore true

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

  3. Crea una sottoscrizione sull’argomento SNS incoming-sns-topic
    a. Il protocollo è AWS Lambda
    b. Imposta l’endpoint sull’ARN di email-receiver-lambda

  4. Saranno necessari permessi IAM per la sottoscrizione SNS sull’argomento incoming-sns-topic per invocare email-receiver-lambda, ma credo che questo verrà fatto automaticamente quando configurato tramite la console

A scopo di debug, o per auto-fastidio generale, puoi aggiungere una sottoscrizione email a uno dei due argomenti SNS per monitorare le notifiche.

Ho messo giù questo in un paio di sessioni, ma credo sia tutto. Posso provare a rispondere a domande generali quando il tempo lo permette.

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