AWS初心者でもできる!LambdaとSESで自動メール通知をマスターしよう

はじめに

先日テックブログに公開した記事『図書管理アプリを支えたAWS環境を解説』の中でも少し触れました「LambdaとSESでメール通知を送る方法」について、本記事で詳しく解説します。

ぜひこちらの記事を読んだ後に、本記事をお読みください。

図書管理アプリを支えたAWS環境を解説

構成

VPCのプライベートサブネット上にあるRDSからデータを読み取り、AWS Lambdaを経由してAmazon SES(Simple Email Service)でメール通知を送ります。通知はAmazon EventBridgeを利用して1日1回毎日同じ時間に送ります。

CDK

ここでは主にLambda、 SES、 EventBridgeの作成と紐付け、権限の設定をしています。RDS ClusterやVPCは別スタックで作成したものをpropsで受け取るようにしています。

ここでハマってしまった点がありました。

LambdaとSESの接続をVPCエンドポイントで行おうとしたのですが、VPCエンドポイントではSMTPエンドポイントを利用しなければならずAPIエンドポイントを利用したければNat Gatewayを利用する必要がありました。
そのためPublicSubnetにNat Gatewayを配置しました。

LambdaはRDSのデータを取得するのでPrivateSubnetの指定をしています。

▶︎ AWS CDK

export class Lambda extends Resource {

  constructor(

    scope: Construct,

    id: string,

    {vpc, cluster}: Props

  ) {

    super(scope, id);

    const rdsSecret = secretsmanager.Secret.fromSecretCompleteArn(this, 'RdsSecret', process.env.SECRET_COMPLETE_ARN!);

    const lambdaSecurityGroup = new ec2.SecurityGroup(this, 'LambdaSecurityGroup', {

      vpc,

      allowAllOutbound: true,

    });

    const natGateway = new ec2.CfnNatGateway(this, 'NatGateway', {

      subnetId: vpc.publicSubnets[0].subnetId,

      allocationId: new ec2.CfnEIP(this, 'EIP', {}).attrAllocationId,

    });

    // ルートテーブルの設定

    vpc.privateSubnets.forEach((subnet, index) => {

      new ec2.CfnRoute(this, `PrivateSubnetRoute${index}`, {

        routeTableId: subnet.routeTable.routeTableId,

        destinationCidrBlock: '0.0.0.0/0',

        natGatewayId: natGateway.attrNatGatewayId,

      });

    });

    const lambdaFunction = new aws_lambda_nodejs.NodejsFunction(this, "notificationLambda", {

      entry: join(__dirname, '../../lambda/index.ts'),

      runtime: lambda.Runtime.NODEJS_20_X,

      handler: 'handler',

      vpc,

      vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },

      securityGroups: [lambdaSecurityGroup],

      environment: {

        SENDER_EMAIL: process.env.SENDER_EMAIL!,

        REGION: region,

        DB_HOST: process.env.POSTGRES_HOST!,

        DB_PORT: process.env.POSTGRES_PORT!,

        DB_USER: 'postgres',

        DB_NAME: 'postgres',

        DB_PASSWORD: process.env.POSTGRES_PASSWORD!,

        DB_SECRET_ARN: rdsSecret.secretArn,

      },

    });

    const rule = new events.Rule(this, 'Rule', {

      schedule: events.Schedule.cron({ minute: '30', hour: '9' }),

    });

    rule.addTarget(new targets.LambdaFunction(lambdaFunction));

    rdsSecret.grantRead(lambdaFunction)

    cluster.connections.allowDefaultPortFrom(lambdaFunction)

    lambdaFunction.addToRolePolicy(new iam.PolicyStatement({

      actions: ['ses:SendEmail', 'ses:SendRawEmail', 'ses:CreateEmailIdentity', 'ses:ListIdentities', 'ses:*'],

      resources: ['*'],

    }));

  }

}

Lambda

Lambda関数は以下のようにしました。
SESでメールを送るにはドメイン認証かメール認証が必要になります。ドメイン認証は色々大変だったので今回はメール認証で行うことにしました。

▶︎ Lambda.ts

export const handler = async () => {

  try {

    const region = process.env.REGION

    const sesClient = await new SESv2Client({ region });

    const client = new Client({

      host: process.env.DB_HOST,

      user: process.env.DB_USER,

      password: process.env.DB_PASSWORD,

      database: process.env.DB_NAME,

      port: 5432,

    });

    const sql = "SELECT bb.return_date, u.name, u.email, b.title FROM users u JOIN borrower_books bb ON u.id = bb.user_id JOIN books b ON bb.book_id = b.id WHERE bb.is_borrowing = true"

    await client.connect();

    const res = await client.query(sql);

    const data = res.rows

    const today = new Date()

    // 既存のSES Email Identitiesを取得

    const listEmailIdentitiesCommand = new ListEmailIdentitiesCommand({});

    const existingIdentities = await sesClient.send(listEmailIdentitiesCommand);

    const existingEmails = existingIdentities.EmailIdentities?.map(identity => {

      if (identity.IdentityType === 'EMAIL_ADDRESS' && identity.SendingEnabled) return identity.IdentityName

   });

    for (let i = 0; i<data.length;i++) {

      // Emailの認証

      if (existingEmails && existingEmails.length > 0 && !existingEmails.includes(data[i]['email'])) {

        // 未認証の場合

        const createEmailIdentityCommand = new CreateEmailIdentityCommand({

          EmailIdentity: data[i]['email'],

        });

        await sesClient.send(createEmailIdentityCommand);

      }

      // 返却期限を過ぎているかの確認とメールの本文の作成

      const specifiedDate = new Date(data[i]['return_date'])

      if (specifiedDate < today) {

        const emailParams = {

          FromEmailAddress: process.env.SENDER_EMAIL,

          Destination: {

            ToAddresses: [`${data[i]['email']}`],

          },

          Content: {

            Simple: {

              Subject: {

                Data: '図書館アプリからの通知 本の返却',

              },

              Body: {

                Text: {

                  Data: `お疲れ様です。${data[i]['name']}さん \n\n現在借りている本「${data[i]['title']}」の返却期限が過ぎています。\n本の返却をお願いします。`,

                }

              },

            },

          },

        };

        await sesClient.send(new SendEmailCommand(emailParams));

      }

    }

    await client.end();

    return {

      statusCode: 200,

      body: JSON.stringify({ message: 'Email sent successfully' }),

    };

  } catch (error) {

    return {

      statusCode: 500,

      body: JSON.stringify({ message: 'Failed', error }),

    };

  }

};

SESにするかSNSにするか問題

SES(Simple Email Service)にするかSNS(Simple Notification Service)にするかで悩みましたが、今回のケースではSESを利用しました。

SNSの場合、Topicごとのメールになります。そのため個別に内容の違うメールを送信する場合はTopicをそれぞれ作成して各ユーザーにサブスクライブしてもらう必要があります。ユーザーやコンテンツの種類などで大量のTopicが必要になってしまうことが考えられたので、SNSではなくSESを利用することにしました。

また、以下のコードのように動的に本文を作成すると、ユーザーごとの本文は作成されますが、サブスクライブしているメールアドレス全てに配信されてしまいます。
Topicごとに共通の内容を送る場合はSNS、個別に内容を変えたい場合はSESの利用が適していると思います。

        const sns = new AWS.SNS();

        const subscriptions = await sns.listSubscriptionsByTopic({ TopicArn: process.env.SNS_TOPIC_ARN }).promise();

        const existingSubscription = subscriptions.Subscriptions.find((sub: any) => sub.Endpoint === data[i]['email'] && sub.SubscriptionArn !== 'PendingConfirmation');

        // サブスクライブがまだの場合はサブスクライブの設定

        if (!existingSubscription) {

          const subscribeParams = {

            Protocol: 'email',

            TopicArn: process.env.SNS_TOPIC_ARN,

            Endpoint: data[i]['email'],

          };

          await sns.subscribe(subscribeParams).promise();

        }

          // 認証済みの人にはメールを送る

          const publishParams = {

            Message: getMessage(data[i]['name'], data[i]['title']),

            Subject: '本の返却期限のお知らせ',

            TopicArn: process.env.SNS_TOPIC_ARN,

            MessageAttributes: {

              'email': {

                DataType: 'String',

                StringValue: data[i]['email'],

              },

            },

          };

          await sns.publish(publishParams).promise();

さいごに

Amazon SES(Simple Email Service)とAmazon SNS(Simple Notification Service)はどちらも通知やメッセージ送信を行うためのAWSサービスですが、用途や機能が異なるので、それぞれの特徴を理解する必要があります。

これらのサービスは併用も可能で、たとえば、SNSを使ってシステムのアラートをトリガーし、その後SESで詳細なレポートをメールで送るなど、シナリオに応じて適切に使い分けることができます。

テックブログでは、これからもAWSを活用したアプリケーション開発やインフラ構築・運用に必要なTipsを発信していきます。ぜひご参考ください。

システムやプロダクトの開発をご検討中の方は、ぜひこちらからご相談ください。