Skip to content

Commit

Permalink
Add SES stack and send emails via aws sdk
Browse files Browse the repository at this point in the history
  • Loading branch information
MikkoKauhanen committed Jan 17, 2025
1 parent 6fec176 commit 899d2da
Show file tree
Hide file tree
Showing 7 changed files with 151 additions and 72 deletions.
21 changes: 19 additions & 2 deletions aoe-infra/bin/infra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { DocumentdbStack } from '../lib/documentdb-stack'
import { MskStack } from '../lib/msk-stack'
import { GithubActionsStack } from '../lib/githubActionsStack'
import { UtilityStack } from '../lib/utility-stack'
import { SesStack } from "../lib/ses-stack";

const app = new cdk.App()

Expand Down Expand Up @@ -96,6 +97,13 @@ if (environmentName === 'dev' || environmentName === 'qa' || environmentName ===
vpc: Network.vpc
})

if (environmentName !== 'prod') {
const SESStack = new SesStack(app, 'SesStack', {
env: { region: 'eu-west-1' },
hostedZone: HostedZones.publicHostedZone
});
}

const SecurityGroups = new SecurityGroupStack(app, 'SecurityGroupStack', {
env: { region: 'eu-west-1' },
stackName: `${environmentName}-security-groups`,
Expand Down Expand Up @@ -422,6 +430,13 @@ if (environmentName === 'dev' || environmentName === 'qa' || environmentName ===
resources: [efs.fileSystem.fileSystemArn]
})

const sesIamPolicy = new iam.PolicyStatement({
actions: ['ses:SendEmail'],
resources: [
'*'
]
});

new EcsServiceStack(app, 'WebBackendEcsService', {
env: { region: 'eu-west-1' },
stackName: `${environmentName}-web-backend-service`,
Expand Down Expand Up @@ -456,7 +471,8 @@ if (environmentName === 'dev' || environmentName === 'qa' || environmentName ===
Secrets.secrets.CLIENT_SECRET,
Secrets.secrets.JWT_SECRET,
Secrets.secrets.PROXY_URI,
Secrets.secrets.CLIENT_ID
Secrets.secrets.CLIENT_ID,
Secrets.secrets.ADMIN_EMAIL
],
utilityAccountId: utilityAccountId,
alb: Alb.alb,
Expand All @@ -472,7 +488,8 @@ if (environmentName === 'dev' || environmentName === 'qa' || environmentName ===
s3PolicyStatement,
efsPolicyStatement,
kafkaClusterIamPolicy,
kafkaTopicIamPolicy
kafkaTopicIamPolicy,
sesIamPolicy
],
privateDnsNamespace: namespace.privateDnsNamespace,
efs: {
Expand Down
14 changes: 8 additions & 6 deletions aoe-infra/environments/dev.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
"memory_limit": "1024",
"min_count": 1,
"max_count": 1,
"image_tag": "ga-281",
"image_tag": "ga-285",
"allow_ecs_exec": true,
"env_vars": {
"NODE_ENV": "production",
Expand Down Expand Up @@ -151,11 +151,13 @@
"REDIS_USERNAME": "app",
"REDIS_USE_TLS": "true",
"BASE_URL": "https://dev.aoe.fi/api/v1/",
"EMAIL_FROM": "oppimateriaalivaranto@aoe.fi",
"TRANSPORT_AUTH_USER": "oppimateriaalivaranto@aoe.fi",
"TRANSPORT_AUTH_HOST": "XXXX",
"TRANSPORT_PORT": "25",
"SEND_EMAIL": "0",
"EMAIL_FROM": "no-reply@dev.aoe.fi",

"SEND_SYSTEM_NOTIFICATION_EMAIL" : "0",
"SEND_EXPIRATION_NOTIFICATION_EMAIL" : "0",
"SEND_RATING_NOTIFICATION:EMAIL": "0",
"SEND_VERIFICATION_EMAIL": "1",

"VERIFY_EMAIL_REDIRECT_URL": "/",

"CLOUD_STORAGE_ENABLED": "1",
Expand Down
12 changes: 6 additions & 6 deletions aoe-infra/environments/prod.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,12 @@
"REDIS_USERNAME": "app",
"REDIS_USE_TLS": "true",
"BASE_URL": "https://aws.aoe.fi/api/v1/",
"EMAIL_FROM": "oppimateriaalivaranto@aoe.fi",
"TRANSPORT_AUTH_USER": "oppimateriaalivaranto@aoe.fi",
"TRANSPORT_AUTH_HOST": "XXXX",
"TRANSPORT_PORT": "25",
"SEND_EMAIL": "0",
"VERIFY_EMAIL_REDIRECT_URL": "/",

"EMAIL_FROM": "no-reply@aoe.fi",
"SEND_SYSTEM_NOTIFICATION_EMAIL" : "0",
"SEND_EXPIRATION_NOTIFICATION_EMAIL" : "0",
"SEND_RATING_NOTIFICATION:EMAIL": "0",
"SEND_VERIFICATION_EMAIL": "0",

"CLOUD_STORAGE_ENABLED": "1",
"KAFKA_ENABLED": "1",
Expand Down
11 changes: 6 additions & 5 deletions aoe-infra/environments/qa.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,12 @@
"REDIS_USERNAME": "app",
"REDIS_USE_TLS": "true",
"BASE_URL": "https://qa.aoe.fi/api/v1/",
"EMAIL_FROM": "oppimateriaalivaranto@aoe.fi",
"TRANSPORT_AUTH_USER": "oppimateriaalivaranto@aoe.fi",
"TRANSPORT_AUTH_HOST": "XXXX",
"TRANSPORT_PORT": "25",
"SEND_EMAIL": "0",

"EMAIL_FROM": "no-reply@qa.aoe.fi",
"SEND_SYSTEM_NOTIFICATION_EMAIL" : "0",
"SEND_EXPIRATION_NOTIFICATION_EMAIL" : "0",
"SEND_RATING_NOTIFICATION:EMAIL": "0",
"SEND_VERIFICATION_EMAIL": "0",
"VERIFY_EMAIL_REDIRECT_URL": "/",

"CLOUD_STORAGE_ENABLED": "1",
Expand Down
1 change: 1 addition & 0 deletions aoe-infra/lib/secrets-manager-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class SecretManagerStack extends cdk.Stack {
ANALYTICS_PG_PASS: {envVarName: 'SPRING_DATASOURCE_PRIMARY_PASSWORD', path: '/auroradbs/web-backend/dev/reporter', secretKey: 'password' },
ANALYTICS_DOCDB_PASSWORD: {envVarName: 'MONGODB_PRIMARY_PASSWORD', path: '/service/data-analytics/DOCDB_PASS', secretKey: 'secretkey' },
ANALYTICS_TRUST_STORE_PASSWORD: {envVarName: 'TRUST_STORE_PASS', path: '/service/data-analytics/TRUST_STORE_PASS', secretKey: 'secretkey' },
ADMIN_EMAIL: {envVarName: 'ADMIN_EMAIL', path: '/service/web-backend/ADMIN_EMAIL', secretKey: 'secretkey' },
}

constructor(scope: Construct, id: string, props: SecretManagerStackProps) {
Expand Down
26 changes: 26 additions & 0 deletions aoe-infra/lib/ses-stack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ses from 'aws-cdk-lib/aws-ses'
import { HostedZone } from 'aws-cdk-lib/aws-route53';
import * as cdk from 'aws-cdk-lib';
import { EmailIdentity } from "aws-cdk-lib/aws-ses";

interface sesProps extends StackProps {
hostedZone: HostedZone
}

export class SesStack extends Stack {
public emailIdentity: EmailIdentity;

constructor(scope: Construct, id: string, props: sesProps) {
super(scope, id, props);
this.emailIdentity = new ses.EmailIdentity(this, 'EmailIdentity', {
identity: ses.Identity.publicHostedZone(props.hostedZone)
})

new cdk.CfnOutput(this, 'EmailIdentityArn', {
value: this.emailIdentity.emailIdentityArn,
description: 'The ARN of the SES Email Identity',
});
}
}
138 changes: 85 additions & 53 deletions aoe-web-backend/src/services/mailService.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,67 @@
import { Request, Response, NextFunction } from 'express';
import { NextFunction, Request, Response } from 'express';
import { sign, verify } from 'jsonwebtoken';
import Mail from 'nodemailer/lib/mailer';
import { createTransport, Transporter } from 'nodemailer';
import winstonLogger from '@util/winstonLogger';

import { db } from '@resource/postgresClient';
import AWS from 'aws-sdk';

/**
* Initialize Nodemailer Transporter
*/
const transporter: Transporter = createTransport({
host: process.env.TRANSPORT_AUTH_HOST as string,
port: parseInt(process.env.TRANSPORT_PORT, 10) as number,
secure: false,
auth: {
user: process.env.TRANSPORT_AUTH_USER as string,
},
});
AWS.config.update({ region: process.env.AWS_REGION || 'eu-west-1' });
const ses = new AWS.SES();

const sendEmail = async (email: {
to: string;
from: string;
subject: string;
body: { html?: string; text?: string };
}) => {
if (email.body.html && !email.body.text) {
throw new Error('At least one of html or text body must be provided.');
}

const params: AWS.SES.SendEmailRequest = {
Destination: {
ToAddresses: [email.to],
},
Message: {
Body: {
...(email.body.html && {
Html: {
Charset: 'UTF-8',
Data: email.body.html,
},
}),
...(email.body.text && {
Text: {
Charset: 'UTF-8',
Data: email.body.text,
},
}),
},
Subject: {
Charset: 'UTF-8',
Data: email.subject,
},
},
Source: email.from,
};

return await ses.sendEmail(params).promise();
};

/**
* Send system notifications like state and error messages to the service mainteiners.
* @param content string Message to be sent as a system notification.
*/
export const sendSystemNotification = async (content: string): Promise<void> => {
const sender = 'oppimateriaalivaranto@csc.fi';
const recipient = 'oppimateriaalivaranto@csc.fi';
const subject = 'AOE System Notification';

// Plain text message to the 'text' field - HTML message to the 'html' field.
const mailOptions: Mail.Options = {
from: sender as string,
to: recipient as string,
subject: subject as string,
text: content as string,
};
try {
// If environment variable SEND_MAIL is true (1), not false (0).
if (parseInt(process.env.SEND_EMAIL, 10)) {
const info: Record<string, unknown> = await transporter.sendMail(mailOptions);
winstonLogger.debug('System email notification delivery completed: ' + info);
if (parseInt(process.env.SEND_SYSTEM_NOTIFICATION_EMAIL, 10)) {
await sendEmail({
to: process.env.ADMIN_EMAIL,
from: process.env.EMAIL_FROM,
subject: 'AOE System Notification',
body: { text: content },
});
winstonLogger.debug('System email notification delivery completed');
} else {
winstonLogger.info('System email notification not sent while email service is currently disabled');
}
Expand All @@ -56,15 +79,19 @@ export async function sendExpirationMail() {
};
try {
const materials = await getExpiredMaterials();
const emailArray = materials.filter((m) => m.email != undefined).map((m) => m.email);
mailOptions.to = emailArray;
if (!(process.env.SEND_EMAIL === '1')) {
winstonLogger.debug('Email sending disabled');
const emails = materials.filter((m) => m.email != undefined).map((m) => m.email);
if (!(process.env.SEND_EXPIRATION_NOTIFICATION_EMAIL === '1')) {
winstonLogger.debug('Material expiration email sending disabled');
} else {
for (const element of emailArray) {
mailOptions.to = element;
const info = await transporter.sendMail(mailOptions);
winstonLogger.debug('Message sent: %s', info.messageId);
for (const email of emails) {
const info = await sendEmail({
to: email,
from: mailOptions.from,
subject: mailOptions.subject,
body: { text: mailOptions.text },
});

winstonLogger.debug('Message sent: %s', info.MessageId);
}
}
} catch (err) {
Expand All @@ -84,8 +111,8 @@ export async function sendRatingNotificationMail() {
holder[d.email] = d.materialname;
}
});
if (!(process.env.SEND_EMAIL === '1')) {
winstonLogger.debug('Email sending disabled');
if (!(process.env.SEND_RATING_NOTIFICATION === '1')) {
winstonLogger.debug('Rating notification email sending disabled');
} else {
for (const element of emailArray) {
const mailOptions = {
Expand All @@ -94,10 +121,16 @@ export async function sendRatingNotificationMail() {
subject: 'Uusi arvio - Avointen oppimateriaalien kirjasto (aoe.fi)',
text: await ratingNotificationText(holder[element]),
};
winstonLogger.debug('sending rating mail to: ' + element);
winstonLogger.debug('sending rating mail to: ' + mailOptions.to);
try {
const info = await transporter.sendMail(mailOptions);
winstonLogger.debug('Message sent: %s', info.messageId);
const info = await sendEmail({
to: mailOptions.to,
from: process.env.EMAIL_FROM,
subject: mailOptions.subject,
body: { text: mailOptions.text },
});

winstonLogger.debug('Message sent: %s', info.MessageId);
} catch (error) {
winstonLogger.error(error);
}
Expand Down Expand Up @@ -141,16 +174,15 @@ export async function sendVerificationEmail(user: string, email: string) {
const token_mail_verification = sign(mail, jwtSecret, { expiresIn: '1d' });

const url = process.env.BASE_URL + 'verify?id=' + token_mail_verification;
winstonLogger.debug(await verificationEmailText(url));
const mailOptions = {
from: process.env.EMAIL_FROM,
to: email,
subject: 'Sähköpostin vahvistus - Avointen oppimateriaalien kirjasto (aoe.fi)',
html: await verificationEmailText(url),
};
if (process.env.SEND_EMAIL === '1') {
const info = await transporter.sendMail(mailOptions);
winstonLogger.debug('Message sent: %s', info.messageId);
const content = await verificationEmailText(url);

if (process.env.SEND_VERIFICATION_EMAIL === '1') {
await sendEmail({
to: email,
from: process.env.EMAIL_FROM,
subject: 'Sähköpostin vahvistus - Avointen oppimateriaalien kirjasto (aoe.fi)',
body: { html: content },
});
}
return url;
}
Expand All @@ -163,7 +195,7 @@ export async function verifyEmailToken(req: Request, res: Response, next: NextFu
const decoded = await verify(token, jwtSecret);
const id = decoded.id;
winstonLogger.debug(id);
updateVerifiedEmail(id);
await updateVerifiedEmail(id);
return res.redirect(process.env.VERIFY_EMAIL_REDIRECT_URL || '/');
} catch (err) {
winstonLogger.error('Error in verifyEmailToken(): %o', err);
Expand Down

0 comments on commit 899d2da

Please sign in to comment.