【後編：実装編】空メールを送ったら登録用メールが返ってくるアレをSESで実装する

ごきげんよう、@An_nAです！
この記事はハンズラボ [Advent Calendar 2022](https://qiita.com/advent-calendar/2022/handslab) 22日目の記事です。

[前回の記事](https://qiita.com/An_nA/items/557fb35c754c7933a8ac)では、SESでのメール受信について概要をご紹介しました。
（まだ読んでないよ〜という方はぜひご一読ください）

今回の記事では、Serverless Frameworkを用いて実装をしていきますよ！

## 前提

### この記事では扱わないこと
* SESのバウンス対策
本記事では取り扱いませんが、SESを使用してメールを送信する場合はバウンス率の監視やログの保管を行いましょう。
参考：
[どのようなものが Amazon SES でソフトバウンスとみなされますか? また、ソフトバウンスを監視するにはどうすればよいですか?](https://aws.amazon.com/jp/premiumsupport/knowledge-center/ses-understand-soft-bounces/)

* 今回の内容に特化した部分以外の説明は割愛しています。ゴメンナサイ

### 環境
* macOS Monterey
* Framework Core: 3.25.1
Plugin: 6.2.2
SDK: 4.3.2
* serverless-python-requirements インストール済み
* Route 53にドメイン登録済み＆ホストゾーン作成済みのAWSアカウントを利用
（Route 53以外のDNSサービスをご利用の方は適宜読み替えてください。）

### 構成図
![Advent2022.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/624944/7a344042-04d9-78c9-023c-803345bd95e4.png)
※メールデータの保存用DynamoDBやメール送信用SESは他の機能(本記事のスコープ外なのであえて描いていません)でも使用したいので、バージニア北部リージョンではなく東京リージョンに置きます。

### ディレクトリ構成（必要部分だけ抜粋）
```
.
├── functions
│   ├── send_url.py
│   └── template
│       └── send_url.html
├── resource
│   ├── dynamodb.yml
│   ├── ses.yml
│   └── ses_us-east-1.yml
├── Pipfile
├── serverless.yml
└── serverless_us-east-1.yml
```

### Pipfile
```
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
jinja2 = "*"
boto3 = "*"

[dev-packages]
pytest = "*"
flake8 = "*"
autopep8 = "*"

[requires]
python_version = "3.9"
```
では、実装していきますよーーー！！！

## 東京リージョンのリソース作成
まずは東京リージョンのリソースを作っちゃいましょう。

### デプロイ用ファイルを作成する
Serverless Frameworkでリソースを作成するために、必要なファイルを書いていきます。

#### DynamoDB用ファイル
uuidとメールアドレスを保存しておくDynamoDBを作成するためのファイルです。
メールアドレスをずっと保管しておくのは躊躇われたので、有効期限を設定しました。

```yaml:dynamodb.yml
Resources:
  AdventCalendar2022EmailAddressTable:
    # テーブル定義 --------------------------------------------------------------------
    # uuid                  | String | uuid
    # mail_address          | String | メールアドレス
    # expired_at_unix_time  | Number | 有効期限(Unix エポック時間形式のタイムスタンプ・秒単位)
    # -------------------------------------------------------------------------------
    Type: AWS::DynamoDB::Table
    Properties: 
      KeySchema: 
        - 
          AttributeName: uuid
          KeyType: HASH
      AttributeDefinitions: 
        -
          AttributeName: uuid
          AttributeType: 'S'
      BillingMode: PAY_PER_REQUEST
      PointInTimeRecoverySpecification: 
        PointInTimeRecoveryEnabled: true
      SSESpecification: 
        SSEEnabled: true
        SSEType: KMS
      TableName: AdventCalendar2022EmailAddressTable
      TimeToLiveSpecification: 
        AttributeName: expired_at_unix_time
        Enabled: true
```

#### SES用ファイル
このファイルでは、メール送信に利用するドメインIDを作成します。
`EmailIdentity`部分が利用するドメインです。

```yaml:ses.yml
Resources:
  # ドメインID
  EmailIdentity:
    Type: AWS::SES::EmailIdentity
    Properties: 
      DkimAttributes: 
        SigningEnabled: true
      EmailIdentity: advent.example.com
```

#### serverless.yml
言わずと知れたserverless.ymlファイル。
東京リージョン用なので、`provider.region`は「ap-northeast-1」です。

```yaml:serverless.yml
service: advent2022
frameworkVersion: ">=3.18.2 <4.0.0"

plugins:
  - serverless-python-requirements

custom:
  defaultStage: dev
  pythonRequirements:
    usePipenv: true
    layer: true
    dockerizePip: true
    dockerImage: public.ecr.aws/sam/build-python3.9:latest

provider:
  name: aws
  runtime: python3.9
  region: ap-northeast-1
  profile: ${opt:profile, ''}
  stage: ${opt:stage, self:custom.defaultStage}
  environment:
    TZ: Asia/Tokyo
    REGION: ${self:provider.region}
    STAGE: ${self:provider.stage}

package:
  patterns:
    - '!node_modules/**'
    - '!.vscode/**'
    - '!.venv/**'
    - '!.flake8'
    - '!.gitignore'
    - '!Pipfile'
    - '!Pipfile.lock'
    - '!README.md'
    - '!package.json'
    - '!package-lock.json'

functions:

resources:
  - ${file(./resource/ses.yml)}
  - ${file(./resource/dynamodb.yml)}
```

### デプロイする
ファイルが作成できたら、さくっとデプロイしちゃいましょう！

```
$ sls deploy --stage dev --config serverless.yml
```

### SESのドメインIDの認証
無事にリソースがデプロイされてSESのIDが作成できたら、ドメインの検証を行います。
今回は、Route 53のホストゾーンにCNAMEを登録しました。

ドメインの検証手順は以下の通りです。
1. 東京リージョンのSESコンソールにアクセスして、[検証済みID]->「登録したID」を選択する。
![ドメイン検証1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/624944/75509136-a065-85c6-3b0e-226c6dd8ad08.png)

1. [認証]タブの「DNSレコードの発行」に記載されているレコードをすべてDNSに登録する。
Route 53を使用している場合は[DNSレコードのRoute53への発行]を押すと早いです。
![ドメイン検証2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/624944/eef0d8ef-3329-a3b6-54e4-9492a1ddc242.png)

1. IDステータスが「検証済み」になればOKです（少し時間がかかることがあります）。
![ドメイン検証3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/624944/8dd4dc5d-80c3-7be2-289c-26d015a25e72.png)

参考：
・[Amazon SES の ID の作成と検証](https://docs.aws.amazon.com/ja_jp/ses/latest/dg/creating-identities.html)

### SESをサンドボックスの外に出す（の代わりの作業を実施）
SESでメールを送る場合は、「サンドボックスの外に出す」という作業が必要です。

参考：
・[Amazon SES サンドボックス外への移動](https://docs.aws.amazon.com/ja_jp/ses/latest/dg/request-production-access.html)

が、このサンドボックス外に出す作業が少し手間なので、今回は割愛します。
代わりに自分のメールアドレスを検証済みEメールアドレスにしておき、メールの送信先に利用できるようにしました。のちほど動作確認を行う際は、ここで登録したメールアドレスから空メールを送ります。

1. 東京リージョンのSESコンソールにアクセスして、[検証済みID]->[IDの作成]を選択する。
![メール検証1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/624944/becb3cf8-2cc2-0070-8f73-c88a8032c84d.png)

1. 「Eメールアドレス」にチェックを入れ、Eメールアドレス欄にアドレスを入力後、[IDの作成]をクリック。
![メール検証2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/624944/832e002d-4fd7-f771-70ce-6459e28a12f8.png)

1. 入力したEメールアドレス宛にAWSからメールが来るので、記載されたURLにアクセスする。
1. 「検証に成功しました」と表示されればOK。
1. 登録したメールアドレスが「検証済み」になっていることを確認する。
![メール検証3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/624944/114a773f-328b-bd77-576a-72cbaac3f1ad.png)

東京リージョンでの作業は以上です！

## バージニア北部リージョンのリソース作成
次にバージニア北部リージョンのリソースを作成していきますよ〜。

### デプロイ用ファイルを作成する
Serverless Frameworkでリソースを作成するために、必要なファイルを書いていきます。

#### SES用ファイル
メール受信用のSES関連リソースです。東京リージョン用とは別ファイルにします。
このファイルで作成するのは以下の４つ。

* ドメインID
東京リージョンと構文は同じ。分かりやすいようサブドメインを変えています。

* 受信ルール
メール受信時にどのような動作をするかと、どのルールセットに含めるかを設定します。
複数のルールを作成して受信ルールセットにひとまとめにすることも可能です。
今回は、以下のルールを設定しました。
・`register@advent-us-east-1.example.com`宛のメールを受信したら
・ arnが`SendUrlLambdaFunction.Arn`なLambda関数を呼び出す

* 受信ルールセット
ルールセット名を定義するだけ。

* Lambdaを呼び出すための権限
Lambda呼び出しに必要な権限を付与します。

```yaml:ses_us-east-1.yml
Resources:
  # ドメインID
  ReceivingEmailIdentity:
    Type: AWS::SES::EmailIdentity
    Properties: 
      DkimAttributes: 
        SigningEnabled: true
      EmailIdentity: advent-us-east-1.example.com

  # 受信ルールセット
  ReceiptRuleSet:
    Type: AWS::SES::ReceiptRuleSet
    Properties:
      RuleSetName: AdventCalendar2022ReceiptRuleSet

  # 受信ルール
  ReceiptRule:
    Type: AWS::SES::ReceiptRule
    DependsOn: SendUrlLambdaPermission
    Properties: 
      Rule: 
        Actions: 
          - LambdaAction: 
              FunctionArn: !GetAtt SendUrlLambdaFunction.Arn
              InvocationType: Event
        Enabled: true
        Name: AdventCalendar2022ReceiptRule
        Recipients: 
          - register@advent-us-east-1.example.com
        ScanEnabled: true
      RuleSetName: !Ref ReceiptRuleSet

  # Lambdaを呼び出すための権限を付与
  SendUrlLambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !GetAtt SendUrlLambdaFunction.Arn
      Action: lambda:InvokeFunction
      Principal: ses.amazonaws.com
      SourceArn: arn:aws:ses:${aws:region}:${aws:accountId}:receipt-rule-set/*
```

#### Lambda関数
受信ルールセットが呼び出すLambda関数と、関数内で使用するメール用のテンプレートです。
1. 東京リージョンのDynamoDBに送信元のアドレスとUUID、有効期限を保存
1. 東京リージョンのSESで送信元のアドレスにメールを送信

という流れにします。

メール用のテンプレートはこんな感じにしました。
必要最低限でまったくかわいくないので、実際に使う場合はデコってあげてください。
```html:send_url.html
<p>
  登録用URLは以下の通りです。
</p>
<p>
  {{ web_url }}
</p>
```

Lambda関数はこんな感じ。pythonで書きました。
ログ出力や例外処理は書いていないので、適宜追加してください。

```python:send_url.py
import boto3
import uuid
import datetime
from jinja2 import Environment, FileSystemLoader

ses = boto3.client('ses', region_name='ap-northeast-1')
dynamodb = boto3.resource('dynamodb', region_name='ap-northeast-1')

env = Environment(loader=FileSystemLoader(
    './functions/template/', encoding='utf8'))


def handler(event, context):

    # 送信元(返信先)アドレスを抽出
    to_address = event['Records'][0]['ses']['mail']['source']

    # uuidを発行
    temporary_uuid = str(uuid.uuid4())

    # 有効期限を算出(現在時刻から10分後)
    expired_at = datetime.datetime.now() + datetime.timedelta(minutes=10)
    expired_at_unix_time = int(expired_at.timestamp())

    # DynamoDBに書き込み
    table_name = 'AdventCalendar2022EmailAddressTable'
    item = {
        'uuid': temporary_uuid,
        'mail_address': to_address,
        'expired_at_unix_time': expired_at_unix_time,
    }
    table = dynamodb.Table(table_name)
    table.put_item(Item=item)

    # メール送信準備
    subject = '【AdventCalendar2022】登録URLのご案内'
    web_url = f'https://xxx/input?code={temporary_uuid}'

    dataset = {
        'web_url': web_url,
    }
    html = env.get_template('send_url.html')
    email_message = html.render(dataset)

    # 送信先にメールを返信
    response = ses.send_email(
        Source='info@advent.example.com',
        Destination={
            'ToAddresses': [to_address],
            'CcAddresses': [],
            'BccAddresses': [],
        },
        Message={
            'Subject': {
                'Data': subject,
                'Charset': 'UTF-8',
            },
            'Body': {
                'Text': {'Data': email_message, 'Charset': 'UTF-8'},
                'Html': {'Data': email_message, 'Charset': 'UTF-8'}
            }
        },
    )

    return response
```

このLambda関数のポイントはなんと言ってもココです。
>ses = boto3.client('ses', region_name='ap-northeast-1')
dynamodb = boto3.resource('dynamodb', region_name='ap-northeast-1')

`region_name='ap-northeast-1'`を指定することで、バージニア北部リージョンのLambdaから東京リージョンのSESやDynamoDBを操作することが可能なんですね〜。意外に簡単。

#### serverless_us-east-1.yml
言わずと知れたserverless.ymlファイル。
こちらのファイルはバージニア北部リージョン用なので、`provider.region`を「us-east-1」にします。

`iam`部でLambdaに適切な権限を付与するのも忘れずに！
「ses:SendEmail」と「dynamodb:PutItem」の`Resource`のリージョン部分が「ap-northeast-1」になっている点に要注目です。

```yaml:serverless.yml
service: advent2022
frameworkVersion: ">=3.18.2 <4.0.0"

plugins:
  - serverless-python-requirements

custom:
  defaultStage: dev
  pythonRequirements:
    usePipenv: true
    layer: true
    dockerizePip: true
    dockerImage: public.ecr.aws/sam/build-python3.9:latest

provider:
  name: aws
  runtime: python3.9
  region: us-east-1
  profile: ${opt:profile, ''}
  stage: ${opt:stage, self:custom.defaultStage}
  environment:
    TZ: Asia/Tokyo
    REGION: ${self:provider.region}
    STAGE: ${self:provider.stage}
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - ses:SendEmail
          Resource:
            - arn:aws:ses:ap-northeast-1:${aws:accountId}:identity/*
        - Effect: Allow
          Action:
            - dynamodb:PutItem
          Resource:
            - arn:aws:dynamodb:ap-northeast-1:${aws:accountId}:table/*
        - Effect: Allow
          Action:
            - logs:DescribeLogGroups
            - logs:DescribeSubscriptionFilters
            - logs:PutSubscriptionFilter
          Resource:
            - arn:aws:logs:${aws:region}:${aws:accountId}:log-group:*
  logRetentionInDays: 14
  versionFunctions: false

package:
  patterns:
    - '!node_modules/**'
    - '!.vscode/**'
    - '!.venv/**'
    - '!.flake8'
    - '!.gitignore'
    - '!Pipfile'
    - '!Pipfile.lock'
    - '!README.md'
    - '!package.json'
    - '!package-lock.json'

functions:
  SendUrl:
    handler: functions/send_url.handler
    layers:
      - !Ref PythonRequirementsLambdaLayer

resources:
  - ${file(./resource/ses_us-east-1.yml)}

```

### デプロイする
こちらもさくっとデプロイしちゃいます。

```
$ sls deploy --stage dev --config serverless_us-east-1.yml
```

### SESのドメインIDの認証
バージニア北部リージョンのリソースがデプロイできたら、東京リージョンのときと同じくSESのドメインIDの検証をしてください。

### サンドボックスの外には出さない
今回の構成ではバージニア北部リージョンのSESからはメールの送信を行わないので、サンドボックスの外に出す作業や送信先メールアドレスの検証作業は不要です。

### MXレコードの登録
メール受信のため、DNSサーバーにMXレコードを登録します。
Route 53の場合は、以下の通り登録すればOKです。

| 設定箇所|登録内容|
|-------------|------------------|
| レコード名|SESに登録した受信用ドメイン<br>(今回の例ならadvent-us-east-1.example.com)|
| レコードタイプ|MX|
| 値|10 inbound-smtp.us-east-1.amazonaws.com|

参考：
[Amazon SES による E メール受信のための MX レコードの公開](https://docs.aws.amazon.com/ja_jp/ses/latest/dg/receiving-email-mx-record.html)

### 受信ルールセットをActiveにする
デプロイしただけでは受信ルールセットが有効化されないため、コンソールで有効化します。

1. バージニア北部リージョンのSESコンソールにアクセスして、[Eメール受信]->作成したルールセットにチェック->[有効として設定]をクリック。
![ルールセット1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/624944/6363479b-0919-4ace-4493-d23798a2a05a.png)

1. [有効として設定]をクリック。
![ルールセット2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/624944/9f09db57-77ba-a8fa-f211-32fb4cc1c361.png)

1. ルールセットが有効になったことを確認する。
![ルールセット3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/624944/dbaf4148-78b6-6762-393a-c127d906267d.png)

### 受信ルールの確認
ルールセットの画面から以下の画像の通り順にクリックしていくことで受信ルールを確認できます。

#### 受信者の条件
![ルール1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/624944/aa08455c-333c-55fd-752b-f50b953c9119.png)

### アクション
![ルール2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/624944/f58d69c3-d543-0184-029c-d761184e5c68.png)


## 空メールを送ってみよう
さて、ここまでできたらリソースの準備はバッチリです。動作確認をしてみましょう！
PCのメーラーを起動して、空メールを送ります。
|設定箇所|設定内容|
|------|--------|
|送信元|東京リージョンのSESに登録した「検証済みEメールアドレス」|
|送信先|受信ルールセットの「受信者の条件」に設定したアドレス<br>(今回の例だと`register@advent-us-east-1.example.com`)|
|件名|不要|
|本文|不要|

その後、受信ボックスに登録用URLが記載されたメールが届けば大成功です！

![動作1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/624944/ba3aa590-e82a-094b-949e-0c7b92276272.png)

届きました！
（知っていたけど文面がシンプルすぎる...。）

伏字にしているので分かりづらいですが、東京リージョンのSESに設定したドメインからメールが届いています。

東京リージョンのDynamoDBにきちんとデータが保存されているか、様子を見てみましょう。
![動作2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/624944/2f06e2c6-615e-af40-4211-f384c32904bc.png)

アイテムが保存できていました！
こちらも大成功です。

## おわりに
ということで、空メールを送ったら登録用メールが返ってくるアレを無事実装できました。
どなたかの参考になれば幸いです。

明日、12/23の担当は@umiwatariさんです。
どうぞお楽しみに！