我将分享我为使用 AWS SES 进行出站、退信和入站电子邮件而配置的设置。SES 服务确实存在一些细微之处,经过大量的试错才完全理解其工作原理。这更像是一次思路的倾泻,而不是循序渐进的指南。理论上是不必要的,但请自行承担风险。而且,务必仔细阅读并理解您实现的任何他人编写的代码。
背景:
我正在 AWS 中部署 Discourse,并尽可能利用其所有服务来确保可靠性和冗余。作为一名开发人员,我更熟悉命令行和代码,并希望使用 IaC 自动化。我的整个环境都使用 Terraform 进行部署,但我尝试通过 Web 控制台进行点击并尽可能地进行配置。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 Webhook 端点能够处理入站消息,但在通读代码后,我意识到它不能。我的 Lambda 接收器代码基于 @dltj 的 优秀示例。我选择使用 SNS 来传递消息,而不是 S3。
先决条件
- AWS 账户
- 熟悉 DNS 和电子邮件相关记录类型
- 一个可以进行更改的域名(或子域名)
注意事项
- 所有记录的内容都必须在同一个 AWS 区域中创建
- 粗斜体文本 例如此文本 是您特定于实现的数值
- 斜体文本 是变量名、固定值或 UI 元素
步骤
-
在支持电子邮件接收的 AWS 区域之一中,创建一个简单的电子邮件服务 (SES) 域名身份,your.domain
-
验证域名身份
-
创建一个简单的通知服务 (SNS) 主题,feedback-sns-topic,用于反馈通知
-
配置 your.domain 域名身份
a. 启用电子邮件反馈转发
b. 配置退信和投诉(非送达)反馈通知,使其使用 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 -
在您的 Discourse 实例中为用户 system 创建 API 密钥,授予对 email 资源的 receive 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)
-
配置 email-receiver-lambda Lambda 函数:
a. 添加层 lambda-receiver-layer
b. 添加区域特定的层以支持 AWS 参数存储
c. 添加环境变量 SECRET_NAME,值为 email-handler-secret
d. 如果您想记录更多详细信息,请添加环境变量 POWERTOOLS_LOGGER_LOG_EVENT,值为 true -
授予 Lambda 函数 email-receiver-lambda 对密钥 email-handler-secret 的 IAM 权限 secretsmanager:GetSecretValue
-
在 SNS 主题 incoming-sns-topic 上创建一个订阅
a. 协议是 AWS Lambda
b. 将端点设置为 email-receiver-lambda 的 ARN -
SNS 订阅 incoming-sns-topic 主题需要 IAM 权限来调用 email-receiver-lambda,但我认为通过控制台配置时会自动完成
为了调试或一般性的自我烦恼,您可以向任一 SNS 主题添加电子邮件订阅来监控通知。
我分几次写完了这些内容,但我想这应该涵盖了所有内容。在时间允许的情况下,我可以尝试回答一般性问题。
