7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWSのLambda + SESでメール送信するやつ備忘録

Last updated at Posted at 2025-08-01

①: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のロールを入れるのを忘れた
7
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?