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
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
14 changes: 6 additions & 8 deletions apps/backend/src/emails/emails.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,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 +32,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 @@ -66,8 +64,7 @@ export class EmailsController {
}

@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 Down Expand Up @@ -98,6 +95,7 @@ export class EmailsController {
return {
message: 'Bulk email campaign sent successfully',
sent: result.sent,
failed: result.failed,
targetGroup: body.targetGroup,
};
}
Expand Down
57 changes: 29 additions & 28 deletions apps/backend/src/emails/emails.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,38 +49,30 @@ 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[][] = [];

for (let i = 0; i < recipientEmails.length; i += batchSize) {
batches.push(recipientEmails.slice(i, i + batchSize));
}

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`);
): Promise<{ sent: number; failed: number }> {
let sent = 0;
let failed = 0;

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

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;
}

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

/**
Expand Down Expand Up @@ -175,9 +167,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
45 changes: 45 additions & 0 deletions apps/backend/src/emails/html-to-text.util.ts
Original file line number Diff line number Diff line change
@@ -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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is really excessive commenting but I thought it might be helpful cause regex's are so hard to read

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good!

.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
// Remove HTML comments, including FCC_META / FCC_BODY_* markers.
.replace(/<!--[\s\S]*?-->/g, '')
// Turn block-level boundaries into line breaks before stripping tags.
.replace(/<\/(p|div|h[1-6]|li|tr|table)>/gi, '\n')
.replace(/<br\s*\/?>/gi, '\n')
// Strip all remaining tags.
.replace(/<[^>]+>/g, '')
// Decode common HTML entities.
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
// Collapse runs of whitespace and blank lines.
.replace(/[ \t]+/g, ' ')
.replace(/\n\s*\n\s*\n+/g, '\n\n')
.replace(/^\s+|\s+$/g, '')
);
}
16 changes: 11 additions & 5 deletions apps/frontend/src/components/EmailComms/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import FCCEmailMallory from './FCCEmailMallory.png';
export type EmailTabId = 'donation' | 'relapsed' | 'mass';
export type TabId = EmailTabId;

Expand Down Expand Up @@ -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 `
<table cellpadding="0" cellspacing="0" border="0" style="width:100%;font-family:Arial,sans-serif;max-width:100%;table-layout:fixed;">
<tr>
<!-- Photo -->
${
hasImage
? `<!-- Photo -->
<td style="vertical-align:middle;width:clamp(45px, 6vw, 65px);padding-right:clamp(6px, 1vw, 12px);">
<img src="${imageSrc}" alt="${sig.name}"
<img src="${sig.imageUrl}" alt="${sig.name}"
style="width:clamp(40px, 5vw, 60px);height:clamp(40px, 5vw, 60px);border-radius:50%;object-fit:cover;object-position:top;border:2px solid white;display:block;" />
</td>
</td>`
: ''
}

<!-- Name / Title / Pronouns -->
<td style="vertical-align:middle;overflow:hidden;">
Expand Down
Loading