Настройка AWS SES для исходящей, bounce и входящей почты

Я решил поделиться конфигурацией, которую я разработал для использования AWS SES для исходящей, bounce- и входящей электронной почты. В сервисе SES есть определённые нюансы, и мне потребовалось немало проб и ошибок, чтобы понять, как именно он работает. Это скорее поток мыслей, чем пошаговая инструкция с чёткими указаниями. Использование описанного ниже может быть излишним, но вы делаете это на свой страх и риск. И, разумеется, всегда внимательно читайте и понимайте любой код, написанный другими, перед его внедрением.

Предыстория:

Я занимаюсь развёртыванием Discourse в AWS и стараюсь использовать все доступные сервисы для обеспечения надёжности и избыточности. Как разработчик, я предпочитаю командную строку и код, поэтому хотел использовать автоматизацию на основе IaC. Вся моя среда разворачивается с помощью Terraform, хотя я также пробовал настраивать параметры через веб-консоль, насколько это возможно. Документы IAM и политики выходят за рамки этой статьи, но я постарался указать, где они необходимы.

Запуск экземпляра Postfix кажется избыточным для одного приложения. Использование почтового ящика POP3 — это очень по-90-м. Поэтому я отправился в кроличью нору AWS.

Я нашёл несколько крайне полезных постов, которые помогли мне в этом поиске:

Контейнер mail-receiver также помог мне понять, как Discourse обрабатывает сообщения:

Изначально я ожидал, что вебхук AWS будет обрабатывать входящие сообщения, но после изучения кода понял, что это не так. Мой код приёмника Lambda я основал на отличном примере от @dltj. Я выбрал использование SNS для доставки сообщений вместо S3.

Требования

  • Аккаунт AWS
  • Базовые знания DNS и типов записей, связанных с электронной почтой
  • Домен (или поддомен), в который вы можете вносить изменения

Примечания

  • Всё описанное должно быть создано в одном и том же регионе AWS
  • Текст, выделенный жирным курсивом, — это ваши специфичные значения реализации
  • Текст курсивом — это имена переменных, фиксированные значения или элементы интерфейса

Шаги

  1. Создайте доменную идентичность Simple Email Service (SES) для your.domain в одном из регионов AWS, поддерживающих получение электронной почты:

  2. Подтвердите доменную идентичность:

  3. Создайте топик Simple Notification Service (SNS) с именем feedback-sns-topic для уведомлений об обратной связи.

  4. Настройте доменную идентичность your.domain:
    a. Включите пересылку обратной связи по электронной почте.
    b. Настройте уведомления о bounce и жалобах (не о доставке) для использования топика SNS feedback-sns-topic.

  5. Создайте подписку на топик SNS feedback-sns-topic:
    a. Протокол — HTTPS (вы же всё ещё не используете HTTP, верно?)
    b. Установите конечную точку как https://your.domain/webhooks/aws (см. пост о VERP)
    c. Выберите опцию включения доставки необработанных сообщений.

  6. Создайте ещё один топик SNS с именем incoming-sns-topic для входящей электронной почты.

  7. Создайте набор правил получения почты SES с именем inbound-mail-set, если ещё нет активного набора. Если такой есть, используйте его, так как активным может быть только один набор правил.

  8. Создайте правило получения в наборе правил inbound-mail-set:
    a. Установите условие получателя как your.domain.
    b. Добавьте действие публикации в топик SNS incoming-sns-topic с кодировкой Base64.

  9. Создайте API-ключ в вашем экземпляре Discourse для пользователя system, предоставив действие receive email для ресурса email.

  10. Создайте секрет в Secret Manager с именем email-handler-secret со следующими ключами и их соответствующими значениями:

    • api_endpointhttps://your.domain/admin/email/handle_mail
    • api_key — полученный на шаге 9
    • api_usernamesystem, если вы не использовали другое значение на шаге 9
  11. Создайте слой Lambda с именем lambda-receiver-layer для среды выполнения python3.10, содержащий библиотеки requests и aws-lambda-powertools.

  12. Создайте функцию Lambda с именем email-receiver-lambda для среды выполнения python3.10 с кодом приёмника:

# 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. Настройте функцию Lambda email-receiver-lambda:
    a. Добавьте слой lambda-receiver-layer.
    b. Добавьте специфичный для региона слой для AWS Parameter Store.
    c. Добавьте переменную окружения SECRET_NAME со значением email-handler-secret.
    d. Если вы хотите видеть дополнительные детали в логах, добавьте переменную окружения POWERTOOLS_LOGGER_LOG_EVENT со значением true.

  2. Для функции Lambda email-receiver-lambda необходимо предоставить права IAM secretsmanager:GetSecretValue для секрета email-handler-secret.

  3. Создайте подписку на топик SNS incoming-sns-topic:
    a. Протокол — AWS Lambda.
    b. Установите конечную точку как ARN функции email-receiver-lambda.

  4. Для подписки SNS на топик incoming-sns-topic потребуются права IAM для вызова функции email-receiver-lambda, но, как мне кажется, это будет сделано автоматически при настройке через консоль.

Для целей отладки или просто чтобы следить за уведомлениями, вы можете добавить подписку на электронную почту к любому из топиков SNS.

Я записывал это в несколько приёмов, но, думаю, это всё. Я постараюсь ответить на общие вопросы, как только появится время.

9 лайков

Обновления к исходному посту

Версия 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 лайк

Спасибо за это руководство, @dlambert :smiley:

У меня всё шло отлично, пока я не дошёл до шага 11:

Где и как это создать? :thinking:

У вас получилось это настроить?

Я тоже застрял на шаге 11. Не знаю, что делать дальше. Кто-нибудь может помочь?

1 лайк

Нет, извините, мы сдались и отключили всю функциональность ответов по электронной почте, используя SES только для простой исходящей почты :cry:

Я попытался выполнить все шаги по настройке, но в итоге получил эту ошибку в CloudWatch. Кто-нибудь может помочь?

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

Хорошо, проблема была в том, что Cloudflare отключил решение. Возможно, позже я напишу здесь, как мне удалось всё запустить, выполнив все шаги. :slight_smile:

1 лайк

Вот что я сделал.

После шага 10 установил Python 3.10 на свой ПК.

Затем выполнил следующие команды:

mkdir lambda-receiver-layer

cd lambda-receiver-layer

mkdir python

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

touch ./python/__init__.py

У меня возникли проблемы с urllib3.

Вот дополнительные шаги, чтобы избежать этой ошибки.

В директории lambda-receiver-layer создайте файл requirements.txt.

Добавьте в этот файл requirements.txt следующую строку:

urllib3<2

Затем выполните следующую команду:

pip install -r requirements.txt -t layer

Теперь внутри директории lambda-receiver-layer будет создана новая папка с именем layer.

Скопируйте все содержимое папки layer в папку python.

Теперь щелкните правой кнопкой мыши по папке Python и выберите «Сжать в ZIP», затем переименуйте этот zip-файл в lambda-receiver-layer.

Теперь вернитесь в консоль управления AWS, перейдите в службу Lambda и откройте раздел «Слои». Нажмите «Создать слой», укажите имя lambda-receiver-layer и загрузите созданный вами zip-архив. В поле «Среда выполнения» выберите Python 3.10, затем нажмите «Создать».

Теперь вернитесь к шагу 12 из оригинального поста.

Я застрял на шаге 11. Куда нужно вставить код на Python?

Мне срочно нужна помощь в устранении проблем с возвратами SMTP в нескольких случаях. Я разместил вакансию на Marketplace Fix AWS SNS Bounce

Я застрял на пункте 14, кто-нибудь может прояснить, что мне нужно сделать?

Если кто-то в 2025 году сомневается, работает ли версия 2, могу подтвердить: да, работает.

Вот несколько возможных проблем, с которыми вы можете столкнуться:

  • Убедитесь, что настраиваете наборы правил в разделе Configuration > Email receiving в консоли, а не наборы правил в разделе Mail Manager > Rule sets. Функции Mail Manager стоят дорого, особенно с такими ingress endpoints.
  • Для получения ответов и отправки их в AWS SES вам нужна MX-запись в DNS. Если у вашей корневой доменной зоны уже есть MX-запись для общей почты (например, вы используете Google Workspace для бизнес-адреса вроде contact@example.com), для ответов лучше использовать поддомен. В моём случае я создал MX-запись для reply.example.com, чтобы пересылать ответы на inbound-smtp.<REGION>.amazonaws.com. Подробнее см. эту документацию.
  • Для отслеживания работы используйте CloudWatch. Если видите ошибку о том, что определённая библиотека/модуль не загружается, скорее всего, вы неправильно настроили Lambda Layer или не подключили его к функции. Проверьте, что загруженный ZIP-файл имеет правильную структуру каталогов вида python/lib/python3.10/site-packages/; см. эту документацию. Рекомендую также найти онлайн-уроки по созданию Lambda Layer.

Код по-прежнему работает на архитектуре ARM64 — вам просто нужно настроить Lambda Layer с правильной архитектурой, загрузив ARM-версии библиотек Python.

В итоге вы должны увидеть полученные письма в логах администратора.

1 лайк

Я следовал этому руководству с использованием v2 на новом развертывании сегодня, и всё сработало отлично! Спасибо!

Я использовал Python 3.14, а не 3.10, и в основном всё прошло без проблем. Нужно было добавить только одну дополнительную библиотеку.

Для шага 11 моя команда для создания слоя библиотек выглядит так:

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"

# Упаковать в требуемую структуру: zip должен содержать папку 'python/' верхнего уровня
cd layer
zip -r ../${LAYER_NAME}.zip python
cd ..
echo "Создан: ${LAYER_NAME}.zip"

# Развернуть в 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