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

@@ -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

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()

View File

@@ -86,8 +86,8 @@ services:
# Application Services - Backend API
mvp-backend:
build:
context: ./backend
dockerfile: Dockerfile
context: .
dockerfile: backend/Dockerfile
cache_from:
- node:lts-alpine
container_name: mvp-backend

View File

@@ -48,16 +48,19 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
- Make no assumptions.
- Ask clarifying questions.
- Ultrathink
- The initial data load for this applicaiton during the CI/CD process in gitlab needs to be updated
- This application is ready to go into production.
- Analysis needs to be done on the CI/CD pipeline
*** CONTEXT ***
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
- The current deployment database load needs to be thoroughly critiqued and scrutenized.
- The current deployment does not take into account no downtime or miniimal downtime updates.
- The same runner's build the software that run the software
- There needs to be a balance of uptime and complexity
- production will run on a single server to start
*** ACTION - CHANGES TO IMPLEMENT ***
- The vehicle catalog currently loaded into the local mvp-postres container needs to be exported into a SQL file and saved as a source of truth
- Whatever process is running today only goes up to model year 2022.
- All the existing SQL files setup for import can be replaced with new ones created from the running mvp-postres data.
- Research this code base and ask iterative questions to compile a complete plan.
- We will pair plan this. Ask me for options for various levels of redundancy and automation
@@ -100,14 +103,17 @@ You are a senior software engineer specializsing in NodeJS, Typescript, front en
- Make no assumptions.
- Ask clarifying questions.
- Ultrathink
- You will be making changes to the color theme of this application.
- You will be making changes to email templates of this application.
*** CONTEXT ***
- This is a modern web app for managing a vehicle fleet. It has both a desktop and mobile versions of the site that both need to maintain feature parity. It's currently deployed via docker compose but in the future will be deployed via k8s.
- Read README.md CLAUDE.md and AI-INDEX.md and follow relevant instructions to understand this code repository in the context of this change.
- Currently the onboarding drop downs are washed out when using the light theme. See image.
- The colors need to change to have more contrast but retain the MUI theme for drop down.
- Start your research at this route https://motovaultpro.com/garage/settings/admin/email-templates
- The email templates are currently plain text.
- The templates need to be improved with colors and the company logo
- The company log should be base64 encoded in the email so end users don't need to download anything.
- The theme should match the website light theme
- A screenshot showing the colors is attached
*** CHANGES TO IMPLEMENT ***
- Research this code base and ask iterative questions to compile a complete plan.
- The URL is here. https://motovaultpro.com/onboarding

View File

@@ -30,6 +30,7 @@ import {
CascadeDeleteResult,
EmailTemplate,
UpdateEmailTemplateRequest,
PreviewTemplateResponse,
// User management types
ManagedUser,
ListUsersResponse,
@@ -278,15 +279,15 @@ export const adminApi = {
const response = await apiClient.put<EmailTemplate>(`/admin/email-templates/${key}`, data);
return response.data;
},
preview: async (key: string, variables: Record<string, string>): Promise<{ subject: string; body: string }> => {
const response = await apiClient.post<{ subject: string; body: string }>(
preview: async (key: string, variables: Record<string, string>): Promise<PreviewTemplateResponse> => {
const response = await apiClient.post<PreviewTemplateResponse>(
`/admin/email-templates/${key}/preview`,
{ variables }
);
return response.data;
},
sendTest: async (key: string): Promise<{ message?: string; error?: string; subject: string; body: string }> => {
const response = await apiClient.post<{ message?: string; error?: string; subject: string; body: string }>(
sendTest: async (key: string): Promise<{ message?: string; error?: string }> => {
const response = await apiClient.post<{ message?: string; error?: string }>(
`/admin/email-templates/${key}/test`
);
return response.data;

View File

@@ -38,6 +38,8 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
const [editIsActive, setEditIsActive] = useState(true);
const [previewSubject, setPreviewSubject] = useState('');
const [previewBody, setPreviewBody] = useState('');
const [previewHtml, setPreviewHtml] = useState('');
const [showHtmlPreview, setShowHtmlPreview] = useState(false);
// Queries
const { data: templates, isLoading } = useQuery({
@@ -66,6 +68,7 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
onSuccess: (data) => {
setPreviewSubject(data.subject);
setPreviewBody(data.body);
setPreviewHtml(data.html || '');
},
onError: () => {
toast.error('Failed to generate preview');
@@ -117,6 +120,8 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
setPreviewTemplate(null);
setPreviewSubject('');
setPreviewBody('');
setPreviewHtml('');
setShowHtmlPreview(false);
}, []);
const handleSave = useCallback(() => {
@@ -304,6 +309,23 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
</div>
) : (
<>
{/* Toggle HTML/Text Preview */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-700">Show HTML Preview</span>
<button
onClick={() => setShowHtmlPreview(!showHtmlPreview)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors min-h-[44px] min-w-[44px] ${
showHtmlPreview ? 'bg-blue-600' : 'bg-gray-200'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
showHtmlPreview ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Subject
@@ -313,14 +335,29 @@ export const AdminEmailTemplatesMobileScreen: React.FC = () => {
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Body
</label>
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg font-mono text-sm whitespace-pre-wrap">
{previewBody}
{showHtmlPreview ? (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
HTML Preview
</label>
<div className="border border-gray-300 rounded-lg overflow-hidden bg-gray-50">
<iframe
srcDoc={previewHtml}
style={{ width: '100%', height: '400px', border: 'none' }}
title="Email HTML Preview"
/>
</div>
</div>
</div>
) : (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Body (Plain Text)
</label>
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg font-mono text-sm whitespace-pre-wrap">
{previewBody}
</div>
</div>
)}
</>
)}

View File

@@ -210,6 +210,12 @@ export interface UpdateEmailTemplateRequest {
isActive?: boolean;
}
export interface PreviewTemplateResponse {
subject: string;
body: string;
html: string;
}
// ============================================
// User Management types (subscription tiers)
// ============================================

View File

@@ -59,6 +59,8 @@ export const AdminEmailTemplatesPage: React.FC = () => {
const [editIsActive, setEditIsActive] = useState(true);
const [previewSubject, setPreviewSubject] = useState('');
const [previewBody, setPreviewBody] = useState('');
const [previewHtml, setPreviewHtml] = useState('');
const [showHtmlPreview, setShowHtmlPreview] = useState(false);
// Queries
const { data: templates, isLoading } = useQuery({
@@ -87,6 +89,7 @@ export const AdminEmailTemplatesPage: React.FC = () => {
onSuccess: (data) => {
setPreviewSubject(data.subject);
setPreviewBody(data.body);
setPreviewHtml(data.html || '');
setPreviewDialogOpen(true);
},
onError: () => {
@@ -141,6 +144,8 @@ export const AdminEmailTemplatesPage: React.FC = () => {
setPreviewDialogOpen(false);
setPreviewSubject('');
setPreviewBody('');
setPreviewHtml('');
setShowHtmlPreview(false);
}, []);
const handleSave = useCallback(() => {
@@ -362,6 +367,16 @@ export const AdminEmailTemplatesPage: React.FC = () => {
This preview uses sample data to show how the template will appear.
</Alert>
<FormControlLabel
control={
<Switch
checked={showHtmlPreview}
onChange={(e) => setShowHtmlPreview(e.target.checked)}
/>
}
label="Show HTML Preview"
/>
<TextField
label="Subject"
fullWidth
@@ -371,19 +386,41 @@ export const AdminEmailTemplatesPage: React.FC = () => {
}}
/>
<TextField
label="Body"
fullWidth
multiline
rows={12}
value={previewBody}
InputProps={{
readOnly: true,
}}
inputProps={{
style: { fontFamily: 'monospace' },
}}
/>
{showHtmlPreview ? (
<Box>
<Typography variant="subtitle2" gutterBottom>
HTML Preview
</Typography>
<Box
sx={{
border: '1px solid #e2e8f0',
borderRadius: 1,
overflow: 'hidden',
backgroundColor: '#f8f9fa',
}}
>
<iframe
srcDoc={previewHtml}
style={{ width: '100%', height: '500px', border: 'none' }}
title="Email HTML Preview"
/>
</Box>
</Box>
) : (
<TextField
label="Body (Plain Text)"
fullWidth
multiline
rows={12}
value={previewBody}
InputProps={{
readOnly: true,
}}
inputProps={{
style: { fontFamily: 'monospace' },
}}
/>
)}
</Box>
</DialogContent>
<DialogActions>