Skip to content

Commit 300bf2f

Browse files
authored
Merge pull request #54 from Code-4-Community/piersol-create-endpoint-for-sending-emails
#43 - Create endpoint for sending emails
2 parents e26dcd8 + 24244b5 commit 300bf2f

13 files changed

Lines changed: 5700 additions & 5025 deletions

apps/backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { UsersModule } from './users/users.module';
77
import { AuthModule } from './auth/auth.module';
88
import { PaymentsModule } from './payments/payments.module';
99
import { DonationsModule } from './donations/donations.module';
10+
import { EmailsModule } from './emails/emails.module';
1011
import AppDataSource from './data-source';
1112

1213
@Module({
@@ -16,6 +17,7 @@ import AppDataSource from './data-source';
1617
AuthModule,
1718
PaymentsModule,
1819
DonationsModule,
20+
EmailsModule,
1921
],
2022
controllers: [AppController],
2123
providers: [AppService],
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Provider } from '@nestjs/common';
2+
import { SES as AmazonSESClient } from '@aws-sdk/client-ses';
3+
import { strict as assert } from 'node:assert';
4+
import * as dotenv from 'dotenv';
5+
import path from 'node:path';
6+
dotenv.config({ path: path.resolve(__dirname, '.env') });
7+
8+
export const AMAZON_SES_CLIENT = 'AMAZON_SES_CLIENT';
9+
10+
export const amazonSESClientFactory: Provider<AmazonSESClient> = {
11+
provide: AMAZON_SES_CLIENT,
12+
useFactory: () => {
13+
assert(
14+
process.env.AWS_SES_ACCESS_KEY_ID,
15+
'AWS_SES_ACCESS_KEY_ID is not defined',
16+
);
17+
assert(
18+
process.env.AWS_SES_SECRET_ACCESS_KEY,
19+
'AWS_SES_SECRET_ACCESS_KEY is not defined',
20+
);
21+
assert(process.env.AWS_SES_REGION, 'AWS_SES_REGION is not defined');
22+
23+
return new AmazonSESClient({
24+
region: process.env.AWS_SES_REGION,
25+
credentials: {
26+
accessKeyId: process.env.AWS_SES_ACCESS_KEY_ID,
27+
secretAccessKey: process.env.AWS_SES_SECRET_ACCESS_KEY,
28+
},
29+
});
30+
},
31+
};
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Inject, Injectable } from '@nestjs/common';
2+
import {
3+
SES as AmazonSESClient,
4+
SendRawEmailCommandInput,
5+
SendRawEmailCommand,
6+
} from '@aws-sdk/client-ses';
7+
import { AMAZON_SES_CLIENT } from './amazon-ses-client.factory';
8+
import MailComposer = require('nodemailer/lib/mail-composer');
9+
import * as dotenv from 'dotenv';
10+
import Mail from 'nodemailer/lib/mailer';
11+
dotenv.config();
12+
export const AMAZON_SES_WRAPPER = 'AMAZON_SES_WRAPPER';
13+
14+
@Injectable()
15+
export class AmazonSESWrapper {
16+
private client: AmazonSESClient;
17+
18+
/**
19+
* @param client injected from `amazon-ses-client.factory.ts`
20+
*/
21+
constructor(@Inject(AMAZON_SES_CLIENT) client: AmazonSESClient) {
22+
this.client = client;
23+
}
24+
25+
/**
26+
* Sends an email via Amazon SES.
27+
*
28+
* @param recipientEmails the email addresses of the recipients
29+
* @param subject the subject of the email
30+
* @param emailContent the HTML body of the email
31+
* @resolves if the email was sent successfully
32+
* @rejects if the email was not sent successfully
33+
*/
34+
async sendEmail(
35+
recipientEmails: string[],
36+
subject: string,
37+
emailContent: string,
38+
) {
39+
const mailOptions: Mail.Options = {
40+
from: process.env.AWS_SES_SENDER_EMAIL,
41+
to: recipientEmails,
42+
subject: subject,
43+
html: emailContent,
44+
};
45+
46+
const messageData = await new MailComposer(mailOptions).compile().build();
47+
48+
const params: SendRawEmailCommandInput = {
49+
Source: process.env.AWS_SES_SENDER_EMAIL,
50+
RawMessage: { Data: messageData },
51+
Destinations: recipientEmails,
52+
};
53+
await this.client.send(new SendRawEmailCommand(params));
54+
}
55+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { IsEmail, IsString } from 'class-validator';
2+
3+
export class CreateEmailDto {
4+
@IsEmail()
5+
email: string;
6+
7+
@IsString()
8+
emailSubject: string;
9+
10+
@IsString()
11+
emailContent: string;
12+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
2+
import { EmailsService } from './emails.service';
3+
import { CreateEmailDto } from './create-email.dto';
4+
5+
@Controller('emails')
6+
export class EmailsController {
7+
constructor(private readonly emailService: EmailsService) {}
8+
9+
@Post('send-email')
10+
// @UseGuards(JwtAuthGuard) (should use auth, not implemented rn)
11+
async sendVerificationEmail(@Body() body: CreateEmailDto) {
12+
await this.emailService.sendEmail(
13+
body.email,
14+
body.emailSubject,
15+
body.emailContent,
16+
);
17+
return { message: 'email sent' };
18+
}
19+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Module } from '@nestjs/common';
2+
import { EmailsController } from './emails.controller';
3+
import { EmailsService } from './emails.service';
4+
import { AmazonSESWrapper, AMAZON_SES_WRAPPER } from './amazon-ses.wrapper';
5+
import { amazonSESClientFactory } from './amazon-ses-client.factory';
6+
import { UsersModule } from '../users/users.module';
7+
8+
@Module({
9+
imports: [UsersModule],
10+
controllers: [EmailsController],
11+
providers: [
12+
EmailsService,
13+
{
14+
provide: AMAZON_SES_WRAPPER,
15+
useClass: AmazonSESWrapper,
16+
},
17+
amazonSESClientFactory,
18+
],
19+
exports: [EmailsService],
20+
})
21+
export class EmailsModule {}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { EmailsService } from './emails.service';
3+
import { AMAZON_SES_WRAPPER } from './amazon-ses.wrapper';
4+
5+
describe('EmailsService', () => {
6+
let service: EmailsService;
7+
let mockAmazonSESWrapper: any;
8+
let loggerErrorSpy: jest.SpyInstance;
9+
10+
beforeEach(async () => {
11+
mockAmazonSESWrapper = {
12+
sendEmail: jest.fn(),
13+
};
14+
15+
const module: TestingModule = await Test.createTestingModule({
16+
providers: [
17+
EmailsService,
18+
{
19+
provide: AMAZON_SES_WRAPPER,
20+
useValue: mockAmazonSESWrapper,
21+
},
22+
],
23+
}).compile();
24+
service = module.get<EmailsService>(EmailsService);
25+
loggerErrorSpy = jest.spyOn(service['logger'], 'error');
26+
});
27+
28+
afterEach(() => {
29+
jest.clearAllMocks();
30+
});
31+
32+
describe('sendEmail', () => {
33+
const recipientEmail = 'user@example.com';
34+
const subject = 'Test Email Subject';
35+
const bodyHTML = '<h1>Test Email</h1><p>This is a test email body.</p>';
36+
37+
it('should be defined', () => {
38+
expect(service).toBeDefined();
39+
});
40+
41+
it('should send email successfully with valid parameters', async () => {
42+
const expectedResponse = { MessageId: 'test-message-id-123' };
43+
mockAmazonSESWrapper.sendEmail.mockResolvedValue(expectedResponse);
44+
45+
const result = await service.sendEmail(recipientEmail, subject, bodyHTML);
46+
47+
expect(mockAmazonSESWrapper.sendEmail).toHaveBeenCalledTimes(1);
48+
expect(mockAmazonSESWrapper.sendEmail).toHaveBeenCalledWith(
49+
[recipientEmail],
50+
subject,
51+
bodyHTML,
52+
);
53+
expect(result).toEqual(expectedResponse);
54+
expect(loggerErrorSpy).not.toHaveBeenCalled();
55+
});
56+
57+
it('should throw an error and pass on information with no loss if the SESWrapper throws', async () => {
58+
mockAmazonSESWrapper.sendEmail.mockRejectedValueOnce(
59+
new Error('Error in sending email.'),
60+
);
61+
await expect(
62+
service.sendEmail('recipient@email.com', 'Subject', '<h1>body</h1>'),
63+
).rejects.toThrow('Error in sending email.');
64+
});
65+
66+
it('should propagate the exact error thrown by wrapper', async () => {
67+
const customError = new Error('Custom error message');
68+
customError.name = 'CustomSESError';
69+
(customError as any).code = 'CUSTOM_CODE';
70+
mockAmazonSESWrapper.sendEmail.mockRejectedValue(customError);
71+
72+
await expect(
73+
service.sendEmail(recipientEmail, subject, bodyHTML),
74+
).rejects.toThrow(customError);
75+
76+
try {
77+
await service.sendEmail(recipientEmail, subject, bodyHTML);
78+
fail('Should have thrown an error');
79+
} catch (thrownError) {
80+
expect(thrownError).toBeInstanceOf(Error);
81+
expect((thrownError as Error).name).toBe('CustomSESError');
82+
expect((thrownError as any).code).toBe('CUSTOM_CODE');
83+
}
84+
});
85+
86+
it('should handle concurrent email sending calls', async () => {
87+
mockAmazonSESWrapper.sendEmail.mockResolvedValue({ MessageId: 'test' });
88+
const promises = [
89+
service.sendEmail('user1@example.com', 'Subject 1', '<p>Body 1</p>'),
90+
service.sendEmail('user2@example.com', 'Subject 2', '<p>Body 2</p>'),
91+
service.sendEmail('user3@example.com', 'Subject 3', '<p>Body 3</p>'),
92+
];
93+
94+
await Promise.all(promises);
95+
expect(mockAmazonSESWrapper.sendEmail).toHaveBeenCalledTimes(3);
96+
expect(mockAmazonSESWrapper.sendEmail).toHaveBeenNthCalledWith(
97+
1,
98+
['user1@example.com'],
99+
'Subject 1',
100+
'<p>Body 1</p>',
101+
);
102+
expect(mockAmazonSESWrapper.sendEmail).toHaveBeenNthCalledWith(
103+
2,
104+
['user2@example.com'],
105+
'Subject 2',
106+
'<p>Body 2</p>',
107+
);
108+
expect(mockAmazonSESWrapper.sendEmail).toHaveBeenNthCalledWith(
109+
3,
110+
['user3@example.com'],
111+
'Subject 3',
112+
'<p>Body 3</p>',
113+
);
114+
});
115+
});
116+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Inject, Injectable, Logger } from '@nestjs/common';
2+
import { AMAZON_SES_WRAPPER } from './amazon-ses.wrapper';
3+
4+
@Injectable()
5+
export class EmailsService {
6+
private readonly logger = new Logger(EmailsService.name);
7+
constructor(
8+
@Inject(AMAZON_SES_WRAPPER)
9+
private readonly amazonSESWrapper: any,
10+
) {}
11+
12+
/**
13+
* Sends an email.
14+
*
15+
* @param recipientEmail the email address of the recipient
16+
* @param subject the subject of the email
17+
* @param bodyHtml the HTML body of the email
18+
* @resolves if the email was sent successfully
19+
* @rejects if the email was not sent successfully
20+
*/
21+
public async sendEmail(
22+
recipientEmail: string,
23+
subject: string,
24+
bodyHTML: string,
25+
): Promise<unknown> {
26+
try {
27+
return this.amazonSESWrapper.sendEmail(
28+
[recipientEmail],
29+
subject,
30+
bodyHTML,
31+
);
32+
} catch (error) {
33+
this.logger.error('Error sending email', error);
34+
throw error;
35+
}
36+
}
37+
}

apps/backend/src/payments/payments.module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { DonationsModule } from '../donations/donations.module';
1313
provide: 'STRIPE_CLIENT',
1414
useFactory: (configService: ConfigService) => {
1515
return new Stripe(configService.get<string>('STRIPE_SECRET_KEY'), {
16-
apiVersion: '2025-09-30.clover',
16+
apiVersion: '2025-12-15.clover',
1717
});
1818
},
1919
inject: [ConfigService],

apps/frontend/src/components/ui/button.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ function Button({
5151
}) {
5252
const Comp = asChild ? Slot : 'button';
5353
return (
54-
// @ts-expect-error - Slot type compatibility with button element
5554
<Comp
5655
data-slot="button"
5756
data-variant={variant}

0 commit comments

Comments
 (0)