我想分享一下我配置的一套方案,用于通过 AWS SES 处理发件、退信以及收件邮件。SES 服务确实有一些细微之处,我花了不少时间通过反复试验才完全弄清楚它的工作原理。这更像是一次思路的倾泻(brain-dump),而不是那种一步一步跟着虚线走的教程。理论上你不需要这样做,但请自行承担风险。而且,请务必始终仔细阅读并理解你将要实施的任何由他人编写的代码。
背景:
我正在 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 区域中,创建一个 Simple Email Service (SES) 域名身份,your.domain
-
验证域名身份
-
创建一个 Simple Notification Service (SNS) 主题,feedback-sns-topic,用于反馈通知
a. 将 feedback-sns-topic 主题的 ARN 添加到你的aws_sns_topic_arn_allowlist设置中。 -
配置 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 中使用了其他值
-
为 python3.10 运行时创建一个 Lambda 层,lambda-receiver-layer,其中包含 requests 和 aws-lambda-powertools 库
-
为 python3.10 运行时创建一个 Lambda 函数,email-receiver-lambda,并使用以下接收器代码:
# 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 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. 将端点设置为 email-receiver-lambda 的 ARN -
SNS 主题 incoming-sns-topic 上的订阅将需要 IAM 权限来调用 email-receiver-lambda,但我相信通过控制台配置时这会自动完成
为了调试目的,或者仅仅是为了自我折磨,你可以为这两个 SNS 主题中的任意一个添加电子邮件订阅,以监控通知。
我分几次写完了这些,但我认为已经涵盖了所有内容。如果时间允许,我可以尝试回答一些一般性的问题。
