fix: Email template improvements

This commit is contained in:
Eric Gullickson
2025-12-28 16:56:36 -06:00
parent e65669fede
commit 57d2c43da7
13 changed files with 325 additions and 64 deletions

View File

@@ -0,0 +1,85 @@
/**
* Base HTML Email Layout
* @ai-summary Main email wrapper with MotoVaultPro branding
* @ai-context Uses table-based layout for email client compatibility
*/
import { EMAIL_STYLES } from './email-styles';
// External logo URL - hosted on GitHub for reliability
const LOGO_URL = 'https://raw.githubusercontent.com/ericgullickson/images/c58b0e4773e8395b532f97f6ab529e38ea4dc8be/motovaultpro-auth0-small.png';
/**
* Renders the complete HTML email layout with branding
* @param content - The rendered email body content (HTML)
* @returns Complete HTML email string with DOCTYPE and layout
*/
export function renderEmailLayout(content: string): string {
return `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>MotoVaultPro</title>
<!--[if mso]>
<style type="text/css">
table { border-collapse: collapse; }
.outlook-fix { font-family: Arial, sans-serif; }
</style>
<![endif]-->
</head>
<body style="margin: 0; padding: 0; background-color: #f8f9fa;">
<!-- Wrapper table for full width background -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="${EMAIL_STYLES.wrapper}">
<tr>
<td align="center" style="${EMAIL_STYLES.cell}">
<!-- Main container table (max-width 600px) -->
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="${EMAIL_STYLES.container}" class="outlook-fix">
<!-- Header with logo -->
<tr>
<td style="${EMAIL_STYLES.header}">
<img src="${LOGO_URL}" alt="MotoVaultPro" style="${EMAIL_STYLES.logo}" width="280" />
</td>
</tr>
<!-- Content area -->
<tr>
<td style="${EMAIL_STYLES.content}">
${content}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="${EMAIL_STYLES.footer}">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="center">
<p style="${EMAIL_STYLES.footerText}">
<strong>Professional Vehicle Management &amp; Maintenance Tracking</strong>
</p>
<p style="${EMAIL_STYLES.footerText}">
<a href="https://motovaultpro.com" style="${EMAIL_STYLES.footerLink}" target="_blank">Login to MotoVaultPro</a>
</p>
<p style="${EMAIL_STYLES.footerText}">
<a href="{{unsubscribeUrl}}" style="${EMAIL_STYLES.footerLink}" target="_blank">Manage Email Preferences</a>
</p>
<p style="${EMAIL_STYLES.copyright}">
&copy; 2025 MotoVaultPro. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}

View File

@@ -0,0 +1,39 @@
/**
* Email Template Inline Styles
* @ai-summary Reusable inline CSS constants for email templates
* @ai-context Email clients require inline styles for proper rendering
*/
export const EMAIL_STYLES = {
// Layout containers
wrapper: 'width: 100%; background-color: #f8f9fa; padding: 20px 0;',
container: 'max-width: 600px; margin: 0 auto; background-color: #ffffff;',
innerContainer: 'width: 100%;',
// Header
header: 'background-color: #7A212A; padding: 30px 20px; text-align: center;',
logo: 'max-width: 280px; height: auto; display: block; margin: 0 auto;',
// Content area
content: 'padding: 40px 30px; font-family: Arial, Helvetica, sans-serif; color: #1e293b; line-height: 1.6; font-size: 16px;',
// Typography
heading: 'color: #7A212A; font-size: 24px; font-weight: bold; margin: 0 0 20px 0; font-family: Arial, Helvetica, sans-serif;',
subheading: 'color: #1e293b; font-size: 18px; font-weight: bold; margin: 0 0 16px 0; font-family: Arial, Helvetica, sans-serif;',
paragraph: 'margin: 0 0 16px 0; font-size: 16px; color: #1e293b; font-family: Arial, Helvetica, sans-serif; line-height: 1.6;',
strong: 'font-weight: bold; color: #7A212A;',
// Footer
footer: 'background-color: #f1f5f9; padding: 30px 20px; text-align: center; border-top: 2px solid #7A212A;',
footerText: 'font-size: 14px; color: #64748b; margin: 8px 0; font-family: Arial, Helvetica, sans-serif;',
footerLink: 'color: #7A212A; text-decoration: none; font-weight: bold;',
footerLinkHover: 'color: #9c2a36; text-decoration: underline;',
copyright: 'font-size: 12px; color: #94a3b8; margin: 16px 0 0 0; font-family: Arial, Helvetica, sans-serif;',
// Divider
divider: 'border: 0; border-top: 1px solid #e2e8f0; margin: 20px 0;',
// Table cells
cell: 'padding: 0;',
cellWithPadding: 'padding: 20px;',
} as const;

View File

@@ -23,7 +23,7 @@ export class EmailService {
* Send an email using Resend
* @param to Recipient email address
* @param subject Email subject line
* @param html Email body (HTML format)
* @param html Email body (HTML format with inline styles for email client compatibility)
* @returns Promise that resolves when email is sent
*/
async send(to: string, subject: string, html: string): Promise<void> {
@@ -39,16 +39,4 @@ export class EmailService {
throw new Error(`Failed to send email: ${errorMessage}`);
}
}
/**
* Send an email with plain text body (converted to HTML)
* @param to Recipient email address
* @param subject Email subject line
* @param text Email body (plain text)
*/
async sendText(to: string, subject: string, text: string): Promise<void> {
// Convert plain text to HTML with proper line breaks
const html = text.split('\n').map(line => `<p>${line}</p>`).join('');
await this.send(to, subject, html);
}
}

View File

@@ -94,10 +94,15 @@ export class NotificationsService {
subject: string,
body: string,
variables: Record<string, string | number | boolean | null | undefined>
): Promise<{ subject: string; body: string }> {
): Promise<{ subject: string; body: string; html: string }> {
const renderedSubject = this.templateService.render(subject, variables);
const renderedBody = this.templateService.render(body, variables);
const renderedHtml = this.templateService.renderEmailHtml(body, variables);
return {
subject: this.templateService.render(subject, variables),
body: this.templateService.render(body, variables),
subject: renderedSubject,
body: renderedBody,
html: renderedHtml,
};
}
@@ -130,10 +135,10 @@ export class NotificationsService {
};
const subject = this.templateService.render(template.subject, variables);
const body = this.templateService.render(template.body, variables);
const htmlBody = this.templateService.renderEmailHtml(template.body, variables);
try {
await this.emailService.sendText(userEmail, subject, body);
await this.emailService.send(userEmail, subject, htmlBody);
await this.repository.insertNotificationLog({
user_id: userId,
@@ -188,10 +193,10 @@ export class NotificationsService {
};
const subject = this.templateService.render(template.subject, variables);
const body = this.templateService.render(template.body, variables);
const htmlBody = this.templateService.renderEmailHtml(template.body, variables);
try {
await this.emailService.sendText(userEmail, subject, body);
await this.emailService.send(userEmail, subject, htmlBody);
await this.repository.insertNotificationLog({
user_id: userId,
@@ -249,9 +254,10 @@ export class NotificationsService {
const subject = this.templateService.render(template.subject, sampleVariables);
const body = this.templateService.render(template.body, sampleVariables);
const htmlBody = this.templateService.renderEmailHtml(template.body, sampleVariables);
try {
await this.emailService.sendText(recipientEmail, `[TEST] ${subject}`, body);
await this.emailService.send(recipientEmail, `[TEST] ${subject}`, htmlBody);
return {
subject,

View File

@@ -3,6 +3,9 @@
* @ai-context Replaces {{variableName}} with values
*/
import { renderEmailLayout } from './email-layout/base-layout';
import { EMAIL_STYLES } from './email-layout/email-styles';
export class TemplateService {
/**
* Render a template string by replacing {{variableName}} with values
@@ -22,6 +25,38 @@ export class TemplateService {
return result;
}
/**
* Render a template as HTML email with branded layout
* @param template Template string with {{variable}} placeholders
* @param variables Object mapping variable names to values
* @returns Complete HTML email string with layout wrapper
*/
renderEmailHtml(template: string, variables: Record<string, string | number | boolean | null | undefined>): string {
// 1. Replace variables in template body
const renderedContent = this.render(template, variables);
// 2. Escape HTML special characters to prevent XSS
const escapeHtml = (text: string): string => {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
// 3. Convert plain text line breaks to HTML paragraphs
const htmlContent = renderedContent
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.map(line => `<p style="${EMAIL_STYLES.paragraph}">${escapeHtml(line)}</p>`)
.join('\n');
// 4. Wrap in branded email layout
return renderEmailLayout(htmlContent);
}
/**
* Extract variable names from a template string
* @param template Template string with {{variable}} placeholders

View File

@@ -0,0 +1,16 @@
/**
* Migration: Add HTML body column to email templates
* @ai-summary Non-breaking migration for future HTML template support
* @ai-context Existing plain text templates auto-convert to HTML
*/
-- Add optional html_body column for custom HTML templates (future enhancement)
ALTER TABLE email_templates
ADD COLUMN html_body TEXT DEFAULT NULL;
-- Add comment explaining the column purpose
COMMENT ON COLUMN email_templates.html_body IS
'Optional custom HTML body. If NULL, the plain text body will be auto-converted to HTML with base layout.';
-- No data updates needed - existing templates continue to work
-- The system will auto-convert plain text body to HTML using renderEmailHtml()