①:SESのID設定
※メールアドレス認証の場合
SESのメニューの「設定」→「ID」からメールアドレス設定
設定するとメールアドレス宛にメールが来るので認証しておく。
※ドメイン認証の場合
Route53にすでに設定済みのドメインであればEasyDKIMでポチポチしてちょっと待てば承認される。
②:SES用のIAMロール作成
IAMユーザをとりあえず作った上で、こんな感じで。
aws iam create-role \
--role-name SESEmailSenderRole \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::YOUR_ACCOUNT_ID:user/YOUR_USER_NAME"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "your-external-id-here"
}
}
}
]
}'
それで送信権限のポリシーをアタッチする
aws iam attach-role-policy \
--role-name SESEmailSenderRole \
--policy-arn arn:aws:iam::aws:policy/AmazonSESFullAccess
③:envファイル設定
下記のような感じで
.env
AWS_REGION=ap-northeast-1
AWS_ROLE_ARN=arn:aws:iam::YOUR_ACCOUNT_ID:role/SESEmailSenderRole
# オプション
AWS_ROLE_DURATION_SECONDS=3600
AWS_EXTERNAL_ID=your-external-id-here
# 基本認証情報(AssumeRoleを実行するため)
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
AWS_SES_FROM=送り元のメアド(SESで設定したやつ)
AWS_SES_TO=適当な送り先
(テスト時の送り先はGmailのエイリアスでexample+1@example.comみたいに作ると楽)
④:IAMユーザ側のポリシー設定
下記のような形で
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::YOUR_ACCOUNT_ID:role/SESEmailSenderRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "your-external-id-here"
}
}
},
{
"Effect": "Allow",
"Action": [
"ses:SendEmail",
"ses:SendRawEmail"
],
"Resource": "*"
}
]
}
⑤:コード書く
今回Slackでの通知が失敗したらメール通知をするやつを作りたい。
sesHelper.ts
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses'
import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts'
import { sanitizeForEmailHeader, sanitizeForEmailBody } from './emailSanitizer'
// 一時認証情報をキャッシュするための変数
let cachedCredentials: {
accessKeyId: string;
secretAccessKey: string;
sessionToken: string;
expiration: Date;
} | null = null
/**
* STS AssumeRoleを使用して一時認証情報を取得する
*/
const assumeRole = async () => {
// 必要な環境変数の確認
if (!process.env.AWS_REGION) {
throw new Error('AWS_REGION is required')
}
if (!process.env.AWS_ROLE_ARN) {
throw new Error('AWS_ROLE_ARN is required for AssumeRole')
}
// キャッシュされた認証情報があり、まだ有効な場合はそれを返す
if (cachedCredentials && cachedCredentials.expiration > new Date()) {
// eslint-disable-next-line no-console
console.log('Using cached STS credentials')
return cachedCredentials
}
// eslint-disable-next-line no-console
console.log('Assuming role:', process.env.AWS_ROLE_ARN)
// STSクライアントを作成(基本認証情報またはIAMロールを使用)
// 開発環境でのみ認証情報を設定
const isDevelopment = process.env.NODE_ENV === 'development'
const stsClient = new STSClient({
region: process.env.AWS_REGION,
// 開発環境でのみ明示的な認証情報を設定
// 本番環境ではEC2インスタンスやECS、Lambda等のIAMロールを自動使用
...(isDevelopment && process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY && {
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
sessionToken: process.env.AWS_SESSION_TOKEN || undefined,
}
})
})
const assumeRoleCommand = new AssumeRoleCommand({
RoleArn: process.env.AWS_ROLE_ARN,
RoleSessionName: `ses-email-session-${Date.now()}`,
DurationSeconds: parseInt(process.env.AWS_ROLE_DURATION_SECONDS || '3600'), // デフォルト1時間
// 必要に応じて外部IDを設定
...(process.env.AWS_EXTERNAL_ID && { ExternalId: process.env.AWS_EXTERNAL_ID })
})
try {
const response = await stsClient.send(assumeRoleCommand)
if (!response.Credentials) {
throw new Error('Failed to assume role: No credentials returned')
}
// 認証情報をキャッシュ
cachedCredentials = {
accessKeyId: response.Credentials.AccessKeyId!,
secretAccessKey: response.Credentials.SecretAccessKey!,
sessionToken: response.Credentials.SessionToken!,
expiration: response.Credentials.Expiration!
}
// eslint-disable-next-line no-console
console.log('Successfully assumed role, credentials expire at:', cachedCredentials.expiration)
return cachedCredentials
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to assume role:', error)
throw error
}
}
// SESクライアントを初期化する関数
const createSESClient = async () => {
if (!process.env.AWS_REGION) {
throw new Error('AWS_REGION is required')
}
// AssumeRoleが設定されている場合はSTS経由で認証情報を取得
if (process.env.AWS_ROLE_ARN) {
const credentials = await assumeRole()
// eslint-disable-next-line no-console
console.log('Creating SES Client with AssumeRole credentials')
return new SESClient({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey,
sessionToken: credentials.sessionToken,
}
})
}
}
interface EmailParams {
to: string;
from: string;
subject: string;
body: string;
}
/**
* AWS SES経由でメールを送信する
* @param params - メールのパラメータ
*/
export const sendEmailViaSES = async (params: EmailParams) => {
// 入力パラメータをサニタイズ
const to = sanitizeForEmailHeader(params.to)
const from = sanitizeForEmailHeader(params.from)
const subject = sanitizeForEmailHeader(params.subject)
const body = sanitizeForEmailBody(params.body)
// 実行時にSESクライアントを作成(非同期)
const sesClient = await createSESClient()
const command = new SendEmailCommand({
Destination: {
ToAddresses: [to],
},
Message: {
Body: {
Text: {
Charset: 'UTF-8',
Data: body,
},
},
Subject: {
Charset: 'UTF-8',
Data: subject,
},
},
Source: from,
})
try {
const response = await sesClient?.send(command)
// eslint-disable-next-line no-console
console.log('Email sent successfully:', response?.MessageId)
return response
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to send email via SES:', error)
throw error
}
}
で、Slack通知機能側にこんな感じで関数を追加してる。
slackHelper.ts
/**
* Slackでの送信に失敗した場合、メールで通知を行う
* @param message - 送信しようとしたメッセージ
* @param attached - 添付データ
* @param error - 発生したエラー
*/
private async sendFailureNotificationEmail (message: string, attached: Record<string, string>, error: Error) {
try {
const subject = 'Slack通知の送信に失敗しました'
const formedText = this.paramToAttached(attached)
const body = `Slackへのメッセージ送信に失敗しました。\n\nエラー内容: ${error.message}\n\n元のメッセージ:\n${message}\n\n添付データ:\n${formedText}`
await sendEmailViaSES({
to: process.env.AWS_SES_TO || '',
from: process.env.AWS_SES_FROM || '',
subject,
body
})
} catch (emailError) {
console.error('Failed to send failure notification email:', emailError)
}
}
つまづいたところ
- LambdaではAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKENを環境変数に入れられない。(Lambdaに与えたロールからよしなにやってくれるらしい)
- ローカルではAWS_SESSION_TOKENなしで作成したIAMユーザから叩けたが、Lambdaから実行する場合はAWS_SESSION_TOKENが必須(ここでめっちゃつまづいた)
- TOKENがないと、The security token included in the request is invalidというエラーが出る。
- ミスって環境変数のロール設定欄にユーザのarn入れてた
- lambdaで運用しようとした時、SENDER_ROLEの信頼関係ポリシーにlambdaのロールを入れるのを忘れた