配置 AWS SES 用于发送、退信和接收邮件

我想分享一下我配置的一套方案,用于通过 AWS SES 处理发件、退信以及收件邮件。SES 服务确实有一些细微之处,我花了不少时间通过反复试验才完全弄清楚它的工作原理。这更像是一次思路的倾泻(brain-dump),而不是那种一步一步跟着虚线走的教程。理论上你不需要这样做,但请自行承担风险。而且,请务必始终仔细阅读并理解你将要实施的任何由他人编写的代码。

背景:

我正在 AWS 上部署 Discourse,并尽可能利用其所有服务以确保可靠性和冗余性。作为一名开发者,我更喜欢使用命令行和代码,并希望使用 IaC(基础设施即代码)自动化。我的整个环境都是使用 Terraform 部署的,但我尝试通过 Web 控制台点击操作,并尽可能地将各项配置对齐。IAM 和策略文档超出了本文的范围,但我相信我已经指出了需要配置的地方。

对于单个应用程序来说,运行一个 Postfix 实例似乎有点大材小用。使用 POP3 邮箱则显得太 90 年代了。于是,我一头扎进了 AWS 的兔子洞里。

我确实找到了一些非常有用帖子,帮助了我的探索:

mail-receiver 容器也帮助我理解了 Discourse 是如何解析消息的:

起初,我以为 AWS webhook 端点会处理传入的消息,但在查看代码后我意识到它并不会。我的 Lambda 接收器代码是基于 @dltj 提供的优秀示例构建的。我选择使用 SNS 进行消息传递,而不是 S3。

先决条件

  • AWS 账户
  • 对 DNS 及与电子邮件相关的记录类型有基本的了解
  • 一个可以修改记录的域名(或子域名)

注意事项

  • 文档中记录的所有内容都必须在同一个 AWS 区域中创建
  • 粗体斜体文本 是你实施中特定的值
  • 斜体文本 是变量名、固定值或 UI 元素的名称

步骤

  1. 在支持接收电子邮件的某个 AWS 区域中,创建一个 Simple Email Service (SES) 域名身份,your.domain

  2. 验证域名身份

  3. 创建一个 Simple Notification Service (SNS) 主题,feedback-sns-topic,用于反馈通知
    a. 将 feedback-sns-topic 主题的 ARN 添加到你的 aws_sns_topic_arn_allowlist 设置中。

  4. 配置 your.domain 域名身份
    a. 启用电子邮件反馈转发
    b. 配置退信和投诉(而非投递)反馈通知,以使用 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. 在你的 Discourse 实例中为 system 用户创建一个 API 密钥,授予其对 email 资源的 receive email 操作权限

  10. 在 Secret Manager 中创建一个密钥,email-handler-secret,包含以下键及其相应的值:

    • api_endpoint - https://your.domain/admin/email/handle_mail
    • api_key - 来自步骤 9
    • api_username - system,除非你在步骤 9 中使用了其他值
  11. python3.10 运行时创建一个 Lambda 层,lambda-receiver-layer,其中包含 requestsaws-lambda-powertools

  12. 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)
  1. 配置 email-receiver-lambda 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. 将端点设置为 email-receiver-lambda 的 ARN

  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 个赞

这是我所做的。

在我的 PC 上安装了 Python 3.10,在完成第 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 服务,然后导航到“Layers”。单击“Create Layer”,在名称中输入 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
  • 您需要在 DNS 中设置 MX 记录,以便将回复电子邮件发送到 AWS SES。如果您已经为根域设置了 MX 记录用于常规电子邮件(例如,将 Google Workspace 电子邮件用于 contact@example.com 等地址的常规业务),您将需要为回复使用子域。在我的例子中,我在 reply.example.com 上设置了一个 MX 记录,用于将回复发送到 inbound-smtp.<REGION>.amazonaws.com。有关更多详细信息,请参阅此文档
  • 您可以使用 CloudWatch 来查看各项功能的工作情况。如果您看到某个库/模块未加载的错误,则可能是您错误配置了 Lambda Layer 或未将其连接到函数。检查您上传的 ZIP 文件是否具有正确的目录结构,看起来像 python/lib/python3.10/site-packages/;请参阅此文档。我建议查找一些关于创建 Lambda Layer 的在线教程。

代码仍然支持 ARM64 - 您只需通过下载基于 ARM 的 Python 库来配置具有正确架构的 Lambda Layer。

完成所有设置后,您应该能在管理员日志中看到收到的电子邮件。

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 "Created: ${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

我刚设置完,我认为原始消息传递必须禁用,而不是启用

如果启用了原始消息传递,SNS 的退信通知将不包含 Discourse 验证消息所需的 SNS 元数据。我的访问日志中出现了类似以下的条目:

"POST /webhooks/aws HTTP/1.1" "Amazon Simple Notification Service Agent" "-" 406 414 "-" 0.008 0.008 "-" "-" "-" "-" "-" "-" "-"

即 HTTP 状态码 406,“不可接受”(Not Acceptable)。

禁用原始消息传递后,访问日志显示:

"POST /webhooks/aws HTTP/1.1" "Amazon Simple Notification Service Agent" "-" 200 402 "-" 0.022 0.022 "-" "-" "-" "-" "-" "-" "-"

即 HTTP 状态码 200,“OK”。