fix: Email template improvements
This commit is contained in:
@@ -9,16 +9,21 @@ RUN apk add --no-cache dumb-init git curl
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
# Copy package files from backend directory
|
||||
COPY backend/package*.json ./
|
||||
|
||||
# Install all dependencies (including dev for building)
|
||||
RUN npm install && npm cache clean --force
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
# Copy logo from frontend for email templates (needed for build)
|
||||
RUN mkdir -p frontend/public/images/logos
|
||||
COPY frontend/public/images/logos/motovaultpro-logo-title.png frontend/public/images/logos/
|
||||
|
||||
# Build the application
|
||||
# Copy backend source code
|
||||
COPY backend/ .
|
||||
|
||||
# Build the application (prebuild will encode logo)
|
||||
ENV DOCKER_BUILD=true
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production runtime
|
||||
@@ -31,7 +36,7 @@ RUN apk add --no-cache dumb-init curl postgresql-client
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and any lock file generated in builder stage
|
||||
COPY package*.json ./
|
||||
COPY backend/package*.json ./
|
||||
COPY --from=builder /app/package-lock.json ./
|
||||
|
||||
# Install only production dependencies
|
||||
@@ -52,7 +57,7 @@ COPY --from=builder /app/src/features /app/migrations/features
|
||||
COPY --from=builder /app/src/core /app/migrations/core
|
||||
|
||||
# Copy entrypoint script for permission checks
|
||||
COPY scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
COPY backend/scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Change ownership to non-root user
|
||||
|
||||
@@ -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 & 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}">
|
||||
© 2025 MotoVaultPro. All rights reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user