Skip to content
Merged
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
4 changes: 4 additions & 0 deletions apps/backend/src/emails/amazon-ses.wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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';
import { htmlToPlainText } from './html-to-text.util';
dotenv.config();
export const AMAZON_SES_WRAPPER = 'AMAZON_SES_WRAPPER';

Expand Down Expand Up @@ -41,6 +42,9 @@ export class AmazonSESWrapper {
to: recipientEmails,
subject: subject,
html: emailContent,
// Attach a plain-text alternative so the message is multipart/alternative,
// which renders for non-HTML clients and improves spam-filter scoring.
text: htmlToPlainText(emailContent),
};

const messageData = await new MailComposer(mailOptions).compile().build();
Expand Down
57 changes: 49 additions & 8 deletions apps/backend/src/emails/emails.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Body,
UseGuards,
BadRequestException,
Query,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { EmailsService } from './emails.service';
Expand All @@ -21,8 +22,7 @@ export class EmailsController {
) {}

@Post('send-email')
// TODO: re-enable auth guard temp disabled for local debugging
// @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'))
async sendVerificationEmail(@Body() body: CreateEmailDto) {
await this.emailService.sendEmail(
body.email,
Expand All @@ -33,21 +33,20 @@ export class EmailsController {
}

@Get('template')
@UseGuards(AuthGuard('jwt'))
async getTemplates() {
return this.emailService.getAllTemplates();
}

@Get('subscribers')
// TODO: re-enable auth guard temp disabled for local debugging
// @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'))
async getSubscribers() {
const emails = await this.emailService.getSubscribers();
return { emails, count: emails.length };
}

@Post('template')
// TODO: re-enable auth guard temp disabled for local debugging
// @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'))
async saveTemplate(@Body() body: SaveTemplateDto) {
const template = await this.emailService.saveTemplate(
body.type,
Expand All @@ -65,9 +64,38 @@ export class EmailsController {
};
}

@Get('unsubscribe')
async unsubscribe(@Query('email') email: string) {
if (!email) {
throw new BadRequestException('Email is required');
}

await this.emailService.unsubscribe(email);

return `
<!DOCTYPE html>
<html>
<head>
<title>Unsubscribed</title>
<style>
body { font-family: Arial, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f9fafb; }
.container { text-align: center; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
h1 { color: #1f2937; margin-bottom: 16px; }
p { color: #4b5563; }
</style>
</head>
<body>
<div class="container">
<h1>Successfully Unsubscribed</h1>
<p>You have been removed from our mass mailing list.</p>
</div>
</body>
</html>
`;
}

@Post('bulk-send')
// TODO: re-enable auth guard temp disabled for local debugging
// @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'))
async bulkSend(@Body() body: BulkSendDto) {
let recipientEmails: string[] = [];

Expand All @@ -88,6 +116,18 @@ export class EmailsController {
};
}

// Filter out anyone who has unsubscribed
recipientEmails =
await this.emailService.filterSubscribedEmails(recipientEmails);

if (recipientEmails.length === 0) {
return {
message: 'No subscribed recipients found after filtering unsubscribes',
sent: 0,
targetGroup: body.targetGroup,
};
}

// Send bulk emails
const result = await this.emailService.sendBulkEmail(
recipientEmails,
Expand All @@ -98,6 +138,7 @@ export class EmailsController {
return {
message: 'Bulk email campaign sent successfully',
sent: result.sent,
failed: result.failed,
targetGroup: body.targetGroup,
};
}
Expand Down
96 changes: 96 additions & 0 deletions apps/backend/src/emails/emails.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { Test, TestingModule } from '@nestjs/testing';
import { EmailsService } from './emails.service';
import { AMAZON_SES_WRAPPER } from './amazon-ses.wrapper';

import { getRepositoryToken } from '@nestjs/typeorm';
import { EmailTemplate } from './email-template.entity';
import { EmailSubscriber } from './email-subscriber.entity';

describe('EmailsService', () => {
let service: EmailsService;
let mockAmazonSESWrapper: any;
Expand All @@ -19,6 +23,25 @@ describe('EmailsService', () => {
provide: AMAZON_SES_WRAPPER,
useValue: mockAmazonSESWrapper,
},
{
provide: getRepositoryToken(EmailTemplate),
useValue: {
findOne: jest.fn(),
find: jest.fn(),
create: jest.fn(),
save: jest.fn(),
},
},
{
provide: getRepositoryToken(EmailSubscriber),
useValue: {
findOne: jest.fn(),
find: jest.fn(),
create: jest.fn(),
save: jest.fn(),
createQueryBuilder: jest.fn(),
},
},
],
}).compile();
service = module.get<EmailsService>(EmailsService);
Expand Down Expand Up @@ -113,4 +136,77 @@ describe('EmailsService', () => {
);
});
});

describe('unsubscribe', () => {
it('should update an existing subscriber to unsubscribed', async () => {
const mockSubscriber = {
email: 'test@example.com',
isSubscribed: true,
unsubscribedAt: null,
};
const mockRepo = service['emailSubscriberRepository'];
(mockRepo.findOne as jest.Mock).mockResolvedValue(mockSubscriber);
(mockRepo.save as jest.Mock).mockResolvedValue({
...mockSubscriber,
isSubscribed: false,
});

await service.unsubscribe('test@example.com');

expect(mockRepo.findOne).toHaveBeenCalledWith({
where: { email: 'test@example.com' },
});
expect(mockSubscriber.isSubscribed).toBe(false);
expect(mockSubscriber.unsubscribedAt).toBeInstanceOf(Date);
expect(mockRepo.save).toHaveBeenCalledWith(mockSubscriber);
});

it('should create a new unsubscribed record if subscriber does not exist', async () => {
const mockRepo = service['emailSubscriberRepository'];
(mockRepo.findOne as jest.Mock).mockResolvedValue(null);
(mockRepo.create as jest.Mock).mockImplementation((dto) => dto);
(mockRepo.save as jest.Mock).mockImplementation(async (entity) => entity);

await service.unsubscribe('new@example.com');

expect(mockRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
email: 'new@example.com',
isSubscribed: false,
}),
);
expect(mockRepo.save).toHaveBeenCalled();
});
});

describe('filterSubscribedEmails', () => {
it('should return empty array if empty list provided', async () => {
const result = await service.filterSubscribedEmails([]);
expect(result).toEqual([]);
});

it('should filter out emails that have isSubscribed === false', async () => {
const mockRepo = service['emailSubscriberRepository'];
const mockQueryBuilder = {
where: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([
{ email: 'sub1@example.com', isSubscribed: true },
{ email: 'unsub@example.com', isSubscribed: false },
]),
};
(mockRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder,
);

const input = [
'sub1@example.com',
'unsub@example.com',
'unknown@example.com',
];
const result = await service.filterSubscribedEmails(input);

expect(result).toEqual(['sub1@example.com', 'unknown@example.com']);
});
});
});
117 changes: 91 additions & 26 deletions apps/backend/src/emails/emails.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,38 +49,45 @@ export class EmailsService {
* @param recipientEmails array of recipient email addresses
* @param subject the subject of the email
* @param bodyHTML the HTML body of the email
* @resolves with the number of emails sent
* @rejects if sending fails
* @resolves with the number of emails sent and failed
*/
public async sendBulkEmail(
recipientEmails: string[],
subject: string,
bodyHTML: string,
): Promise<{ sent: number }> {
try {
// Send emails in batches to avoid rate limiting
const batchSize = 50; // AWS SES recommends batch sizes
const batches: string[][] = [];
): Promise<{ sent: number; failed: number }> {
let sent = 0;
let failed = 0;

for (let i = 0; i < recipientEmails.length; i += batchSize) {
batches.push(recipientEmails.slice(i, i + batchSize));
}
const backendUrl = process.env.API_URL || 'http://localhost:3000/api';

let sentCount = 0;
for (const batch of batches) {
await this.amazonSESWrapper.sendEmail(batch, subject, bodyHTML);
sentCount += batch.length;
this.logger.log(`Sent batch of ${batch.length} emails`);
}
for (const email of recipientEmails) {
try {
const unsubscribeUrl = `${backendUrl}/emails/unsubscribe?email=${encodeURIComponent(email)}`;
const unsubscribeHtml = `<br><br><hr><p style="font-size: 12px; color: #666; text-align: center;">If you no longer wish to receive these emails, you can <a href="${unsubscribeUrl}">unsubscribe here</a>.</p>`;

this.logger.log(
`Successfully sent ${sentCount} emails with subject: ${subject}`,
);
return { sent: sentCount };
} catch (error) {
this.logger.error('Error sending bulk email', error);
throw error;
let finalBodyHTML = bodyHTML;
if (finalBodyHTML.includes('</body>')) {
finalBodyHTML = finalBodyHTML.replace(
'</body>',
`${unsubscribeHtml}\n</body>`,
);
} else {
finalBodyHTML += unsubscribeHtml;
}

await this.amazonSESWrapper.sendEmail([email], subject, finalBodyHTML);
sent += 1;
} catch (error) {
failed += 1;
this.logger.error(`Failed to send bulk email to ${email}`, error);
}
}

this.logger.log(
`Bulk send complete: ${sent} sent, ${failed} failed (subject: ${subject})`,
);
return { sent, failed };
}

/**
Expand Down Expand Up @@ -151,6 +158,55 @@ export class EmailsService {
return await this.emailTemplateRepository.find();
}

/**
* Unsubscribes an email from receiving mass communications.
*/
public async unsubscribe(email: string): Promise<void> {
try {
let subscriber = await this.emailSubscriberRepository.findOne({
where: { email },
});

if (!subscriber) {
subscriber = this.emailSubscriberRepository.create({
email,
isSubscribed: false,
unsubscribedAt: new Date(),
});
} else {
subscriber.isSubscribed = false;
subscriber.unsubscribedAt = new Date();
}

await this.emailSubscriberRepository.save(subscriber);
this.logger.log(`Unsubscribed email: ${email}`);
} catch (error) {
this.logger.error(`Failed to unsubscribe email: ${email}`, error);
throw error;
}
}

/**
* Filters a list of emails, returning only those that are not explicitly unsubscribed.
*/
public async filterSubscribedEmails(emails: string[]): Promise<string[]> {
if (!emails.length) return [];

const subscribers = await this.emailSubscriberRepository
.createQueryBuilder('sub')
.where('sub.email IN (:...emails)', { emails })
.select(['sub.email', 'sub.isSubscribed'])
.getMany();

const unsubscribedSet = new Set(
subscribers
.filter((sub) => sub.isSubscribed === false)
.map((sub) => sub.email),
);

return emails.filter((email) => !unsubscribedSet.has(email));
}

/**
* Sends the Donation Response email to a donor using the stored template.
*
Expand All @@ -175,9 +231,18 @@ export class EmailsService {
return;
}

const bodyHTML = template.bodyHtml
.replace(/\{\{donorName\}\}/g, donorName)
.replace(/\{\{amount\}\}/g, amount.toString());
let bodyHTML = template.bodyHtml;
try {
bodyHTML = template.bodyHtml
.replace(/\{\{donorName\}\}/g, donorName)
.replace(/\{\{amount\}\}/g, amount.toString());
} catch (error) {
// Fall back to the raw template so a bad value doesn't drop the email.
this.logger.error(
'Error replacing template variables, sending raw template',
error,
);
}

await this.sendEmail(recipientEmail, template.subject, bodyHTML);

Expand Down
Loading
Loading