Amazon SESの連絡先リストによる購読管理を試してみました

2023.12.22

初めに

先日メールのメルマガの登録・解除の話を別件のついでで少しだけ話す機会がありました。

話したのちにアプリで対応しようとするとそこそこ労力が必要な感覚があるからAmazon SESでよしなにできる機能がないかなと調べたところ「連絡先リスト(Contact List)」という機能を見つけました。

名前から送信用のメーリングリスト的な機能かな?と思っていたのですがそういった機能ではないようで、むしろメールの購読のオプトイン/オプトアウトを管理しオプトアウトされた連絡先にはメールを飛ばさないようにする機能オンリーのようです。

AWSのドキュメントページは以下となりますのでより詳細な情報はこちらもご参照ください。

自分の調べ方が悪いのか利用に関する情報が公式ドキュメント以外で見当たらなかったので実際に利用しどのような挙動になるかみていきます。

リストとトピック

こちらを使うためのグルーピングとしてリストとトピックの2つ概念が存在します。

概念としてはリストの中にトピックが存在し(最大20個まで所属可能)、各メールアドレス+任意属性を組とした情報をリストに所属させ、さらにそこに対してトピックを結びつける形となります。

AWS アカウント ごとに許可される連絡先リストは 1 つだけです。

なおリストは現状では複数作れるわけではないようで1アカウントに1つのみとなります(後述しますが実際に試す限りはリージョン毎?)。一応listコマンドは実装されているので複数利用できる未来もあるのかもしれません。

どこまでを一塊として「連絡先」として呼称するかドキュメント上の定義は曖昧ですが以降このドキュメントでは「各メールアドレス+任意属性」の1つの組を「連絡先」と呼称します。

連絡先にはトピックを複数結びつけることができトピックごとに購読を管理することができます。

例として「最新情報」と「お役立ち情報」のような2つのメールマガジンをその顧客が読んでおりそれを管理したい場合は、その連絡先に対してその2つのトピックを結びつける形となります。

途中でその顧客が「最新情報」が不要になった場合はそちらのトピックをオプトアウトすることで以降メールの送信時にそのトピックを指定して送信すると顧客にメールが届かなくなります(挙動としてはバウンスとなるようです)。

実際トピックにどのような意味を持たせるかは設計やご利用方法にはよりますがカテゴリのようなものを用意しておきそれをユーザに割り当ててそれごとに購読要否を管理できます。

個人的にはトピックについては連絡先に購読情報とセットで結びつけられているものと頭の中でイメージしています。

メール送信時の購読管理の利用

AWS APIを利用の場合はListManagementOptionsを指定、SMTPインターフェースを利用の場合はX-SES-LIST-MANAGEMENT-OPTIONSヘッダに対してリスト名およびトピック名を指定することで利用可能です。

そのトピックに含まれているかつオプトアウトとなっている場合はバウンスし、それ以外の場合は送信に成功する形となります。

これを指定している状態で{{amazonSESUnsubscribeUrl}}という値をメール本文に埋め込むとこの部分にそのメールアドレスでの購読管理用のリンクが追加され、受信者がこのリンクを通して購読および購読解除ができるようになります。

SMTPインターフェースの場合は標準化された仕様である(らしい)List-UnsubscribeおよびList-Unsubscribe-Postも利用可能なようですが標準仕様の読み込みが少し必要となりそうですのでまたの機会に試してみようと思います。

リストとトピックの作成

まずはリストとそこに含まれるトピックを作成します。

今回はAWS CLIで操作を行いますがaws sesv2 create-contact-listコマンドで新規の登録が可能です。

情報の指定はJSONで行いますが今回はExampleContactListNameという名前でリストを作成し、おすすめ商品を通知するニュースレター管理用のrecommend-itemトピック、その他の雑多な情報を含めたニュースレターを管理するother-newsトピックを用意します。

JSONの情報は以下のとおりです。

ses-contact-list.json

{
    "ContactListName": "ExampleContactListName",
    "Description": "Creating a contact list example",
    "Topics": [
     {
         "TopicName": "recommend-item",
         "DisplayName": "recommend item letter",
         "Description": "recommend item news letter topic",
         "DefaultSubscriptionStatus": "OPT_IN"
     },
     {
         "TopicName": "other-news",
         "DisplayName": "news letter",
         "Description": "mixed news letter topis",
         "DefaultSubscriptionStatus": "OPT_IN"
     }
    ]
}

DefaultSubscriptionStatusはリスト追加時のデフォルトの購読状況の設定でOPT_IN(オプトイン)、OPT_OUT(オプトアウト)が指定可能です。

実行に成功しても特にレスポンスは無いようですが、再度実行するとリストが最大1個しか作れない旨のエラーが確認できます。

$ aws sesv2 create-contact-list --cli-input-json file://ses-contact-list.json
$ aws sesv2 create-contact-list --cli-input-json file://ses-contact-list.json
An error occurred (BadRequestException) when calling the CreateContactList operation: A maximum of 1 Lists allowed per account.

ドキュメント上アカウントごとに一つということで複数リージョンで使いたい場合はどうするんだろう?と思ったのですが実際に実行してみるとリージョン毎に1つリストを作成できました。

# 上記のJSONの`ExampleContactListName`を`ExampleContactListNameUsEast2`に変更して実行
$ aws sesv2 create-contact-list --cli-input-json file://ses-contact.json --region us-west-2

$ aws sesv2 list-contact-lists
{
    "ContactLists": [
        {
            "ContactListName": "ExampleContactListName",
            "LastUpdatedTimestamp": "2023-12-21T16:12:39.461000+09:00"
        }
    ]
}

$ aws sesv2 list-contact-lists --region us-east-2
{
    "ContactLists": [
        {
            "ContactListName": "ExampleContactListNameUsEast2",
            "LastUpdatedTimestamp": "2023-12-21T16:14:48.750000+09:00"
        }
    ]
}

現時点では少なくとも東京リージョンではマネージメントコンソールからリストを確認できる画面はなさそうなので、この辺りの管理が必要であれば現状は独自で管理できるような何かを準備する必要がありそうです。

連絡先の登録

こちらもJSONに登録値を記載しそれをAPIで登録します。

CreateImportJobを利用することでS3上にあげたJSONやCSVからまとめて取り込めるようですが、準備が手間なのと今回は購読の挙動を見ることを主軸に置くためCreateContactを利用して個々に登録します。

JSONは以下の通りです(emailAddressの指定は実際には私のアドレスです)。

ses-contact.json

{
     "ContactListName": "ExampleContactListName",
     "EmailAddress": "example@example.com",
     "UnsubscribeAll": false,
     "AttributesData": "{\"Name\":\"Junya\"}",
     "TopicPreferences": [
      {
          "TopicName": "recommend-item",
          "SubscriptionStatus": "OPT_IN"
      },
      {
          "TopicName": "other-news",
          "SubscriptionStatus": "OPT_OUT"
      }
     ]
}

登録コマンドを実行しますが相変わらず出力は空です。一応JSONキーの大文字小文字の区別はあるようです。

% aws sesv2 create-contact --cli-input-json file://ses-contact.json

#大文字小文字に誤りがあるとエラー
% aws sesv2 create-contact --cli-input-json file://ses-contact-invalid.json
Parameter validation failed:
Missing required parameter in input: "EmailAddress"
Unknown parameter in input: "unsubscribeAll", must be one of: ContactListName, EmailAddress, TopicPreferences, UnsubscribeAll, AttributesData
Unknown parameter in input: "attributesData", must be one of: ContactListName, EmailAddress, TopicPreferences, UnsubscribeAll, AttributesData
Unknown parameter in input: "topicPreferences", must be one of: ContactListName, EmailAddress, TopicPreferences, UnsubscribeAll, AttributesData

list-conactsで登録されていることが確認できます。

% aws sesv2 list-contacts --contact-list-name ExampleContactListName
{
    "Contacts": [
        {
            "EmailAddress": "example@example.com",
            "TopicPreferences": [
                {
                    "TopicName": "other-news",
                    "SubscriptionStatus": "OPT_OUT"
                },
                {
                    "TopicName": "recommend-item",
                    "SubscriptionStatus": "OPT_IN"
                }
            ],
            "UnsubscribeAll": false,
            "LastUpdatedTimestamp": "2023-12-21T22:51:03.078000+09:00"
        }
    ]
}

なお登録時に存在しているトピックを全て指定していない場合そのトピックに関する指定がない状態ではなく、デフォルト値で設定されるようです。

# recommend-itemのトピックが未指定
$ cat ses-contact-1.json
{
     "ContactListName": "ExampleContactListName",
     "EmailAddress": "example+1@example.com",
     "UnsubscribeAll": false,
     "AttributesData": "{\"Name\":\"Junya\"}",
     "TopicPreferences": [
      {
          "TopicName": "other-news",
          "SubscriptionStatus": "OPT_OUT"
      }
     ]
}
$ aws sesv2 create-contact-list --cli-input-json file://ses-contact.json
$ aws sesv2 list-contacts --contact-list-name ExampleContactListName
{
    "Contacts": [
        {
            "EmailAddress": "example+1@example.com",
            "TopicPreferences": [
                {
                    "TopicName": "other-news",
                    "SubscriptionStatus": "OPT_OUT"
                }
            ],
            "TopicDefaultPreferences": [
                {
                    "TopicName": "recommend-item",
                    "SubscriptionStatus": "OPT_IN"
                }
            ],
            "UnsubscribeAll": false,
            "LastUpdatedTimestamp": "2023-12-21T23:02:14.175000+09:00"
        }
    ....
    ]
}

なお後述しますが送信する際にリスト・トピックに所属していないアドレスに送信した場合は送信タイミングで登録されるため必ずしも送信する準備としてこちらのAPIを呼ぶ必要はありません。

オプトイン対象へのメール送信

メール送信はSMTPインターフェースの利用で送信します。送信にはopensslコマンドを利用して生のSMTPで対話します。

X-SES-LIST-MANAGEMENT-OPTIONS: {contactListName}; topic={topicName}というようにヘッダを含めることで利用可能です。

今回はオプトインとなっているrecomend-itemを指定して送ります。

$ openssl s_client -crlf -quiet -starttls smtp -connect email-smtp.ap-northeast-1.amazonaws.com:587
depth=2 C = US, O = Amazon, CN = Amazon Root CA 1
verify return:1
depth=1 C = US, O = Amazon, CN = Amazon RSA 2048 M01
verify return:1
depth=0 CN = email-smtp.ap-northeast-1.amazonaws.com
verify return:1
250 Ok
EHLO mail.example.com
250-email-smtp.amazonaws.com
250-8BITMIME
250-STARTTLS
250-AUTH PLAIN LOGIN
250 Ok
AUTH PLAIN xxxxxxxxxxx
235 Authentication successful.
MAIL FROM: option@example.com
250 Ok
RCPT TO: example@example.net
250 Ok
DATA
354 End data with <CR><LF>.<CR><LF>
From: option@example.com
To: example@example.net
Subject: from ses
X-SES-LIST-MANAGEMENT-OPTIONS: ExampleContactListName; topic=recommend-item

im from ses

{{amazonSESUnsubscribeUrl}}
.
250 Ok 0106018c8fa80422-a1c6b10d-6544-48af-9aea-85433c6e17c2-000000

メールを確認すると{{amazonSESUnsubscribeUrl}}の部分が購読解除用のリンクに置き換わっています。

メールヘッダーを確認してみるとX-SES-LIST-MANAGEMENT-OPTIONSの指定はなくなっていました。
おそらくAWS側でList-Unsubscribe及びList-Unsubscribe-Postに置換されたものと思われます。

...
Date: Fri, 22 Dec 2023 03:53:22 +0000
List-Unsubscribe: <mailto:unsubscribe-xxxxxxxxx>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
Feedback-ID: 1.ap-northeast-1.l/xxxxxxx=:AmazonSES
X-SES-Outgoing: 2023.12.22-23.251.234.12

im from ses
...

ちなみに存在しないトピック名を指定して送ってしまったのですがこの場合は送信エラーとなるようです

...
Subject: from ses
X-SES-LIST-MANAGEMENT-OPTIONS: ExampleContactListName; topic=recommend

im from ses

{{amazonSESUnsubscribeUrl}}
.
554 Message rejected: List: ExampleContactListName doesn't contain Topic: recommend

ユーザ側からのオプトアウト

先ほどのメール内のリンクをクリックして購読を解除してみます。

ワンクリック解除かなと思っていたのですがどうやらユーザリストに含まれる全てのトピックが表示され全て操作でき、また解除だけではなく登録もできるようです。

今回はrecommend item letterを購読解除、news letterを新規購読と逆転させてみます。

成功すると同画面で以下のようなダイアログが表示されます。

ページはカスタマイズできなさそう(?)であり英語ページとなっているので国内向けサービスだと利用しづらいケースは多いかもしれません。

list-contactsで購読を操作した連絡先を確認してみると指定した通りに変更されていることが確認できます。

...
        {
            "EmailAddress": "example@example.com",
            "TopicPreferences": [
                {
                    "TopicName": "other-news",
                    "SubscriptionStatus": "OPT_IN"
                },
                {
                    "TopicName": "recommend-item",
                    "SubscriptionStatus": "OPT_OUT"
                }
            ],
            "UnsubscribeAll": false,
            "LastUpdatedTimestamp": "2023-12-22T13:07:37.832000+09:00"
        }
...

オプトアウトされた対象へのメール送信

先ほど購読を解除したrecommend-itemを指定してメールを送ってみます。

送信処理自体は250 OKで処理されるようです。

$ openssl s_client -crlf -quiet -starttls smtp -connect email-smtp.ap-northeast-1.amazonaws.com:587
...
To: example+optout@example.com
Subject: from ses
X-SES-LIST-MANAGEMENT-OPTIONS: ExampleContactListName; topic=recommend-item

im from ses

{{amazonSESUnsubscribeUrl}}
.
250 Ok 0106018c8fbdf44c-bc6d1381-93a6-4b06-bc91-c8b698d20a69-000000

VDMでイベントを確認してみると実際には永続的なバウンスとして処理されていることが確認できます。
なおGMail等で利用可能なエイリアスでリストに登録されていないexample+optin@example.comに送ったところこちらもバウンス扱いとなリました。

Firehose経由でS3に送信されたイベントは以下のとおりです。

{
    "eventType": "Bounce",
    "bounce": {
        "feedbackId": "xxxxxxx",
        "bounceType": "Permanent",
        "bounceSubType": "UnsubscribedRecipient",
        "bouncedRecipients": [
            {
                "emailAddress": "example@example.net",
                "action": "failed",
                "status": "5.7.1",
                "diagnosticCode": "Amazon SES did not send the message to this address because the contact has unsubscribed from receiving emails."
            }
        ],
        "timestamp": "2023-12-22T04:17:20.345Z",
        "reportingMTA": "dns;amazonses.com"
    },
    "mail": {
        "timestamp": "2023-12-22T04:17:19.948Z",
        "source": "optin@example.com",
        "sourceArn": "arn:aws:ses:ap-northeast-1:xxxxxxx:identity/example.com",
        "sendingAccountId": "xxxxx",
        "messageId": "0106018c8fbdf44c-bc6d1381-93a6-4b06-bc91-c8b698d20a69-000000",
        "destination": [
            "example@example.net"
        ],
        "headersTruncated": false,
        "headers": [
            {
                "name": "Received",
                "value": "from mail.xxxxx.com..."
            },
            {
                "name": "From",
                "value": "optin@example.com"
            },
            {
                "name": "To",
                "value": "example@example.net"
            },
            {
                "name": "Subject",
                "value": "from ses"
            }
        ],
        "commonHeaders": {
            "from": [
                "optin@example.com"
            ],
            "to": [
                "example@example.net"
            ],
            "messageId": "0106018c8fbdf44c-bc6d1381-93a6-4b06-bc91-c8b698d20a69-000000",
            "subject": "from ses"
        },
        "tags": {
            "ses:source-tls-version": [
                "TLSv1.2"
            ],
            "ses:operation": [
                "SendSmtpEmail"
            ],
            "ses:configuration-set": [
                "examplecom-configuration"
            ],
            "ses:recipient-isp": [
                "UNKNOWN_ISP"
            ],
            "ses:source-ip": [
                "xxx.xxx.xxx.xxx"
            ],
            "ses:from-domain": [
                "example.com"
            ],
            "ses:sender-identity": [
                "example.com"
            ],
            "ses:caller-identity": [
                "xxxxxxx"
            ]
        }
    }
}

永続的なバウンス(ハードバウンス)のためAmazon SESの利用停止の原因となるバウンスの対象扱いでは?と思いましたが、メトリクスやVDMを見る限りカウントはされておらずサプレッションリストへの追加もありませんでした(送信前でAWS側で止めているタイプの送信のため?)。

ただ反映遅延等もあるかと思いますのでこのカウントについては試行直後に見た限りはそうだったほどで捉えていただければと思います。

リストに含まれない対象への送信

連絡先リストにない受信者の E メールアドレスへの SendEmail リクエストに ListManagementOptions を含めると、連絡先がリストに自動的に作成されます。

上記のように連絡先リストにない連絡先を対象としてメールを送ると特に拒否されるということはなく自動的に管理下に入るようです。
登録を行っていない私の個人のgmail宛に送ってみます。

届きました。

連絡先リストを確認すると送信先に追加されていました。

...
        {
            "EmailAddress": "gmail@example.com",
            "TopicPreferences": [
                {
                    "TopicName": "recommend-item",
                    "SubscriptionStatus": "OPT_IN"
                }
            ],
            "TopicDefaultPreferences": [
                {
                    "TopicName": "other-news",
                    "SubscriptionStatus": "OPT_IN"
                }
            ],
            "UnsubscribeAll": false,
            "LastUpdatedTimestamp": "2023-12-22T14:00:53.058000+09:00"
        }
....

異なるシステム間の時刻がどれほど差異があるかわかりませんが、がDateヘッダーの値はFri, 22 Dec 2023 05:00:53 +0000、Receivedヘッダ値のうち日付の部分がThu, 21 Dec 2023 21:00:55 -0800 (PST)となっているため登録処理と並行もしくは直前あたりに処理を入れていそうな気がします。

終わりに

今回はAmazon SESの機能を利用しての購読管理を試してみました。

アプリ側で実装しようと思う場合は仕組みや画面諸々必要なためここがヘッダー一つ、プレースホルダーテキストひとつで良いので機能自体はかなり良いのではないでしょうか。

ただし現状購読状態を確認するような画面がないのでその管理をコマンドで行うか自前で管理画面を作る必要があることや、AWS側より提供されるインターフェースの言語が英語となっているのでもう一歩あれば!という機能かもしれません。
(ユーザ側で見える購読管理部分もトピックの説明文自体は日本語で登録できるかと思いますがヘッダー部分等が...)