
Amazon SES | メールアドレスの認証をSendGrid - Lambda - Slackで受信する
興味で試してみました。実際にはGoogle Workspaceなど使った方が手っ取り早いと思います。
解決したかったこと
東京リージョンの Amazon SES のメールアドレス認証をしたかった。
解決する方法が複数あるかと思いますが、調べている内にSendGrid - AWS Lambda - Slackの連携を試したくなったのでその内容をメモとして残しておくことにしました。
また、なぜメールアドレス認証をしたかったのかもメモしておきます。
Amazon Cognitoでメール通知のFROMをカスタマイズしようとするとAmazon SESでのメールアドレスの認証が必要です。
Amazon SESでドメイン認証していたとしてもFROMに使うメールアドレスもAmazon SESで認証しておかなければAmazon CognitoでFROMに設定することが出来ません(候補に表示されない)
そして、Amazon SESでメールアドレスを認証するにはそのメールアドレスで認証メールを受信する必要があります。
その際にドメインは持っているけれども、メールサーバーを利用していない場合はAmazon SESで受信設定を行い、S3に受信メールを保存することによって認証メールの内容を確認出来ますが、Amazon SESでの受信設定はリージョンによっては出来ない場合があります。
ポイント
無料(に近い金額)で利用できる
SendGrid には Parse Webhook があり、任意の API へ POST できる
AWS Lambda には HTTPS エンドポイントがある
AWS Lambda から Slack に POST できる
SendGrid の準備
SendGrid の申込から利用まで即時ではないため、先に申込しておきます。
Freeの範囲での利用予定です。
※夕方に利用申込をして、翌日お昼ごろにアカウントが有効になりました。
AWS Lambda の準備
リージョンによる金額の差は無さそうなので東京リージョンで利用します。
アーキテクチャは Arm の方がx86より20%ほどお得なので Arm にします。
ランタイムは今回何でもいいんですが Node.js にしておきます。
関数名は 「webhook-email-to-slack」とでもしておきます。
SendGridの Parse Webhook へは200ステータスを返す必要があるようです。
関数の中身は一旦以下のようにしておきます。
exports.handler = async (event) => {
console.log(event);
const response = {
statusCode: 200
};
return response;
};
AWS Lambda 関数内の「設定」-「関数URL」を作成します。

ひとまず認証タイプは「None」で作成します。

それっぽい内容で Postman から関数 URL へ POST リクエストを試します。
ひとまず Content-Type を入れてリクエスト Body を投げてみます。


ちゃんと「Status 200 OK」(レスポンス Body なし)が返ってきました。
ClowdWatch Logs で実行結果を見ても console.log の内容がありました。
Slack のチャンネルと Webhook の準備
「#mailbox」チャンネルを作成します。
次に Webhook ですが、いつもどこにあるのか迷います。今回もまず「Incoming Webhooks」にたどり着きましたが、こちらはもう古いようで「Slack Apps」に行けと・・・
うろうろしている内に「Create an app」にたどり着きます。
「From scratch」 を選択します(もう一つはBETAとなっていたので)
App Name を「mailbox」としてworkspaceを選択して作成します。
作成された App の Baseic Information にある「Incoming Webhooks」を選択します。

開いたページの「Activate Incoming Webhooks」を ON にします。

下に表示される「Add New Webhook to Workspace」のボタンから Webhook URL を生成します。

ここまですればSlackのAppsに「mailbox」アプリが表示されると思います。
AWS Lambda と Slack の連携確認
AWS Lambda 関数内の「設定」-「環境変数」で Slack の Webhook URL を追加します。

AWS Lambda 関数のコードを変更します。
試したいだけなので標準モジュールの https を利用します。
const https = require('https');
exports.handler = async (event) => {
console.log(event);
const slackWebhookUrl = process.env.SLACK_WEBHOOK_URL;
const options = {
method: 'POST',
headers: {
'Content-type': 'application/json'
}
}
const payload = {
'text': event['body']
};
const promise = new Promise((resolve, reject) => {
const request = https.request(slackWebhookUrl, options, response => {
if (response.statusCode === 200) {
resolve(response.statusCode);
} else {
console.error(response);
console.error(event);
reject(response.statusCode);
}
});
request.write(JSON.stringify(payload));
request.end();
});
return promise;
};
これでもう一度 Postman からリクエストを投げてみます。
Slack に通知されました。

SendGrid の設定
まずドメイン認証を行います。

そしてメールの受信設定に従って Inbound Parse を追加します。
Destination URL には AWS Lambdaの関数 URL を貼り付けます。

次にDNSレコード(MXレコード)を設定します。
Amazon Route 53 を利用する場合はValueにプライオリティも必要なので、「10 mx.sendgrid.net」を登録します。
ここまで出来たらGmailから送ってみます。
正しく動作すればSlackに通知されます・・・が、暗号化?されている状態でした。

どうやら body は base64 エンコードされているようです(isBase64Encoded: true というのがそれを示してそう)

AWS Lambda の関数にある body をデコードしてセットします。
'text': Buffer.from(event['body'], 'base64').toString()
これで再度試すと Slack には内容が読める状態で届きました。
整形しないと見にくいですが、Slack まで届くことが確認出来ました。
さいごに
Amazon SES でメールアドレスの認証と、Amazon Cognito でカスタムメールアドレスを利用する場合にメールアドレスが候補に出てくることまで確認出来ました。

それぞれのサービスを少しずつしか利用しない構成でしたが、所々他でも利用出来そうな部分もあったのでそれぞれの理解が深まって良かったです。
追記(2022/06/19)
参考になるサイトがあったので少し整形するようにしてみました。
FormDataを自力で整形するのも参考になり、Slackの通知内容もスッキリしました。
const https = require('https');
exports.handler = async (event) => {
console.log(event);
const names = ['to', 'from', 'text', 'sender_ip', 'subject'];
const boundary = getBoundary(event);
const rawData = Buffer.from(event['body'], 'base64').toString();
const rawDataArray = rawData.split(boundary)
const bodyArray = [];
for (let item of rawDataArray) {
let name = getMatching(item, /(?:name=")(.+?)(?:")/);
if (!name || !(name = name.trim())) continue;
let value = getMatching(item, /(?:\r\n\r\n)([\S\s]*)(?:\r\n--$)/);
if (!value) continue;
if (names.includes(name)) {
bodyArray.push(`${name}: ${value}`);
}
}
const slackWebhookUrl = process.env.SLACK_WEBHOOK_URL;
const options = {
method: 'POST',
headers: {
'Content-type': 'application/json'
}
}
const payload = {
'text': bodyArray.join('\n')
};
const promise = new Promise((resolve, reject) => {
const request = https.request(slackWebhookUrl, options, response => {
if (response.statusCode === 200) {
resolve(response.statusCode);
} else {
console.error(response);
console.error(event);
reject(response.statusCode);
}
});
request.write(JSON.stringify(payload));
request.end();
});
return promise;
};
function getBoundary(request) {
const contentType = request.headers['content-type'];
const contentTypeArray = contentType.split(';').map(item => item.trim());
const boundaryPrefix = 'boundary=';
let boundary = contentTypeArray.find(item => item.startsWith(boundaryPrefix));
if (!boundary) return null;
boundary = boundary.slice(boundaryPrefix.length);
if (boundary) boundary = boundary.trim();
return boundary;
}
function getMatching(string, regex) {
const matches = string.match(regex);
if (!matches || matches.length < 2) {
return null;
}
return matches[1];
}