はじめに
先日テックブログに公開した記事『図書管理アプリを支えたAWS環境を解説』の中でも少し触れました「LambdaとSESでメール通知を送る方法」について、本記事で詳しく解説します。
ぜひこちらの記事を読んだ後に、本記事をお読みください。
構成
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を発信していきます。ぜひご参考ください。
システムやプロダクトの開発をご検討中の方は、ぜひこちらからご相談ください。