-
Notifications
You must be signed in to change notification settings - Fork 1
#43 - Create endpoint for sending emails #54
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
79a93f6
e967e95
bc3e0d7
776993e
87f6385
aa2ddda
d6c65ec
dd6f65d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||
| dotenv.config({ path: path.resolve(__dirname, '.env') }); | ||
|
|
||
| export const AMAZON_SES_CLIENT = 'AMAZON_SES_CLIENT'; | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| 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, | ||
| }, | ||
| }); | ||
| }, | ||
| }; | ||
| 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)); | ||
| } | ||
| } |
| 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; | ||
| } |
| 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) | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' }; | ||
| } | ||
| } | ||
| 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 {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import { Test, TestingModule } from '@nestjs/testing'; | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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>', | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
| 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; | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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', | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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], | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -50,7 +50,6 @@ function Button({ | |
| }) { | ||
| const Comp = asChild ? Slot : 'button'; | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,7 @@ | |
| "private": true, | ||
| "dependencies": { | ||
| "@aws-sdk/client-cognito-identity-provider": "^3.410.0", | ||
| "@aws-sdk/client-ses": "^3.975.0", | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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", | ||
|
|
@@ -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", | ||
|
|
@@ -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", | ||
|
|
||
There was a problem hiding this comment.
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?