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('')) { + finalBodyHTML = finalBodyHTML.replace( + '', + `${unsubscribeHtml}\n`, + ); + } 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 }; } /** @@ -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 { + 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 { + 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. * @@ -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); diff --git a/apps/backend/src/emails/html-to-text.util.ts b/apps/backend/src/emails/html-to-text.util.ts new file mode 100644 index 0000000..03bb3bd --- /dev/null +++ b/apps/backend/src/emails/html-to-text.util.ts @@ -0,0 +1,45 @@ +/** + * Converts an HTML email body into a readable plain-text approximation. + * + * Used to attach a `text/plain` alternative alongside the HTML part of every + * outgoing email. A multipart/alternative message improves deliverability + * (clients/spam filters penalize HTML-only mail) and degrades gracefully for + * recipients that don't render HTML. + * + * This is intentionally lightweight (no external dependency): it strips + * scripts/styles/comments and tags, decodes a handful of common entities, and + * normalizes whitespace. It does not aim for pixel-perfect rendering. + * + * @param html the HTML body of the email + * @returns a plain-text version of the body + */ +export function htmlToPlainText(html: string): string { + if (!html) { + return ''; + } + + return ( + html + // Drop content of non-visible elements entirely. + .replace(//gi, '') + .replace(//gi, '') + // Remove HTML comments, including FCC_META / FCC_BODY_* markers. + .replace(//g, '') + // Turn block-level boundaries into line breaks before stripping tags. + .replace(/<\/(p|div|h[1-6]|li|tr|table)>/gi, '\n') + .replace(//gi, '\n') + // Strip all remaining tags. + .replace(/<[^>]+>/g, '') + // Decode common HTML entities. + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + // Collapse runs of whitespace and blank lines. + .replace(/[ \t]+/g, ' ') + .replace(/\n\s*\n\s*\n+/g, '\n\n') + .replace(/^\s+|\s+$/g, '') + ); +} diff --git a/apps/frontend/index.html b/apps/frontend/index.html index 5ce0d4b..9566b6b 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -2,11 +2,11 @@ - Frontend + FCC Admin - + diff --git a/apps/frontend/public/favicon.ico b/apps/frontend/public/favicon.ico deleted file mode 100644 index 317ebcb..0000000 Binary files a/apps/frontend/public/favicon.ico and /dev/null differ diff --git a/apps/frontend/public/favicon.png b/apps/frontend/public/favicon.png new file mode 100644 index 0000000..eb2b47d Binary files /dev/null and b/apps/frontend/public/favicon.png differ diff --git a/apps/frontend/src/components/EmailComms/EmailEditorOverviewPage.tsx b/apps/frontend/src/components/EmailComms/EmailEditorOverviewPage.tsx index 54f2c98..61ca929 100644 --- a/apps/frontend/src/components/EmailComms/EmailEditorOverviewPage.tsx +++ b/apps/frontend/src/components/EmailComms/EmailEditorOverviewPage.tsx @@ -14,18 +14,33 @@ import apiClient from '../../api/apiClient'; export function EmailEditor() { const [activeTab, setActiveTab] = useState('donation'); const [emails, setEmails] = useState(defaultEmails); - const [sig, setSig] = useState(DEFAULT_SIGNATURE); + const [meta, setMeta] = useState< + Record + >({ + donation: { + sig: DEFAULT_SIGNATURE, + ctaText: 'DONATE AT OUR SITE!', + ctaLink: 'https://fenwaycommunitycenter.org/', + }, + relapsed: { + sig: DEFAULT_SIGNATURE, + ctaText: 'DONATE AT OUR SITE!', + ctaLink: 'https://fenwaycommunitycenter.org/', + }, + mass: { + sig: DEFAULT_SIGNATURE, + ctaText: 'DONATE AT OUR SITE!', + ctaLink: 'https://fenwaycommunitycenter.org/', + }, + }); const [saved, setSaved] = useState(false); const [sent, setSent] = useState(false); - const [ctaText, setCtaText] = useState('DONATE AT OUR SITE!'); - const [ctaLink, setCtaLink] = useState('https://fenwaycommunitycenter.org/'); useEffect(() => { const loadTemplates = async () => { try { const templates = await apiClient.getEmailTemplates(); if (templates && templates.length > 0) { - let loadedMeta = false; setEmails((prev) => { const next = { ...prev }; templates.forEach((t: any) => { @@ -45,21 +60,30 @@ export function EmailEditor() { body: body, }; } + }); + return next; + }); - if (!loadedMeta) { + setMeta((prev) => { + const next = { ...prev }; + templates.forEach((t: any) => { + let tab: TabId | null = null; + if (t.type === 'donation_response') tab = 'donation'; + else if (t.type === 'relapsed_donor') tab = 'relapsed'; + else if (t.type === 'email_subscribers') tab = 'mass'; + + if (tab) { const metaMatch = t.bodyHtml.match(//); if (metaMatch) { try { const meta = JSON.parse(metaMatch[1]); - if (meta.sig) setSig(meta.sig); - if (meta.ctaText) setCtaText(meta.ctaText); - if (meta.ctaLink) setCtaLink(meta.ctaLink); - loadedMeta = true; // Use the first found metadata + next[tab] = { + sig: meta.sig || next[tab].sig, + ctaText: meta.ctaText || next[tab].ctaText, + ctaLink: meta.ctaLink || next[tab].ctaLink, + }; } catch (e) { - console.error( - 'Failed to parse metadata from email template', - e, - ); + console.error('Failed to parse metadata from template', e); } } } @@ -84,14 +108,15 @@ export function EmailEditor() { const buildFullHTML = () => { const email = emails[activeTab]; - const metadata = { sig, ctaText, ctaLink }; + const m = meta[activeTab]; + const metadata = { sig: m.sig, ctaText: m.ctaText, ctaLink: m.ctaLink }; return ` ${email.body} - ${buildSignatureHTML(sig)} - ${ctaText ? `` : ''} + ${buildSignatureHTML(m.sig)} + ${m.ctaText ? `` : ''} `; }; @@ -185,12 +210,27 @@ export function EmailEditor() { activeTab={activeTab} emails={emails} onEmailChange={handleEmailChange} - sig={sig} - onSigChange={setSig} - ctaText={ctaText} - onCtaTextChange={setCtaText} - ctaLink={ctaLink} - onLinkChange={setCtaLink} + sig={meta[activeTab].sig} + onSigChange={(s) => + setMeta((prev) => ({ + ...prev, + [activeTab]: { ...prev[activeTab], sig: s }, + })) + } + ctaText={meta[activeTab].ctaText} + onCtaTextChange={(v) => + setMeta((prev) => ({ + ...prev, + [activeTab]: { ...prev[activeTab], ctaText: v }, + })) + } + ctaLink={meta[activeTab].ctaLink} + onLinkChange={(v) => + setMeta((prev) => ({ + ...prev, + [activeTab]: { ...prev[activeTab], ctaLink: v }, + })) + } saved={saved} sent={sent} onSave={handleSave} @@ -202,9 +242,9 @@ export function EmailEditor() { diff --git a/apps/frontend/src/components/EmailComms/EmailTextEditor.tsx b/apps/frontend/src/components/EmailComms/EmailTextEditor.tsx index 565a2df..436362a 100644 --- a/apps/frontend/src/components/EmailComms/EmailTextEditor.tsx +++ b/apps/frontend/src/components/EmailComms/EmailTextEditor.tsx @@ -167,6 +167,12 @@ export default function RichTextEditor({ }, }); + useEffect(() => { + if (editor && content !== editor.getHTML()) { + editor.commands.setContent(content, { emitUpdate: false }); + } + }, [content, editor]); + const setLink = useCallback(() => { if (!editor) return; diff --git a/apps/frontend/src/components/EmailComms/types.ts b/apps/frontend/src/components/EmailComms/types.ts index b1c7c3c..e8053c1 100644 --- a/apps/frontend/src/components/EmailComms/types.ts +++ b/apps/frontend/src/components/EmailComms/types.ts @@ -1,4 +1,3 @@ -import FCCEmailMallory from './FCCEmailMallory.png'; export type EmailTabId = 'donation' | 'relapsed' | 'mass'; export type TabId = EmailTabId; @@ -96,16 +95,23 @@ export function buildSignatureHTML(sig: Signature): string { : '', ].join(''); - const imageSrc = sig.imageUrl ? sig.imageUrl : FCCEmailMallory; + // Only render the photo when an https-hosted image is provided. Local/bundled + // assets won't load in a recipient's inbox, so the signature must reference a + // remote URL (CDN/S3); otherwise the photo cell is omitted entirely. + const hasImage = /^https:\/\//i.test(sig.imageUrl); return ` - + ${ + hasImage + ? ` + ` + : '' + }
- ${sig.name} -