AWS SESでの送信、バウンス、受信メールの設定方法

AWS SES を使用して、送信、バウンス、および受信メールを行うための設定を共有したいと思います。SES サービスには確かにニュアンスがあり、それがどのように機能するかを正確に理解するには、かなりの試行錯誤が必要でした。これは、ステップバイステップの指示というよりは、思考の吐き出しです。不要かもしれませんが、ご自身の責任で使用してください。そして、他人が書いたコードを実装する場合は、必ずすべてを読み、理解してください。

背景:

AWS で Discourse をデプロイし、信頼性と冗長性を確保するために、利用可能なすべてのサービスを活用しようとしています。開発者として、コマンドラインとコードに慣れているため、IaC 自動化を使用したいと考えていました。私の環境全体は Terraform でデプロイされていますが、Web コンソールをクリックして、できる限り調整しようとしました。IAM とポリシー ドキュメントはこの範囲外ですが、必要な箇所は指摘したつもりです。

Postfix インスタンスを実行することは、単一のアプリケーションには過剰なようです。POP3 メールボックスを使用するのは、非常に 90 年代風です。そこで、AWS の奥深くまで潜り込みました。

私の探求に役立った、非常に役立つ投稿がいくつかありました。

メール受信者コンテナは、Discourse がメッセージをどのように処理するかを理解するのに役立ちました。

当初、AWS Webhook エンドポイントが受信メッセージを処理すると予想していましたが、コードを確認したところ、そうではないことに気づきました。Lambda 受信者コードは、@dltj による優れた例に基づいています。S3 ではなく、メッセージ配信に SNS を使用することを選択しました。

前提条件

  • AWS アカウント
  • DNS とメール関連のレコード タイプに関する十分な知識
  • 変更を加えることができるドメイン(またはサブドメイン)

注意事項

  • 文書化されているすべては、同じ AWS リージョンで作成する必要があります。
  • このように太字と斜体で表示されているテキストは、実装固有の値です。
  • 斜体テキストは、変数、固定値、または UI 要素の名前です。

手順

  1. メール受信をサポートする AWS リージョンで、Simple Email Service (SES) ドメイン ID、your.domain を作成します。

  2. ドメイン ID を検証します。

  3. フィードバック通知用の Simple Notification Service (SNS) トピック、feedback-sns-topic を作成します。

  4. your.domain ドメイン ID を設定します。
    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 を作成します。アクティブなルールセットは 1 つしか存在できないため、既存のものをアクティブなものとして使用します。

  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 - ステップ 9 で別のものを選択しない限り、system
  11. python3.10 ランタイム用の Lambda レイヤー、lambda-receiver-layer を作成し、requests および aws-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. email-handler-secret シークレットに対して、Lambda 関数 email-receiver-lambda に IAM 権限 secretsmanager:GetSecretValue を付与します。

  3. SNS トピック incoming-sns-topic にサブスクリプションを作成します。
    a. プロトコルは AWS Lambda です。
    b. エンドポイントを email-receiver-lambda の ARN に設定します。

  4. incoming-sns-topic トピックの SNS サブスクリプションが email-receiver-lambda を呼び出すための IAM 権限が必要になりますが、コンソール経由で設定すると自動的に行われるはずです。

デバッグ目的、または単なる自己満足のために、どちらかの 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サービスに移動して、「レイヤー」に移動します。 「レイヤーの作成」をクリックし、名前に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 を使用する場合、多くの費用がかかります。
  • 返信メールを AWS SES に送信するには、DNS に MX レコードが必要です。一般的なメール(たとえば、contact@example.com のようなアドレスの一般的なビジネス用途で Google Workspace メールを使用している場合)のルートドメインにすでに MX レコードがある場合は、返信にはサブドメインを使用することをお勧めします。私の場合は、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