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
67 changes: 67 additions & 0 deletions apps/backend/src/emails/awsSes.wrapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Inject, Injectable } from '@nestjs/common';
import { SES as AmazonSESClient } from 'aws-sdk';
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: AmazonSESClient;

/**
* @param client injected from `amazon-ses-client.factory.ts`
* builds our Amazon SES client with credentials from environment variables
*/
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 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
*/
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 params: AmazonSESClient.SendRawEmailRequest = {
Destinations: recipientEmails,
Source: process.env.AWS_SES_SENDER_EMAIL,
RawMessage: { Data: messageData },
};

return await this.client.sendRawEmail(params).promise();
}
}
34 changes: 34 additions & 0 deletions apps/backend/src/emails/awsSesClient.factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Provider } from '@nestjs/common';
import * as AWS from 'aws-sdk';
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 client.
* Used to send emails via Amazon SES and actually set it up with credentials.
*/
export const AmazonSESClientFactory: Provider<AWS.SES> = {
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');

const SES_CONFIG: AWS.SES.ClientConfiguration = {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
region: process.env.AWS_REGION,
};

return new AWS.SES(SES_CONFIG);
},
};
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 {}
63 changes: 63 additions & 0 deletions apps/backend/src/emails/email.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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,
});
}

/**
* Queues the email to be sent. Emails are rate limit and sent at a rate of approximately 14 per second.
* Emails are never guaranteed to be delivered. Failures are logged.
*
* @param recipientEmail the email address of the recipient
* @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
*/
public async queueEmail(
recipientEmail: string,
subject: string,
bodyHTML: string,
attachments?: EmailAttachment[],
): Promise<void> {
await this.limiter
.schedule(() =>
this.sendEmail(recipientEmail, subject, bodyHTML, attachments),
)
.catch((err) => this.logger.error(err));
}

/**
* 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 sendEmail(
recipientEmail: string,
subject: string,
bodyHTML: string,
attachments?: EmailAttachment[],
): Promise<unknown> {
return this.amazonSESWrapper.sendEmails(
[recipientEmail],
subject,
bodyHTML,
attachments,
);
}
}
27 changes: 27 additions & 0 deletions apps/backend/src/emails/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {
IsString,
IsOptional,
IsNotEmpty,
Max,
MaxLength,
} from 'class-validator';
import { EmailAttachment } from './awsSes.wrapper';

export class SendEmailDTO {
@IsString()
@IsNotEmpty()
@MaxLength(255)
toEmail: string;

@IsString()
@IsNotEmpty()
@MaxLength(255)
subject: string;

@IsString()
@IsNotEmpty()
bodyHtml: string;

@IsOptional()
attachments?: EmailAttachment[];
}
10 changes: 10 additions & 0 deletions apps/backend/src/pantries/pantries.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@ 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';

@Controller('pantries')
export class PantriesController {
constructor(
private pantriesService: PantriesService,
private ordersService: OrdersService,
private emailsService: EmailsService,
) {}

@Get('/pending')
Expand Down Expand Up @@ -225,4 +228,11 @@ export class PantriesController {
): Promise<void> {
return this.pantriesService.deny(pantryId);
}

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

await this.emailsService.sendEmail(toEmail, subject, bodyHtml, attachments);
}
}
3 changes: 2 additions & 1 deletion apps/backend/src/pantries/pantries.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { PantriesService } from './pantries.service';
import { PantriesController } from './pantries.controller';
import { Pantry } from './pantries.entity';
import { OrdersModule } from '../orders/order.module';
import { EmailsModule } from '../emails/email.module';

@Module({
imports: [TypeOrmModule.forFeature([Pantry]), OrdersModule],
imports: [TypeOrmModule.forFeature([Pantry]), OrdersModule, EmailsModule],
controllers: [PantriesController],
providers: [PantriesService],
exports: [PantriesService],
Expand Down
Loading
Loading