diff --git a/apps/backend/src/emails/amazon-ses.wrapper.ts b/apps/backend/src/emails/amazon-ses.wrapper.ts
index 1df7cde..751da2f 100644
--- a/apps/backend/src/emails/amazon-ses.wrapper.ts
+++ b/apps/backend/src/emails/amazon-ses.wrapper.ts
@@ -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';
@@ -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();
diff --git a/apps/backend/src/emails/emails.controller.ts b/apps/backend/src/emails/emails.controller.ts
index 67fef08..5832ce4 100644
--- a/apps/backend/src/emails/emails.controller.ts
+++ b/apps/backend/src/emails/emails.controller.ts
@@ -5,6 +5,7 @@ import {
Body,
UseGuards,
BadRequestException,
+ Query,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { EmailsService } from './emails.service';
@@ -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,
@@ -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,
@@ -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 `
+
+
+
+ Unsubscribed
+
+
+
+
+
Successfully Unsubscribed
+
You have been removed from our mass mailing list.
+
+
+
+ `;
+ }
+
@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[] = [];
@@ -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,
@@ -98,6 +138,7 @@ export class EmailsController {
return {
message: 'Bulk email campaign sent successfully',
sent: result.sent,
+ failed: result.failed,
targetGroup: body.targetGroup,
};
}
diff --git a/apps/backend/src/emails/emails.service.spec.ts b/apps/backend/src/emails/emails.service.spec.ts
index d54c0ee..7f7a5ec 100644
--- a/apps/backend/src/emails/emails.service.spec.ts
+++ b/apps/backend/src/emails/emails.service.spec.ts
@@ -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;
@@ -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);
@@ -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']);
+ });
+ });
});
diff --git a/apps/backend/src/emails/emails.service.ts b/apps/backend/src/emails/emails.service.ts
index 325c8ab..5b097d7 100644
--- a/apps/backend/src/emails/emails.service.ts
+++ b/apps/backend/src/emails/emails.service.ts
@@ -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 = `
If you no longer wish to receive these emails, you can unsubscribe here.
`;
- 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('