Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { PaymentsModule } from './payments/payments.module';
import { DonationsModule } from './donations/donations.module';
import { EmailsModule } from './emails/emails.module';
import AppDataSource from './data-source';

@Module({
Expand All @@ -16,6 +17,7 @@ import AppDataSource from './data-source';
AuthModule,
PaymentsModule,
DonationsModule,
EmailsModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
31 changes: 31 additions & 0 deletions apps/backend/src/emails/amazon-ses-client.factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Provider } from '@nestjs/common';
import { SES as AmazonSESClient } from '@aws-sdk/client-ses';
import { strict as assert } from 'node:assert';
import * as dotenv from 'dotenv';
import path from 'node:path';
Copy link
Author

Choose a reason for hiding this comment

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

I struggled getting the docker to recognize the env variables, might not be necessary once we move to secrets manager?

dotenv.config({ path: path.resolve(__dirname, '.env') });

export const AMAZON_SES_CLIENT = 'AMAZON_SES_CLIENT';
Copy link
Author

Choose a reason for hiding this comment

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

Had to hardcode this so that nestJS would recognize it, otherwise I got the error that ERROR [ExceptionHandler] Nest can't resolve dependencies of the EmailsService (?).


export const amazonSESClientFactory: Provider<AmazonSESClient> = {
provide: AMAZON_SES_CLIENT,
useFactory: () => {
assert(
process.env.AWS_SES_ACCESS_KEY_ID,
'AWS_SES_ACCESS_KEY_ID is not defined',
);
assert(
process.env.AWS_SES_SECRET_ACCESS_KEY,
'AWS_SES_SECRET_ACCESS_KEY is not defined',
);
assert(process.env.AWS_SES_REGION, 'AWS_SES_REGION is not defined');

return new AmazonSESClient({
region: process.env.AWS_SES_REGION,
credentials: {
accessKeyId: process.env.AWS_SES_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SES_SECRET_ACCESS_KEY,
},
});
},
};
55 changes: 55 additions & 0 deletions apps/backend/src/emails/amazon-ses.wrapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Inject, Injectable } from '@nestjs/common';
import {
SES as AmazonSESClient,
SendRawEmailCommandInput,
SendRawEmailCommand,
} from '@aws-sdk/client-ses';
import { AMAZON_SES_CLIENT } from './amazon-ses-client.factory';
import MailComposer = require('nodemailer/lib/mail-composer');
import * as dotenv from 'dotenv';
import Mail from 'nodemailer/lib/mailer';
dotenv.config();
export const AMAZON_SES_WRAPPER = 'AMAZON_SES_WRAPPER';

@Injectable()
export class AmazonSESWrapper {
private client: AmazonSESClient;

/**
* @param client injected from `amazon-ses-client.factory.ts`
*/
constructor(@Inject(AMAZON_SES_CLIENT) client: AmazonSESClient) {
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 emailContent the HTML body of the email
* @resolves if the email was sent successfully
* @rejects if the email was not sent successfully
*/
async sendEmail(
recipientEmails: string[],
subject: string,
emailContent: string,
) {
const mailOptions: Mail.Options = {
from: process.env.AWS_SES_SENDER_EMAIL,
to: recipientEmails,
subject: subject,
html: emailContent,
};

const messageData = await new MailComposer(mailOptions).compile().build();

const params: SendRawEmailCommandInput = {
Source: process.env.AWS_SES_SENDER_EMAIL,
RawMessage: { Data: messageData },
Destinations: recipientEmails,
};
await this.client.send(new SendRawEmailCommand(params));
}
}
12 changes: 12 additions & 0 deletions apps/backend/src/emails/create-email.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { IsEmail, IsString } from 'class-validator';

export class CreateEmailDto {
@IsEmail()
email: string;

@IsString()
emailSubject: string;

@IsString()
emailContent: string;
}
19 changes: 19 additions & 0 deletions apps/backend/src/emails/emails.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { EmailsService } from './emails.service';
import { CreateEmailDto } from './create-email.dto';

@Controller('emails')
export class EmailsController {
constructor(private readonly emailService: EmailsService) {}

@Post('send-email')
// @UseGuards(JwtAuthGuard) (should use auth, not implemented rn)
Copy link
Author

Choose a reason for hiding this comment

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

commented out for now, should be uncommented once auth is added to the project for security

async sendVerificationEmail(@Body() body: CreateEmailDto) {
await this.emailService.sendEmail(
body.email,
body.emailSubject,
body.emailContent,
);
return { message: 'email sent' };
}
}
21 changes: 21 additions & 0 deletions apps/backend/src/emails/emails.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { EmailsController } from './emails.controller';
import { EmailsService } from './emails.service';
import { AmazonSESWrapper, AMAZON_SES_WRAPPER } from './amazon-ses.wrapper';
import { amazonSESClientFactory } from './amazon-ses-client.factory';
import { UsersModule } from '../users/users.module';

@Module({
imports: [UsersModule],
controllers: [EmailsController],
providers: [
EmailsService,
{
provide: AMAZON_SES_WRAPPER,
useClass: AmazonSESWrapper,
},
amazonSESClientFactory,
],
exports: [EmailsService],
})
export class EmailsModule {}
116 changes: 116 additions & 0 deletions apps/backend/src/emails/emails.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { Test, TestingModule } from '@nestjs/testing';
Copy link
Author

Choose a reason for hiding this comment

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

is this sufficient testing? I am still new to backend

import { EmailsService } from './emails.service';
import { AMAZON_SES_WRAPPER } from './amazon-ses.wrapper';

describe('EmailsService', () => {
let service: EmailsService;
let mockAmazonSESWrapper: any;
let loggerErrorSpy: jest.SpyInstance;

beforeEach(async () => {
mockAmazonSESWrapper = {
sendEmail: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
EmailsService,
{
provide: AMAZON_SES_WRAPPER,
useValue: mockAmazonSESWrapper,
},
],
}).compile();
service = module.get<EmailsService>(EmailsService);
loggerErrorSpy = jest.spyOn(service['logger'], 'error');
});

afterEach(() => {
jest.clearAllMocks();
});

describe('sendEmail', () => {
const recipientEmail = 'user@example.com';
const subject = 'Test Email Subject';
const bodyHTML = '<h1>Test Email</h1><p>This is a test email body.</p>';

it('should be defined', () => {
expect(service).toBeDefined();
});

it('should send email successfully with valid parameters', async () => {
const expectedResponse = { MessageId: 'test-message-id-123' };
mockAmazonSESWrapper.sendEmail.mockResolvedValue(expectedResponse);

const result = await service.sendEmail(recipientEmail, subject, bodyHTML);

expect(mockAmazonSESWrapper.sendEmail).toHaveBeenCalledTimes(1);
expect(mockAmazonSESWrapper.sendEmail).toHaveBeenCalledWith(
[recipientEmail],
subject,
bodyHTML,
);
expect(result).toEqual(expectedResponse);
expect(loggerErrorSpy).not.toHaveBeenCalled();
});

it('should throw an error and pass on information with no loss if the SESWrapper throws', async () => {
mockAmazonSESWrapper.sendEmail.mockRejectedValueOnce(
new Error('Error in sending email.'),
);
await expect(
service.sendEmail('recipient@email.com', 'Subject', '<h1>body</h1>'),
).rejects.toThrow('Error in sending email.');
});

it('should propagate the exact error thrown by wrapper', async () => {
const customError = new Error('Custom error message');
customError.name = 'CustomSESError';
(customError as any).code = 'CUSTOM_CODE';
mockAmazonSESWrapper.sendEmail.mockRejectedValue(customError);

await expect(
service.sendEmail(recipientEmail, subject, bodyHTML),
).rejects.toThrow(customError);

try {
await service.sendEmail(recipientEmail, subject, bodyHTML);
fail('Should have thrown an error');
} catch (thrownError) {
expect(thrownError).toBeInstanceOf(Error);
expect((thrownError as Error).name).toBe('CustomSESError');
expect((thrownError as any).code).toBe('CUSTOM_CODE');
}
});

it('should handle concurrent email sending calls', async () => {
mockAmazonSESWrapper.sendEmail.mockResolvedValue({ MessageId: 'test' });
const promises = [
service.sendEmail('user1@example.com', 'Subject 1', '<p>Body 1</p>'),
service.sendEmail('user2@example.com', 'Subject 2', '<p>Body 2</p>'),
service.sendEmail('user3@example.com', 'Subject 3', '<p>Body 3</p>'),
];

await Promise.all(promises);
expect(mockAmazonSESWrapper.sendEmail).toHaveBeenCalledTimes(3);
expect(mockAmazonSESWrapper.sendEmail).toHaveBeenNthCalledWith(
1,
['user1@example.com'],
'Subject 1',
'<p>Body 1</p>',
);
expect(mockAmazonSESWrapper.sendEmail).toHaveBeenNthCalledWith(
2,
['user2@example.com'],
'Subject 2',
'<p>Body 2</p>',
);
expect(mockAmazonSESWrapper.sendEmail).toHaveBeenNthCalledWith(
3,
['user3@example.com'],
'Subject 3',
'<p>Body 3</p>',
);
});
});
});
37 changes: 37 additions & 0 deletions apps/backend/src/emails/emails.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { AMAZON_SES_WRAPPER } from './amazon-ses.wrapper';

@Injectable()
export class EmailsService {
private readonly logger = new Logger(EmailsService.name);
constructor(
@Inject(AMAZON_SES_WRAPPER)
private readonly amazonSESWrapper: any,
) {}

/**
* Sends an email.
*
* @param recipientEmail the email address of the recipient
* @param subject the subject of the email
* @param bodyHtml the HTML body of the email
* @resolves if the email was sent successfully
* @rejects if the email was not sent successfully
*/
public async sendEmail(
recipientEmail: string,
subject: string,
bodyHTML: string,
): Promise<unknown> {
try {
return this.amazonSESWrapper.sendEmail(
[recipientEmail],
subject,
bodyHTML,
);
} catch (error) {
this.logger.error('Error sending email', error);
throw error;
}
}
}
2 changes: 1 addition & 1 deletion apps/backend/src/payments/payments.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { DonationsModule } from '../donations/donations.module';
provide: 'STRIPE_CLIENT',
useFactory: (configService: ConfigService) => {
return new Stripe(configService.get<string>('STRIPE_SECRET_KEY'), {
apiVersion: '2025-09-30.clover',
apiVersion: '2025-12-15.clover',
Copy link
Author

Choose a reason for hiding this comment

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

I keep having an issue with this file reverting / changing versions when I load my docker containers. I am not sure why this is happening.

});
},
inject: [ConfigService],
Expand Down
1 change: 0 additions & 1 deletion apps/frontend/src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ function Button({
}) {
const Comp = asChild ? Slot : 'button';
Copy link
Author

Choose a reason for hiding this comment

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

Did not modify, this is from merging my PR with main to avoid merge conflicts

return (
// @ts-expect-error - Slot type compatibility with button element
<Comp
data-slot="button"
data-variant={variant}
Expand Down
10 changes: 9 additions & 1 deletion docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ services:

# JWT and Auth (add secrets in prod)
JWT_SECRET: dev-secret-change-in-prod

AWS_SES_REGION: us-east-2
AWS_SES_ACCESS_KEY_ID: ${SES_AWS_ACCESS_KEY}
AWS_SES_SECRET_ACCESS_KEY: ${SES_AWS_SECRET_ACCESS_KEY}
AWS_SES_SENDER_EMAIL: ${SENDER_EMAIL}
COGNITO_USER_POOL_ID: ${COGNITO_USER_POOL_ID}
COGNITO_CLIENT_ID: ${COGNITO_CLIENT_ID}
COGNITO_REGION: ${COGNITO_REGION}
Expand Down Expand Up @@ -97,6 +100,11 @@ services:

STRIPE_SECRET_KEY: 'sk_test_1234567890'

AWS_SES_REGION: us-east-2
AWS_SES_ACCESS_KEY_ID: ${SES_AWS_ACCESS_KEY}
AWS_SES_SECRET_ACCESS_KEY: ${SES_AWS_SECRET_ACCESS_KEY}
AWS_SES_SENDER_EMAIL: ${SENDER_EMAIL}

ports:
- '3001:3001'
depends_on:
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"private": true,
"dependencies": {
"@aws-sdk/client-cognito-identity-provider": "^3.410.0",
"@aws-sdk/client-ses": "^3.975.0",
Copy link
Author

Choose a reason for hiding this comment

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

had to add ses to the project

"@nestjs/cli": "^10.1.17",
"@nestjs/common": "^10.0.2",
"@nestjs/config": "^4.0.2",
Expand All @@ -48,13 +49,15 @@
"@types/supertest": "^6.0.3",
"amazon-cognito-identity-js": "^6.3.5",
"axios": "^1.5.0",
"bottleneck": "^2.19.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"global": "^4.4.0",
"jwks-rsa": "^3.1.0",
"lucide-react": "^0.563.0",
"nodemailer": "^7.0.12",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"pg": "^8.16.3",
Expand All @@ -64,7 +67,7 @@
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0",
"sqlite3": "^5.1.7",
"stripe": "^19.1.0",
"stripe": "^20.2.0",
"supertest": "^7.1.4",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
Expand Down
Loading