Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
0174e6b
Completed initial implementation of an endpoint, awaiting access to API
dburkhart07 Jan 18, 2026
1751e95
prettier
dburkhart07 Jan 18, 2026
dd951da
Initial setup of email testing
dburkhart07 Jan 18, 2026
5729ebc
Added DTO
dburkhart07 Jan 18, 2026
9e78a8d
merged main
dburkhart07 Feb 6, 2026
51324d2
prettier
dburkhart07 Feb 6, 2026
01f5974
Fixed dependencies
dburkhart07 Feb 6, 2026
02b001b
Fixed tests
dburkhart07 Feb 6, 2026
9007244
prettier
dburkhart07 Feb 6, 2026
39a9c95
Merged main
dburkhart07 Feb 7, 2026
d7a43ec
Fixed dependencies
dburkhart07 Feb 7, 2026
c870fca
Updated dependencies
dburkhart07 Feb 7, 2026
091c3a6
Fixed dependencies
dburkhart07 Feb 7, 2026
d6fa280
Fixed dependencies
dburkhart07 Feb 7, 2026
84eab2b
Final dependency fix
dburkhart07 Feb 7, 2026
0be9d5b
Another attempt at fixing dependencies
dburkhart07 Feb 7, 2026
2726548
Deleted yarn.lock temporarily
dburkhart07 Feb 7, 2026
c2eb2e9
Readded dependency
dburkhart07 Feb 7, 2026
26a0c77
Fixed dependencies and merged main
dburkhart07 Feb 13, 2026
d885c47
prettier
dburkhart07 Feb 13, 2026
93e7fec
Fixed email service
dburkhart07 Feb 13, 2026
0369280
Fixed email service
dburkhart07 Feb 13, 2026
e3e576b
Fixed dependencies
dburkhart07 Feb 13, 2026
d166795
fixed dependencies
dburkhart07 Feb 13, 2026
97a679b
please fix the dependencies this time
dburkhart07 Feb 13, 2026
2428076
another attempt
dburkhart07 Feb 13, 2026
11837ac
another attempt at fixing dependencies
dburkhart07 Feb 13, 2026
570982e
pls work
dburkhart07 Feb 13, 2026
c52a4b5
another fix
dburkhart07 Feb 13, 2026
84dee62
updateed workflow
dburkhart07 Feb 13, 2026
d41cec7
updated workflow
dburkhart07 Feb 13, 2026
9b7604b
fixed dep
dburkhart07 Feb 13, 2026
8f09fdf
fixed dep
dburkhart07 Feb 13, 2026
d2cbc60
pls pls pls work
dburkhart07 Feb 13, 2026
4c51c7c
Changes and adjustments fro sendEmail to have multiple email addresse…
dburkhart07 Feb 14, 2026
257ed14
prettier
dburkhart07 Feb 14, 2026
eb05092
Merged main
dburkhart07 Feb 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions apps/backend/src/emails/awsSes.wrapper.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
28 changes: 28 additions & 0 deletions apps/backend/src/emails/awsSesClient.factory.ts
Original file line number Diff line number Diff line change
@@ -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<SESv2Client> = {
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 });
},
};
10 changes: 10 additions & 0 deletions apps/backend/src/emails/email.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
41 changes: 41 additions & 0 deletions apps/backend/src/emails/email.service.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> {
return this.amazonSESWrapper.sendEmails(
recipientEmails,
subject,
bodyHTML,
attachments,
);
}
}
29 changes: 29 additions & 0 deletions apps/backend/src/emails/types.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
6 changes: 6 additions & 0 deletions apps/backend/src/pantries/pantries.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import {
ReserveFoodForAllergic,
ServeAllergicChildren,
} from './types';
import { EmailsService } from '../emails/email.service';
import { ApplicationStatus } from '../shared/types';

const mockPantriesService = mock<PantriesService>();
const mockOrdersService = mock<OrdersService>();
const mockEmailsService = mock<EmailsService>();

describe('PantriesController', () => {
let controller: PantriesController;
Expand Down Expand Up @@ -84,6 +86,10 @@ describe('PantriesController', () => {
provide: OrdersService,
useValue: mockOrdersService,
},
{
provide: EmailsService,
useValue: mockEmailsService,
},
],
}).compile();

Expand Down
15 changes: 15 additions & 0 deletions apps/backend/src/pantries/pantries.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,16 @@ 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')
export class PantriesController {
constructor(
private pantriesService: PantriesService,
private ordersService: OrdersService,
private emailsService: EmailsService,
) {}

@Roles(Role.ADMIN)
Expand Down Expand Up @@ -314,4 +317,16 @@ export class PantriesController {
): Promise<void> {
return this.pantriesService.deny(pantryId);
}

@Post('/email')
async sendEmail(@Body() sendEmailDTO: SendEmailDTO): Promise<void> {
const { toEmails, subject, bodyHtml, attachments } = sendEmailDTO;

await this.emailsService.sendEmails(
toEmails,
subject,
bodyHtml,
attachments,
);
}
Comment on lines 320 to 331
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this shouldn't be a public route. right now this would mean that, once deployed, anyone who could access the api could send unlimited emails from our aws account. if you've finished role based auth, i would recommend adding a guard, otherwise commenting this out for now

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it was going to be changed after we merged in the role based auth. i can just comment it out and whoever tests this next can uncomment

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

either way

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gotcha, either way works

}
2 changes: 2 additions & 0 deletions apps/backend/src/pantries/pantries.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
15 changes: 9 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -38,6 +39,7 @@
"amazon-cognito-identity-js": "^6.3.5",
"aws-amplify": "^6.15.10",
"axios": "^1.8.2",
"bottleneck": "^2.19.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"dotenv": "^16.4.5",
Expand All @@ -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",
Expand All @@ -63,7 +66,6 @@
"@jest/globals": "^30.1.2",
"@nestjs/schematics": "^10.0.2",
"@nestjs/testing": "^10.0.2",
"@nx/cypress": "16.10.0",
"@nx/eslint-plugin": "16.10.0",
"@nx/eslint-plugin-nx": "^16.0.0-beta.1",
"@nx/jest": "16.10.0",
Expand All @@ -75,13 +77,14 @@
"@types/jest": "^29.4.0",
"@types/multer": "^1.4.12",
"@types/node": "^18.14.2",
"@types/nodemailer": "^6.4.17",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@typescript-eslint/eslint-plugin": "^8.23.0",
"@typescript-eslint/parser": "^8.23.0",
"@vitejs/plugin-react": "^4.0.0",
"@vitest/ui": "^0.32.0",
"cypress": "^13.0.0",
"ansi-regex": "5.0.1",
"eslint": "^8.46.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-cypress": "^2.13.4",
Expand All @@ -97,6 +100,8 @@
"nx": "16.10.0",
"nx-cloud": "^19.1.0",
"prettier": "^2.6.2",
"string-width": "4.2.3",
"strip-ansi": "6.0.1",
"ts-jest": "^29.1.0",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
Expand All @@ -115,8 +120,6 @@
"semver": "^7.5.2",
"tmp": "^0.2.4",
"webpack-dev-server": "^5.2.1",
"vite": "^5.4.21",
"strip-ansi": "6.0.1",
"string-width": "4.2.3"
"vite": "^5.4.21"
}
}
}
Loading
Loading