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

我将分享我为使用 AWS SES 进行出站、退信和入站电子邮件而配置的设置。SES 服务确实存在一些细微之处,经过大量的试错才完全理解其工作原理。这更像是一次思路的倾泻,而不是循序渐进的指南。理论上是不必要的,但请自行承担风险。而且,务必仔细阅读并理解您实现的任何他人编写的代码。

背景:

我正在 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 区域之一中,创建一个简单的电子邮件服务 (SES) 域名身份,your.domain

  2. 验证域名身份

  3. 创建一个简单的通知服务 (SNS) 主题,feedback-sns-topic,用于反馈通知

  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. 创建一个 Lambda 层,lambda-receiver-layer,用于 python3.10 运行时,包含 requestsaws-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. 配置 email-receiver-lambda Lambda 函数:
    a. 添加层 lambda-receiver-layer
    b. 添加区域特定的层以支持 AWS 参数存储
    c. 添加环境变量 SECRET_NAME,值为 email-handler-secret
    d. 如果您想记录更多详细信息,请添加环境变量 POWERTOOLS_LOGGER_LOG_EVENT,值为 true

  2. 授予 Lambda 函数 email-receiver-lambda 对密钥 email-handler-secret 的 IAM 权限 secretsmanager:GetSecretValue

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