Я решил поделиться конфигурацией, которую я разработал для использования AWS SES для исходящей, bounce- и входящей электронной почты. В сервисе SES есть определённые нюансы, и мне потребовалось немало проб и ошибок, чтобы понять, как именно он работает. Это скорее поток мыслей, чем пошаговая инструкция с чёткими указаниями. Использование описанного ниже может быть излишним, но вы делаете это на свой страх и риск. И, разумеется, всегда внимательно читайте и понимайте любой код, написанный другими, перед его внедрением.
Предыстория:
Я занимаюсь развёртыванием Discourse в AWS и стараюсь использовать все доступные сервисы для обеспечения надёжности и избыточности. Как разработчик, я предпочитаю командную строку и код, поэтому хотел использовать автоматизацию на основе IaC. Вся моя среда разворачивается с помощью Terraform, хотя я также пробовал настраивать параметры через веб-консоль, насколько это возможно. Документы IAM и политики выходят за рамки этой статьи, но я постарался указать, где они необходимы.
Запуск экземпляра Postfix кажется избыточным для одного приложения. Использование почтового ящика POP3 — это очень по-90-м. Поэтому я отправился в кроличью нору AWS.
Я нашёл несколько крайне полезных постов, которые помогли мне в этом поиске:
- AWS SES / AWS Lambda mail receiver endpoint code?
- How to use Amazon SES for sending emails to users?
- Configure VERP to handle bouncing e-mails
Контейнер mail-receiver также помог мне понять, как Discourse обрабатывает сообщения:
- Configure direct-delivery incoming email for self-hosted sites with Mail-Receiver
- Update mail-receiver to the release version
Изначально я ожидал, что вебхук AWS будет обрабатывать входящие сообщения, но после изучения кода понял, что это не так. Мой код приёмника Lambda я основал на отличном примере от @dltj. Я выбрал использование SNS для доставки сообщений вместо S3.
Требования
- Аккаунт AWS
- Базовые знания DNS и типов записей, связанных с электронной почтой
- Домен (или поддомен), в который вы можете вносить изменения
Примечания
- Всё описанное должно быть создано в одном и том же регионе AWS
- Текст, выделенный жирным курсивом, — это ваши специфичные значения реализации
- Текст курсивом — это имена переменных, фиксированные значения или элементы интерфейса
Шаги
-
Создайте доменную идентичность Simple Email Service (SES) для your.domain в одном из регионов AWS, поддерживающих получение электронной почты:
-
Подтвердите доменную идентичность:
-
Создайте топик Simple Notification Service (SNS) с именем feedback-sns-topic для уведомлений об обратной связи.
-
Настройте доменную идентичность your.domain:
a. Включите пересылку обратной связи по электронной почте.
b. Настройте уведомления о bounce и жалобах (не о доставке) для использования топика SNS feedback-sns-topic. -
Создайте подписку на топик SNS feedback-sns-topic:
a. Протокол — HTTPS (вы же всё ещё не используете HTTP, верно?)
b. Установите конечную точку как https://your.domain/webhooks/aws (см. пост о VERP)
c. Выберите опцию включения доставки необработанных сообщений. -
Создайте ещё один топик SNS с именем incoming-sns-topic для входящей электронной почты.
-
Создайте набор правил получения почты SES с именем inbound-mail-set, если ещё нет активного набора. Если такой есть, используйте его, так как активным может быть только один набор правил.
-
Создайте правило получения в наборе правил inbound-mail-set:
a. Установите условие получателя как your.domain.
b. Добавьте действие публикации в топик SNS incoming-sns-topic с кодировкой Base64. -
Создайте API-ключ в вашем экземпляре Discourse для пользователя system, предоставив действие receive email для ресурса email.
-
Создайте секрет в Secret Manager с именем email-handler-secret со следующими ключами и их соответствующими значениями:
- api_endpoint — https://your.domain/admin/email/handle_mail
- api_key — полученный на шаге 9
- api_username — system, если вы не использовали другое значение на шаге 9
-
Создайте слой Lambda с именем lambda-receiver-layer для среды выполнения python3.10, содержащий библиотеки requests и aws-lambda-powertools.
-
Создайте функцию 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)
-
Настройте функцию Lambda email-receiver-lambda:
a. Добавьте слой lambda-receiver-layer.
b. Добавьте специфичный для региона слой для AWS Parameter Store.
c. Добавьте переменную окружения SECRET_NAME со значением email-handler-secret.
d. Если вы хотите видеть дополнительные детали в логах, добавьте переменную окружения POWERTOOLS_LOGGER_LOG_EVENT со значением true. -
Для функции Lambda email-receiver-lambda необходимо предоставить права IAM secretsmanager:GetSecretValue для секрета email-handler-secret.
-
Создайте подписку на топик SNS incoming-sns-topic:
a. Протокол — AWS Lambda.
b. Установите конечную точку как ARN функции email-receiver-lambda. -
Для подписки SNS на топик incoming-sns-topic потребуются права IAM для вызова функции email-receiver-lambda, но, как мне кажется, это будет сделано автоматически при настройке через консоль.
Для целей отладки или просто чтобы следить за уведомлениями, вы можете добавить подписку на электронную почту к любому из топиков SNS.
Я записывал это в несколько приёмов, но, думаю, это всё. Я постараюсь ответить на общие вопросы, как только появится время.
