feat: refactor email-worker to use HTML email templates
Replace inline HTML in email notifications with professional HTML templates
for better email client compatibility and consistent branding.
Changes:
- Created emailTemplates.ts utility for loading and rendering templates
- Template caching for performance
- Variable substitution with ${variable} syntax
- Warning logging for unreplaced variables
- Updated sendVerificationRequests to use email-validation template
- Variables: location.tenantName, ownerName, location.name, shareId
- Updated sendRentDueNotifications to use rent-due template
- Fetches user settings for owner name and currency
- Calculates rent due date from yearMonth and rentDueDay
- Formats rent amount (converts cents to display format)
- Variables: location.tenantName, location.name, rentDueDate,
rentAmount, currency, ownerName, shareId
- Updated sendUtilityBillsNotifications to use util-bills-due template
- Calculates total amount from all bills
- Fetches user settings for owner name and currency
- Variables: location.tenantName, location.name, totalAmount,
currency, ownerName, shareId
- Fixed ObjectId type mismatches in MongoDB operations
All emails now feature:
- Responsive 3-column layout
- rezije.app branding with logo
- Professional typography and spacing
- Unsubscribe links
- Email client compatible table-based layouts
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { Db, ObjectId } from 'mongodb';
|
|||||||
import { BillingLocation, EmailStatus, UserSettings, generateShareId } from '@evidencija-rezija/shared-code';
|
import { BillingLocation, EmailStatus, UserSettings, generateShareId } from '@evidencija-rezija/shared-code';
|
||||||
import { sendEmail } from './mailgunService';
|
import { sendEmail } from './mailgunService';
|
||||||
import { createLogger } from './logger';
|
import { createLogger } from './logger';
|
||||||
|
import { loadAndRender } from './emailTemplates';
|
||||||
|
|
||||||
const log = createLogger("email:senders");
|
const log = createLogger("email:senders");
|
||||||
|
|
||||||
@@ -53,23 +54,12 @@ export async function sendVerificationRequests(db: Db, budget: number): Promise<
|
|||||||
const ownerName = userSettings?.ownerName || '';
|
const ownerName = userSettings?.ownerName || '';
|
||||||
const shareId = generateShareId(location._id.toString());
|
const shareId = generateShareId(location._id.toString());
|
||||||
|
|
||||||
const html = `
|
const html = loadAndRender('email-validation', {
|
||||||
<p>Hello ${location.tenantName || 'there'}!</p>
|
'location.tenantName': location.tenantName || 'there',
|
||||||
|
'ownerName': ownerName,
|
||||||
<p>You have received this e-mail because your landlord <strong>${ownerName}</strong> wants to send you rent and utility bills invoices for ${location.name} via rezije.app</p>
|
'location.name': location.name,
|
||||||
|
'shareId': shareId
|
||||||
<p><strong>rezije.app</strong> is an online app which helps property owners to manage expenses related to properties they lease.</p>
|
});
|
||||||
|
|
||||||
<p>Before the app can start sending you rent due and utility bills emails we need your verification.</p>
|
|
||||||
|
|
||||||
<p>To verify that you want to receive these notifications please click on the following link: <a href="https://rezije.app/email/verify/${shareId}">Verify</a></p>
|
|
||||||
|
|
||||||
<p>You can ignore this email if you don't want to receive notifications. You can also unsubscribe at any time using the link included in every notification email you receive.</p>
|
|
||||||
|
|
||||||
<p>Thank you!</p>
|
|
||||||
|
|
||||||
<a href="https://rezije.app" target="_blank">rezije.app</a>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const success = await sendEmail({
|
const success = await sendEmail({
|
||||||
to: location.tenantEmail,
|
to: location.tenantEmail,
|
||||||
@@ -80,7 +70,7 @@ export async function sendVerificationRequests(db: Db, budget: number): Promise<
|
|||||||
// Update location status
|
// Update location status
|
||||||
const newStatus = success ? EmailStatus.VerificationPending : EmailStatus.VerificationFailed;
|
const newStatus = success ? EmailStatus.VerificationPending : EmailStatus.VerificationFailed;
|
||||||
await db.collection('lokacije').updateOne(
|
await db.collection('lokacije').updateOne(
|
||||||
{ _id: location._id },
|
{ _id: new ObjectId(location._id as any) },
|
||||||
{ $set: { tenantEmailStatus: newStatus } }
|
{ $set: { tenantEmailStatus: newStatus } }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -149,21 +139,29 @@ export async function sendRentDueNotifications(db: Db, budget: number): Promise<
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch user settings
|
||||||
|
const userSettings = await db.collection<UserSettings>('userSettings')
|
||||||
|
.findOne({ userId: location.userId });
|
||||||
|
|
||||||
|
const ownerName = userSettings?.ownerName || '';
|
||||||
const shareId = generateShareId(location._id.toString());
|
const shareId = generateShareId(location._id.toString());
|
||||||
|
|
||||||
const html = `
|
// Format rent due date
|
||||||
<p>Hello ${location.tenantName || 'there'}!</p>
|
const rentDueDate = `${location.yearMonth.month}/${location.rentDueDay}/${location.yearMonth.year}`;
|
||||||
|
|
||||||
<p>Your rent for the apartment ${location.name} is due today.</p>
|
// Format rent amount (convert from cents to display format)
|
||||||
|
const rentAmount = location.rentAmount ? (location.rentAmount / 100).toFixed(2) : '0.00';
|
||||||
|
const currency = userSettings?.currency || 'EUR';
|
||||||
|
|
||||||
<p>For details and payment options please click the following link: <a href="https://rezije.app/share/location/rent/${shareId}">Rent details</a></p>
|
const html = loadAndRender('rent-due', {
|
||||||
|
'location.tenantName': location.tenantName || 'there',
|
||||||
<p>Thank you!</p>
|
'location.name': location.name,
|
||||||
|
'rentDueDate': rentDueDate,
|
||||||
<p><a href="https://rezije.app" target="_blank">rezije.app</a></p>
|
'rentAmount': rentAmount,
|
||||||
|
'currency': currency,
|
||||||
<p style="font-size:.7em">If you do no longer want to receive these notifications please click the following link: <a href="https://rezije.app/email/unsubscribe/${shareId}">Unsubscribe</a></p>
|
'ownerName': ownerName,
|
||||||
`;
|
'shareId': shareId
|
||||||
|
});
|
||||||
|
|
||||||
const success = await sendEmail({
|
const success = await sendEmail({
|
||||||
to: location.tenantEmail,
|
to: location.tenantEmail,
|
||||||
@@ -174,7 +172,7 @@ export async function sendRentDueNotifications(db: Db, budget: number): Promise<
|
|||||||
// Update location status
|
// Update location status
|
||||||
const newStatus = success ? 'sent' : 'failed';
|
const newStatus = success ? 'sent' : 'failed';
|
||||||
await db.collection('lokacije').updateOne(
|
await db.collection('lokacije').updateOne(
|
||||||
{ _id: location._id },
|
{ _id: new ObjectId(location._id as any) },
|
||||||
{ $set: { rentDueNotificationStatus: newStatus } }
|
{ $set: { rentDueNotificationStatus: newStatus } }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -235,21 +233,28 @@ export async function sendUtilityBillsNotifications(db: Db, budget: number): Pro
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch user settings
|
||||||
|
const userSettings = await db.collection<UserSettings>('userSettings')
|
||||||
|
.findOne({ userId: location.userId });
|
||||||
|
|
||||||
|
const ownerName = userSettings?.ownerName || '';
|
||||||
const shareId = generateShareId(location._id.toString());
|
const shareId = generateShareId(location._id.toString());
|
||||||
|
|
||||||
const html = `
|
// Calculate total amount from all bills
|
||||||
<p>Hello ${location.tenantName || 'there'}!</p>
|
const totalAmountCents = (location.bills || []).reduce((sum, bill) => {
|
||||||
|
return sum + (bill.payedAmount || 0);
|
||||||
|
}, 0);
|
||||||
|
const totalAmount = (totalAmountCents / 100).toFixed(2);
|
||||||
|
const currency = userSettings?.currency || 'EUR';
|
||||||
|
|
||||||
<p>Your utility bills for the apartment ${location.name} are due today.</p>
|
const html = loadAndRender('util-bills-due', {
|
||||||
|
'location.tenantName': location.tenantName || 'there',
|
||||||
<p>For details and payment options please click the following link: <a href="https://rezije.app/share/location/bills/${shareId}">Utility Bills</a></p>
|
'location.name': location.name,
|
||||||
|
'totalAmount': totalAmount,
|
||||||
<p>Thank you!</p>
|
'currency': currency,
|
||||||
|
'ownerName': ownerName,
|
||||||
<p><a href="https://rezije.app" target="_blank">rezije.app</a></p>
|
'shareId': shareId
|
||||||
|
});
|
||||||
<p style="font-size:.7em">If you do no longer want to receive these notifications please click the following link: <a href="https://rezije.app/email/unsubscribe/${shareId}">Unsubscribe</a></p>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const success = await sendEmail({
|
const success = await sendEmail({
|
||||||
to: location.tenantEmail,
|
to: location.tenantEmail,
|
||||||
@@ -260,7 +265,7 @@ export async function sendUtilityBillsNotifications(db: Db, budget: number): Pro
|
|||||||
// Update location status
|
// Update location status
|
||||||
const newStatus = success ? 'sent' : 'failed';
|
const newStatus = success ? 'sent' : 'failed';
|
||||||
await db.collection('lokacije').updateOne(
|
await db.collection('lokacije').updateOne(
|
||||||
{ _id: location._id },
|
{ _id: new ObjectId(location._id as any) },
|
||||||
{ $set: { billFwdStatus: newStatus } }
|
{ $set: { billFwdStatus: newStatus } }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
87
email-worker/src/lib/emailTemplates.ts
Normal file
87
email-worker/src/lib/emailTemplates.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { createLogger } from './logger';
|
||||||
|
|
||||||
|
const log = createLogger('email:templates');
|
||||||
|
|
||||||
|
// Cache for loaded templates
|
||||||
|
const templateCache = new Map<string, string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template variable type for type-safe template rendering
|
||||||
|
*/
|
||||||
|
export type TemplateVariables = {
|
||||||
|
[key: string]: string | number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an email template from the templates directory
|
||||||
|
* @param templateName Name of the template file (without extension)
|
||||||
|
* @param language Language code (default: 'en')
|
||||||
|
* @returns Template content as string
|
||||||
|
*/
|
||||||
|
export function loadTemplate(templateName: string, language: string = 'en'): string {
|
||||||
|
const cacheKey = `${templateName}--${language}`;
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if (templateCache.has(cacheKey)) {
|
||||||
|
log(`Using cached template: ${cacheKey}`);
|
||||||
|
return templateCache.get(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct template file path
|
||||||
|
const templateFileName = `email-template--${templateName}--${language}.html`;
|
||||||
|
const templatePath = path.join(__dirname, '../../email-templates', templateFileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(templatePath, 'utf-8');
|
||||||
|
templateCache.set(cacheKey, content);
|
||||||
|
log(`Loaded template: ${templateFileName}`);
|
||||||
|
return content;
|
||||||
|
} catch (error) {
|
||||||
|
log(`Failed to load template ${templateFileName}: ${error}`);
|
||||||
|
throw new Error(`Template not found: ${templateFileName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a template by replacing variables
|
||||||
|
* @param template Template content
|
||||||
|
* @param variables Object with variable values
|
||||||
|
* @returns Rendered HTML string
|
||||||
|
*/
|
||||||
|
export function renderTemplate(template: string, variables: TemplateVariables): string {
|
||||||
|
let rendered = template;
|
||||||
|
|
||||||
|
// Replace all ${variable} occurrences
|
||||||
|
for (const [key, value] of Object.entries(variables)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
const regex = new RegExp(`\\$\\{${key}\\}`, 'g');
|
||||||
|
rendered = rendered.replace(regex, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log warning if there are unreplaced variables
|
||||||
|
const unreplacedMatches = rendered.match(/\$\{[^}]+\}/g);
|
||||||
|
if (unreplacedMatches) {
|
||||||
|
log(`Warning: Unreplaced variables in template: ${unreplacedMatches.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and render an email template in one step
|
||||||
|
* @param templateName Name of the template file (without extension)
|
||||||
|
* @param variables Object with variable values
|
||||||
|
* @param language Language code (default: 'en')
|
||||||
|
* @returns Rendered HTML string
|
||||||
|
*/
|
||||||
|
export function loadAndRender(
|
||||||
|
templateName: string,
|
||||||
|
variables: TemplateVariables,
|
||||||
|
language: string = 'en'
|
||||||
|
): string {
|
||||||
|
const template = loadTemplate(templateName, language);
|
||||||
|
return renderTemplate(template, variables);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user