diff --git a/apps/backend/src/emails/awsSes.wrapper.ts b/apps/backend/src/emails/awsSes.wrapper.ts new file mode 100644 index 00000000..fd0750d5 --- /dev/null +++ b/apps/backend/src/emails/awsSes.wrapper.ts @@ -0,0 +1,69 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'; +import MailComposer from 'nodemailer/lib/mail-composer'; +import * as dotenv from 'dotenv'; +import Mail from 'nodemailer/lib/mailer'; +import { AMAZON_SES_CLIENT } from './awsSesClient.factory'; +dotenv.config(); + +export interface EmailAttachment { + filename: string; + content: Buffer; +} + +@Injectable() +export class AmazonSESWrapper { + private client: SESv2Client; + + /** + * @param client injected from `awsSesClient.factory.ts` + * builds our Amazon SES v2 client with credentials from environment variables + */ + constructor(@Inject(AMAZON_SES_CLIENT) client: SESv2Client) { + this.client = client; + } + + /** + * Sends an email via Amazon SES. + * + * @param recipientEmails the email addresses of the recipients + * @param subject the subject of the email + * @param bodyHtml the HTML body of the email + * @param attachments any attachments to include in the email + * @resolves if the email was sent successfully + * @rejects if the email was not sent successfully + */ + async sendEmails( + recipientEmails: string[], + subject: string, + bodyHtml: string, + attachments?: EmailAttachment[], + ) { + const mailOptions: Mail.Options = { + from: process.env.AWS_SES_SENDER_EMAIL, + to: recipientEmails, + subject: subject, + html: bodyHtml, + }; + + if (attachments) { + mailOptions.attachments = attachments.map((a) => ({ + filename: a.filename, + content: a.content, + encoding: 'base64', + })); + } + + const messageData = await new MailComposer(mailOptions).compile().build(); + + const command = new SendEmailCommand({ + Content: { + Raw: { + Data: messageData, + }, + }, + }); + + return await this.client.send(command); + } +} diff --git a/apps/backend/src/emails/awsSesClient.factory.ts b/apps/backend/src/emails/awsSesClient.factory.ts new file mode 100644 index 00000000..f819638c --- /dev/null +++ b/apps/backend/src/emails/awsSesClient.factory.ts @@ -0,0 +1,28 @@ +import { Provider } from '@nestjs/common'; +import { SESv2Client } from '@aws-sdk/client-sesv2'; +import { assert } from 'console'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +export const AMAZON_SES_CLIENT = 'AMAZON_SES_CLIENT'; + +/** + * Factory that produces a new instance of the Amazon SES v2 client. + * Used to send emails via Amazon SES and actually set it up with credentials. + */ +export const AmazonSESClientFactory: Provider = { + provide: AMAZON_SES_CLIENT, + useFactory: () => { + assert( + process.env.AWS_ACCESS_KEY_ID !== undefined, + 'AWS_ACCESS_KEY_ID is not defined', + ); + assert( + process.env.AWS_SECRET_ACCESS_KEY !== undefined, + 'AWS_SECRET_ACCESS_KEY is not defined', + ); + assert(process.env.AWS_REGION !== undefined, 'AWS_REGION is not defined'); + + return new SESv2Client({ region: process.env.AWS_REGION }); + }, +}; diff --git a/apps/backend/src/emails/email.module.ts b/apps/backend/src/emails/email.module.ts new file mode 100644 index 00000000..a6cd1bd1 --- /dev/null +++ b/apps/backend/src/emails/email.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { EmailsService } from './email.service'; +import { AmazonSESWrapper } from './awsSes.wrapper'; +import { AmazonSESClientFactory } from './awsSesClient.factory'; + +@Module({ + providers: [AmazonSESWrapper, AmazonSESClientFactory, EmailsService], + exports: [EmailsService], +}) +export class EmailsModule {} diff --git a/apps/backend/src/emails/email.service.ts b/apps/backend/src/emails/email.service.ts new file mode 100644 index 00000000..a319e133 --- /dev/null +++ b/apps/backend/src/emails/email.service.ts @@ -0,0 +1,41 @@ +import { Injectable, Logger } from '@nestjs/common'; +import Bottleneck from 'bottleneck'; +import { AmazonSESWrapper, EmailAttachment } from './awsSes.wrapper'; + +@Injectable() +export class EmailsService { + private readonly EMAILS_SENT_PER_SECOND = 14; + private readonly logger = new Logger(EmailsService.name); + private readonly limiter: Bottleneck; + + constructor(private amazonSESWrapper: AmazonSESWrapper) { + this.limiter = new Bottleneck({ + minTime: Math.ceil(1000 / this.EMAILS_SENT_PER_SECOND), + maxConcurrent: 1, + }); + } + + /** + * Sends an email. + * + * @param recipientEmail the email address of the recipients + * @param subject the subject of the email + * @param bodyHtml the HTML body of the email + * @param attachments any base64 encoded attachments to inlude in the email + * @resolves if the email was sent successfully + * @rejects if the email was not sent successfully + */ + public async sendEmails( + recipientEmails: string[], + subject: string, + bodyHTML: string, + attachments?: EmailAttachment[], + ): Promise { + return this.amazonSESWrapper.sendEmails( + recipientEmails, + subject, + bodyHTML, + attachments, + ); + } +} diff --git a/apps/backend/src/emails/types.ts b/apps/backend/src/emails/types.ts new file mode 100644 index 00000000..34ffb865 --- /dev/null +++ b/apps/backend/src/emails/types.ts @@ -0,0 +1,29 @@ +import { + IsString, + IsOptional, + IsNotEmpty, + MaxLength, + IsEmail, + IsArray, +} from 'class-validator'; +import { EmailAttachment } from './awsSes.wrapper'; + +export class SendEmailDTO { + @IsArray() + @IsEmail({}, { each: true }) + @MaxLength(255, { each: true }) + toEmails!: string[]; + + @IsString() + @IsNotEmpty() + @MaxLength(255) + subject!: string; + + @IsString() + @IsNotEmpty() + bodyHtml!: string; + + @IsArray() + @IsOptional() + attachments?: EmailAttachment[]; +} diff --git a/apps/backend/src/pantries/pantries.controller.spec.ts b/apps/backend/src/pantries/pantries.controller.spec.ts index 96828a91..baeb8766 100644 --- a/apps/backend/src/pantries/pantries.controller.spec.ts +++ b/apps/backend/src/pantries/pantries.controller.spec.ts @@ -14,12 +14,14 @@ import { ReserveFoodForAllergic, ServeAllergicChildren, } from './types'; +import { EmailsService } from '../emails/email.service'; import { ApplicationStatus } from '../shared/types'; import { NotFoundException, UnauthorizedException } from '@nestjs/common'; import { User } from '../users/user.entity'; const mockPantriesService = mock(); const mockOrdersService = mock(); +const mockEmailsService = mock(); describe('PantriesController', () => { let controller: PantriesController; @@ -86,6 +88,10 @@ describe('PantriesController', () => { provide: OrdersService, useValue: mockOrdersService, }, + { + provide: EmailsService, + useValue: mockEmailsService, + }, ], }).compile(); diff --git a/apps/backend/src/pantries/pantries.controller.ts b/apps/backend/src/pantries/pantries.controller.ts index 8ed72be2..d8b4276f 100644 --- a/apps/backend/src/pantries/pantries.controller.ts +++ b/apps/backend/src/pantries/pantries.controller.ts @@ -26,6 +26,8 @@ import { } from './types'; import { Order } from '../orders/order.entity'; import { OrdersService } from '../orders/order.service'; +import { EmailsService } from '../emails/email.service'; +import { SendEmailDTO } from '../emails/types'; import { Public } from '../auth/public.decorator'; @Controller('pantries') @@ -33,6 +35,7 @@ export class PantriesController { constructor( private pantriesService: PantriesService, private ordersService: OrdersService, + private emailsService: EmailsService, ) {} @Roles(Role.PANTRY) @@ -328,4 +331,16 @@ export class PantriesController { ): Promise { return this.pantriesService.deny(pantryId); } + + @Post('/email') + async sendEmail(@Body() sendEmailDTO: SendEmailDTO): Promise { + const { toEmails, subject, bodyHtml, attachments } = sendEmailDTO; + + await this.emailsService.sendEmails( + toEmails, + subject, + bodyHtml, + attachments, + ); + } } diff --git a/apps/backend/src/pantries/pantries.module.ts b/apps/backend/src/pantries/pantries.module.ts index 5e60b78d..7cd5ea9c 100644 --- a/apps/backend/src/pantries/pantries.module.ts +++ b/apps/backend/src/pantries/pantries.module.ts @@ -5,12 +5,14 @@ import { PantriesController } from './pantries.controller'; import { Pantry } from './pantries.entity'; import { AuthModule } from '../auth/auth.module'; import { OrdersModule } from '../orders/order.module'; +import { EmailsModule } from '../emails/email.module'; import { User } from '../users/user.entity'; @Module({ imports: [ TypeOrmModule.forFeature([Pantry, User]), OrdersModule, + EmailsModule, forwardRef(() => AuthModule), ], controllers: [PantriesController], diff --git a/package.json b/package.json index d5f75f46..d225c7c3 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@aws-amplify/ui-react": "^6.9.3", "@aws-sdk/client-cognito-identity-provider": "^3.410.0", "@aws-sdk/client-s3": "^3.735.0", + "@aws-sdk/client-sesv2": "^3.989.0", "@aws-sdk/lib-storage": "^3.735.0", "@chakra-ui/icons": "^2.2.4", "@chakra-ui/react": "^3.27.0", @@ -38,6 +39,7 @@ "amazon-cognito-identity-js": "^6.3.5", "aws-amplify": "^6.16.0", "axios": "^1.8.2", + "bottleneck": "^2.19.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "dotenv": "^16.4.5", @@ -47,6 +49,7 @@ "lucide-react": "^0.544.0", "mongodb": "^6.1.0", "multer": "^2.0.2", + "nodemailer": "^8.0.1", "passport": "^0.6.0", "passport-jwt": "^4.0.1", "pg": "^8.12.0", @@ -76,6 +79,7 @@ "@types/jest": "30.0.0", "@types/multer": "^1.4.12", "@types/node": "^18.14.2", + "@types/nodemailer": "^6.4.17", "@types/passport-jwt": "^4.0.1", "@types/react": "^18.2.14", "@types/react-dom": "^18.2.6", diff --git a/yarn.lock b/yarn.lock index 295bbb82..6ef08033 100644 --- a/yarn.lock +++ b/yarn.lock @@ -592,6 +592,52 @@ "@smithy/util-waiter" "^4.2.8" tslib "^2.6.2" +"@aws-sdk/client-sesv2@^3.989.0": + version "3.992.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-sesv2/-/client-sesv2-3.992.0.tgz#3fb4a244e1ea93c1e99ee982e3355c4fb81197dc" + integrity sha512-cIEsRYH2m+P1nyn2MG/l7x05uSHl6H7aROrHYa7Q+LnafvMIZ38hhRuF53qTga9ZkDjJiUy3peoaXRkzBs4Efw== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "^3.973.10" + "@aws-sdk/credential-provider-node" "^3.972.9" + "@aws-sdk/middleware-host-header" "^3.972.3" + "@aws-sdk/middleware-logger" "^3.972.3" + "@aws-sdk/middleware-recursion-detection" "^3.972.3" + "@aws-sdk/middleware-user-agent" "^3.972.10" + "@aws-sdk/region-config-resolver" "^3.972.3" + "@aws-sdk/signature-v4-multi-region" "3.992.0" + "@aws-sdk/types" "^3.973.1" + "@aws-sdk/util-endpoints" "3.992.0" + "@aws-sdk/util-user-agent-browser" "^3.972.3" + "@aws-sdk/util-user-agent-node" "^3.972.8" + "@smithy/config-resolver" "^4.4.6" + "@smithy/core" "^3.23.0" + "@smithy/fetch-http-handler" "^5.3.9" + "@smithy/hash-node" "^4.2.8" + "@smithy/invalid-dependency" "^4.2.8" + "@smithy/middleware-content-length" "^4.2.8" + "@smithy/middleware-endpoint" "^4.4.14" + "@smithy/middleware-retry" "^4.4.31" + "@smithy/middleware-serde" "^4.2.9" + "@smithy/middleware-stack" "^4.2.8" + "@smithy/node-config-provider" "^4.3.8" + "@smithy/node-http-handler" "^4.4.10" + "@smithy/protocol-http" "^5.3.8" + "@smithy/smithy-client" "^4.11.3" + "@smithy/types" "^4.12.0" + "@smithy/url-parser" "^4.2.8" + "@smithy/util-base64" "^4.3.0" + "@smithy/util-body-length-browser" "^4.2.0" + "@smithy/util-body-length-node" "^4.2.1" + "@smithy/util-defaults-mode-browser" "^4.3.30" + "@smithy/util-defaults-mode-node" "^4.2.33" + "@smithy/util-endpoints" "^3.2.8" + "@smithy/util-middleware" "^4.2.8" + "@smithy/util-retry" "^4.2.8" + "@smithy/util-utf8" "^4.2.0" + tslib "^2.6.2" + "@aws-sdk/client-sso@3.990.0": version "3.990.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-sso/-/client-sso-3.990.0.tgz#a5471e810b848f740e5c6a8abe90eb2304670c8b" @@ -5875,6 +5921,13 @@ dependencies: undici-types "~5.26.4" +"@types/nodemailer@^6.4.17": + version "6.4.22" + resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.22.tgz#8d1b8415b30a9ddc6d2866923017159f64de0859" + integrity sha512-HV16KRsW7UyZBITE07B62k8PRAKFqRSFXn1T7vslurVjN761tMDBhk5Lbt17ehyTzK6XcyJnAgUpevrvkcVOzw== + dependencies: + "@types/node" "*" + "@types/parse-json@^4.0.0": version "4.0.2" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239" @@ -8010,6 +8063,11 @@ boolbase@^1.0.0: resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== +bottleneck@^2.19.5: + version "2.19.5" + resolved "https://registry.yarnpkg.com/bottleneck/-/bottleneck-2.19.5.tgz#5df0b90f59fd47656ebe63c78a98419205cadd91" + integrity sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw== + bowser@^2.11.0: version "2.14.1" resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.14.1.tgz#4ea39bf31e305184522d7ad7bfd91389e4f0cb79" @@ -13221,6 +13279,11 @@ node-schedule@2.1.1: long-timeout "0.1.1" sorted-array-functions "^1.3.0" +nodemailer@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-8.0.1.tgz#225842a5e3a06c4ee39adfdf784acd9e00bea9c2" + integrity sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg== + normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"