【AWS】FargateからSESとNodemailerを使って添付メールを送信する方法

# はじめに
本記事では、Fargate上で動作するアプリケーションからAmazon Simple Email Service（SES）とNodemailerを利用して添付ファイル付きのメールを送信する方法を解説します。特に、セットアップ手順や実装時のポイント、そして筆者が実際にハマった問題とその解決策についても詳しく紹介します。

これから説明する内容は、既にFargate上でアプリケーションが稼働していることを前提としています。まだ環境が整っていない場合は、先にFargateでのアプリケーションデプロイについての基本を確認することをお勧めします。
# AWS構成概略
本記事で使用するAWS構成の概略は以下の図の通りです。
東京リージョンのマルチAZ環境でアプリケーションが稼働していることを想定しています。
※参考にされる際、他のリージョンで行う場合は適宜読み替えてください。

![グラフィックス1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3964984/1b1e0885-2c97-3955-9b83-33823ed6418c.png)

# 事前準備
## Amazon SES
### 初期セットアップ
初めてAmazon SESを使用する際にはセットアップのロードマップが用意されています。

![無題.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3964984/3c138c83-5403-595f-a452-6545545c4657.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3964984/914a77c6-5099-e120-1f30-c7d30f25edf1.png)

### サンドボックスの解除
初めは各機能に対して一定の制限が適用されています。この制限はAWSに対して本番稼働アクセスのリクエストを行うことで解除できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3964984/80a02ef5-f8fc-4950-9d5d-7f432a9c4963.png)
公式ドキュメント：
https://docs.aws.amazon.com/ja_jp/ses/latest/dg/request-production-access.html

### SMTP認証情報の作成
サイドバーのSMTP設定からSMTP認証情報を作成します。
ユーザーを作成するとアクセスキーが発行されるのでCSVをダウンロードして保存しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3964984/28b5ddad-f31d-6ee7-ca7f-708a0f10fcca.png)

## Amazon VPC
### エンドポイントの作成
作成するサービスは`com.amazonaws.ap-northeast-1.email-smtp`です。
検索ボックスにsmtpと入力すれば対象のサービスのみが表示されます。
サブネットはECSが稼働するAZ（今回だと`ap-northeast-1a`、`ap-northeast-1c`）を選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3964984/a43712b3-be26-b1eb-d386-2dd5cfceed7a.png)

公式ドキュメント：
https://docs.aws.amazon.com/ja_jp/ses/latest/dg/send-email-set-up-vpc-endpoints.html

### インバウンドルールの追加
エンドポイントのセキュリティグループに対して下記のインバウンドルールを設定します。
ポート：587
送信元：ECSサービス
※ポート587はSMTPプロトコルを使用したメール送信で一般的に利用されるポート

# アプリケーションの実装
## 手順
必要なパッケージをインストールします。
```bash:bash
npm i nodemailer
npm i -D @types/nodemailer # 型定義ファイル
```
`transporter`を作成します。
hostには作成したSMTPエンドポイントのDNS名を指定します。
```typescript:typescript
import * as dotenv from 'dotenv';
import nodemailer, { SendMailOptions } from 'nodemailer';

dotenv.config();

const transporter = nodemailer.createTransport({
  host: "email-smtp.ap-northeast-1.amazonaws.com", // SESのSMTPエンドポイント
  port: 587,
  secure: false,
  auth: {    
    user: process.env.AWS_ACCESS_KEY_ID, // SESで作成したSMTPユーザー名
    pass: process.env.AWS_SECRET_ACCESS_KEY, // SESで作成したSMTPパスワード
  },
  logger: true, // 必要に応じて設定
  debug: true, // 必要に応じて設定
});
```
メール内容を設定します。
今回は例としてExcelを添付してます。
```typescript:typescript
const mailOptions: SendMailOptions = {
  from: "送信元アドレス", // Amazon SESで作成したメールアドレス
  to: "送信先アドレス",
  subject: "タイトル",
  text: "本文",
  attachiments: [
    {
      filename: 'test.xlsx'
      path: './tmp/test.xlsx'
      contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
    }
  ],
};
```
設定内容をもとにメールを送信します。
```typescript:typescript
try {
  const result = await transporter.sendMail(mailOptions)
  console.log("メール送信完了:", result);
} catch (err) {
  console.log("メール送信失敗:", err);
}
```
ここまでをまとめて関数として定義すると以下のような実装となります。
あとは`sendEmail()`をボタンクリックイベント等に設定すれば添付メールを送信することができます。
```typescript:typescript
import * as dotenv from 'dotenv';
import nodemailer, { SendMailOptions } from 'nodemailer';

dotenv.config();

const transporter = nodemailer.createTransport({
  host: "email-smtp.ap-northeast-1.amazonaws.com", // SESのSMTPエンドポイント
  port: 587,
  secure: false,
  auth: {    
    user: process.env.AWS_ACCESS_KEY_ID, // SESで作成したSMTPユーザー名
    pass: process.env.AWS_SECRET_ACCESS_KEY, // SESで作成したSMTPパスワード
  },
  logger: true, // 必要に応じて設定
  debug: true, // 必要に応じて設定
});

export async function sendEmail() {
  const mailOptions: SendMailOptions = {
    from: "送信元アドレス", // Amazon SESで作成したメールアドレス
    to: "送信先アドレス",
    subject: "タイトル",
    text: "本文",
    attachiments: [
      {
        filename: 'test.xlsx'
        path: './tmp/test.xlsx'
        contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
      }
    ],
  };

  try {
    const result = await transporter.sendMail(mailOptions)
    console.log("メール送信完了:", result);
  } catch (err) {
    console.log("メール送信失敗:", err);
  }
};
```
公式ドキュメント：
https://www.nodemailer.com/transports/ses/
# ハマったポイントと解決方法
ローカル環境で動作確認ができたのでソースをECSにデプロイしてましたがメール送信ができませんでした…。
こうなったらまずはログを確認しましょう。（自戒も込めて）
確認したところ設定の不備がいくつかあったので、ハマったポイントとして紹介します。

## IAM権限不足
まず、初めにIAM権限不足のログがありました。

ローカル環境では`SendRawEmail`ポリシーが許可されたユーザーからアクセスキーを作成して設定していましたが、そもそも本番環境ではECSで実行される構成にしているためタスクロールにポリシーを追加しないといけません。

ということでタスクロールに対して以下の許可ポリシーを追加します。
```json:json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ses:SendRawEmail"
            ],
            "Resource": "*"
        }
    ]
}
```

## エンドポイントの設定不備
ポリシーを追加するとメールが送信されましたが、何度かテストしているとメールが送信できるときもあればできないときもあるという不思議な現象が発生しました。

ログを確認したところエンドポイントへの接続ができるときとできないときがあるようです。

ここで改めてsmtpエンドポイントについて調査すると、払い出されたDNS名それぞれ用途が違っていることがわかりました。
表にすると以下のようになります。

|DNS名|用途| 
|---|---|
|vpce-xxxxxx-ap-northeast-1a.email-smtp.ap-northeast-1.vpce.amazonaws.com|VPC内の1aに配置されたリソースからSESに接続するときに使用（低レイテンシでクロスAZ通信回避）|
|vpce-xxxxxx-ap-northeast-1c.email-smtp.ap-northeast-1.vpce.amazonaws.com|VPC内の1cに配置されたリソースからSESに接続するときに使用（低レイテンシでクロスAZ通信回避）|
|vpce-xxxxxx.email-smtp.ap-northeast-1.vpce.amazonaws.com|VPC内のどのAZからも接続可能|
|email-smtp.ap-northeast-1.amazonaws.com|インターネット経由でSESに接続するときに使用|

ローカル開発とエンドポイントの設定を変える必要があるということが分かりました。
サービスを使用する時はしっかり裏を取ってから使用しましょう。（自戒を込めて）

ということで修正したコードは以下になります。
これで本番環境でもメールの送信ができました。
```typescript:typescript
import * as dotenv from 'dotenv';
import nodemailer, { SendMailOptions } from 'nodemailer';

dotenv.config();

// 環境変数で本番環境かどうかを判定
const host = process.env.IS_PROD
  ? vpce-xxxxxx.email-smtp.ap-northeast-1.vpce.amazonaws.com
  : email-smtp.ap-northeast-1.amazonaws.com

const transporter = nodemailer.createTransport({
  host, // SESのSMTPエンドポイント
  port: 587,
  secure: false,
  auth: {    
    user: process.env.AWS_ACCESS_KEY_ID, // SESで作成したSMTPユーザー名
    pass: process.env.AWS_SECRET_ACCESS_KEY, // SESで作成したSMTPパスワード
  },
  logger: true, // 必要に応じて設定
  debug: true, // 必要に応じて設定
});
```
# コード最終版
最終的なコードを最後に載せておきます。
ローカル環境、本番環境ともに以下のコードで添付メールが送信できるはずです。
```typescript:typescript
import * as dotenv from 'dotenv';
import nodemailer, { SendMailOptions } from 'nodemailer';

dotenv.config();

// 環境変数で本番環境かどうかを判定
const host = process.env.IS_PROD
  ? vpce-xxxxxx.email-smtp.ap-northeast-1.vpce.amazonaws.com
  : email-smtp.ap-northeast-1.amazonaws.com

const transporter = nodemailer.createTransport({
  host, // SESのSMTPエンドポイント
  port: 587,
  secure: false,
  auth: {    
    user: process.env.AWS_ACCESS_KEY_ID, // SESで作成したSMTPユーザー名
    pass: process.env.AWS_SECRET_ACCESS_KEY, // SESで作成したSMTPパスワード
  },
  logger: true, // 必要に応じて設定
  debug: true, // 必要に応じて設定
});

export async function sendEmail() {
  const mailOptions: SendMailOptions = {
    from: "送信元アドレス", // Amazon SESで作成したメールアドレス
    to: "送信先アドレス",
    subject: "タイトル",
    text: "本文",
    attachiments: [
      {
        filename: 'test.xlsx'
        path: './tmp/test.xlsx'
        contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
      }
    ],
  };

  try {
    const result = await transporter.sendMail(mailOptions)
    console.log("メール送信完了:", result);
  } catch (err) {
    console.log("メール送信失敗:", err);
  }
};
```
# おわりに
本記事では、Fargate上のアプリケーションからAmazon SESとNodemailerを使って添付ファイル付きメールを送る方法を解説しました。途中でハマったポイントや解決方法も含めてお伝えしましたが、実際はそれ以外にもAWSの設定を忘れている部分が多く「あ、これは絶対同じことをお願いされてもどこかでミスってしまうやつだ…」と感じました。

だからこそIaCの考え方が大事だなと改めて感じましたし、筆者も途中からAWS CDK（CloudFormation）で最初から構築すればよかったと後悔しました。学習コストは少々かかりますが、手作業を減らすことで設定ミスも減り、デプロイが安定しますし、コードで管理できるのはやっぱり最高だと思います。

少々脱線してしまいましたが、ここまでお付き合いいただきありがとうございました！
記事通りにやったのにできなかった等がありましたらご指摘頂けると幸いです。
本記事がどなたかの参考になればとても嬉しいです:blush: