diff --git a/apps/backend/src/emails/awsSes.wrapper.ts b/apps/backend/src/emails/awsSes.wrapper.ts new file mode 100644 index 000000000..fd0750d58 --- /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 000000000..f819638ce --- /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 000000000..a6cd1bd12 --- /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 000000000..a319e1331 --- /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 000000000..34ffb8651 --- /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 96828a911..baeb8766f 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 8ed72be20..d8b4276f7 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 5e60b78d2..7cd5ea9cd 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/apps/frontend/src/components/forms/manufacturerApplicationForm.tsx b/apps/frontend/src/components/forms/manufacturerApplicationForm.tsx index 95fec1ab8..38c59bcb1 100644 --- a/apps/frontend/src/components/forms/manufacturerApplicationForm.tsx +++ b/apps/frontend/src/components/forms/manufacturerApplicationForm.tsx @@ -28,14 +28,22 @@ import { ManufacturerApplicationDto } from '../../types/types'; import ApiClient from '@api/apiClient'; import axios from 'axios'; import { ChevronDownIcon } from 'lucide-react'; -import { Allergen, DonateWastedFood, ManufacturerAttribute } from '../../types/manufacturerEnums'; +import { + Allergen, + DonateWastedFood, + ManufacturerAttribute, +} from '../../types/manufacturerEnums'; const ManufacturerApplicationForm: React.FC = () => { const [contactPhone, setContactPhone] = useState(''); const [secondaryContactPhone, setSecondaryContactPhone] = useState(''); - const [unlistedProductAllergens, setUnlistedProductAllergens] = useState([]); - const [facilityFreeAllergens, setFacilityFreeAllergens] = useState([]); + const [unlistedProductAllergens, setUnlistedProductAllergens] = useState< + Allergen[] + >([]); + const [facilityFreeAllergens, setFacilityFreeAllergens] = useState< + Allergen[] + >([]); const sectionTitleStyles = { fontFamily: 'inter', @@ -92,9 +100,9 @@ const ManufacturerApplicationForm: React.FC = () => { fontWeight="400" > - This form helps us learn about your company’s allergen-friendly products, - facility standards, and sustainability practices so we can connect you - with the right partner pantries. + This form helps us learn about your company’s allergen-friendly + products, facility standards, and sustainability practices so we + can connect you with the right partner pantries. Please answer as accurately as possible. If you have any questions @@ -263,7 +271,9 @@ const ManufacturerApplicationForm: React.FC = () => { 0 ? 'selected' : ''} + value={ + unlistedProductAllergens.length > 0 ? 'selected' : '' + } required style={{ position: 'absolute', @@ -388,7 +398,7 @@ const ManufacturerApplicationForm: React.FC = () => { setFacilityFreeAllergens((prev) => checked ? [...prev, value as Allergen] - : prev.filter((i) => i !== value as Allergen), + : prev.filter((i) => i !== (value as Allergen)), ); }} display="flex" @@ -485,11 +495,13 @@ const ManufacturerApplicationForm: React.FC = () => { Additional Details - We focus on partnering with eco-friendly businesses and appreciate your support in responding to the next question: + We focus on partnering with eco-friendly businesses and appreciate + your support in responding to the next question: - Are your products sustainable or environmentally conscious? Please describe. + Are your products sustainable or environmentally conscious? Please + describe.