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>
285 lines
9.2 KiB
TypeScript
285 lines
9.2 KiB
TypeScript
import { Db, ObjectId } from 'mongodb';
|
|
import { BillingLocation, EmailStatus, UserSettings, generateShareId } from '@evidencija-rezija/shared-code';
|
|
import { sendEmail } from './mailgunService';
|
|
import { createLogger } from './logger';
|
|
import { loadAndRender } from './emailTemplates';
|
|
|
|
const log = createLogger("email:senders");
|
|
|
|
/**
|
|
* Send email verification requests
|
|
* @param db Database instance
|
|
* @param budget Remaining email budget
|
|
* @returns Number of emails sent
|
|
*/
|
|
export async function sendVerificationRequests(db: Db, budget: number): Promise<number> {
|
|
if (budget <= 0) {
|
|
log('Budget exhausted, skipping verification requests');
|
|
return 0;
|
|
}
|
|
|
|
const now = new Date();
|
|
const currentYear = now.getFullYear();
|
|
const currentMonth = now.getMonth() + 1; // JavaScript months are 0-indexed
|
|
|
|
log(`Fetching locations for verification: year=${currentYear}, month=${currentMonth}`);
|
|
|
|
const locations = await db.collection<BillingLocation>('lokacije')
|
|
.find({
|
|
'yearMonth.year': currentYear,
|
|
'yearMonth.month': currentMonth,
|
|
'tenantEmailStatus': EmailStatus.Unverified
|
|
})
|
|
.toArray();
|
|
|
|
log(`Found ${locations.length} locations needing verification`);
|
|
|
|
let sentCount = 0;
|
|
|
|
for (const location of locations) {
|
|
if (budget <= 0) {
|
|
log('Budget exhausted during verification sending');
|
|
break;
|
|
}
|
|
|
|
if (!location.tenantEmail) {
|
|
log(`Skipping location ${location._id}: no tenant email`);
|
|
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 html = loadAndRender('email-validation', {
|
|
'location.tenantName': location.tenantName || 'there',
|
|
'ownerName': ownerName,
|
|
'location.name': location.name,
|
|
'shareId': shareId
|
|
});
|
|
|
|
const success = await sendEmail({
|
|
to: location.tenantEmail,
|
|
subject: `${ownerName} has invited you to rezije.app`,
|
|
html
|
|
});
|
|
|
|
// Update location status
|
|
const newStatus = success ? EmailStatus.VerificationPending : EmailStatus.VerificationFailed;
|
|
await db.collection('lokacije').updateOne(
|
|
{ _id: new ObjectId(location._id as any) },
|
|
{ $set: { tenantEmailStatus: newStatus } }
|
|
);
|
|
|
|
if (success) {
|
|
sentCount++;
|
|
log(`Verification email sent to ${location.tenantEmail}`);
|
|
} else {
|
|
log(`Failed to send verification email to ${location.tenantEmail}`);
|
|
}
|
|
|
|
budget--;
|
|
}
|
|
|
|
log(`Sent ${sentCount} verification emails`);
|
|
return sentCount;
|
|
}
|
|
|
|
/**
|
|
* Send rent due notifications
|
|
* @param db Database instance
|
|
* @param budget Remaining email budget
|
|
* @returns Number of emails sent
|
|
*/
|
|
export async function sendRentDueNotifications(db: Db, budget: number): Promise<number> {
|
|
if (budget <= 0) {
|
|
log('Budget exhausted, skipping rent due notifications');
|
|
return 0;
|
|
}
|
|
|
|
const now = new Date();
|
|
|
|
// Use CET timezone
|
|
const cetDate = new Date(now.toLocaleString('en-US', { timeZone: 'Europe/Belgrade' }));
|
|
const currentYear = cetDate.getFullYear();
|
|
const currentMonth = cetDate.getMonth() + 1;
|
|
const currentDay = cetDate.getDate();
|
|
|
|
log(`Fetching locations for rent due: year=${currentYear}, month=${currentMonth}, day=${currentDay}`);
|
|
|
|
const locations = await db.collection<BillingLocation>('lokacije')
|
|
.find({
|
|
'yearMonth.year': currentYear,
|
|
'yearMonth.month': currentMonth,
|
|
'tenantEmailStatus': EmailStatus.Verified,
|
|
'rentDueNotificationEnabled': true,
|
|
'rentDueDay': currentDay,
|
|
$or: [
|
|
{ 'rentDueNotificationStatus': { $exists: false } },
|
|
{ 'rentDueNotificationStatus': null }
|
|
]
|
|
})
|
|
.toArray();
|
|
|
|
log(`Found ${locations.length} locations needing rent due notifications`);
|
|
|
|
let sentCount = 0;
|
|
|
|
for (const location of locations) {
|
|
if (budget <= 0) {
|
|
log('Budget exhausted during rent due sending');
|
|
break;
|
|
}
|
|
|
|
if (!location.tenantEmail) {
|
|
log(`Skipping location ${location._id}: no tenant email`);
|
|
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());
|
|
|
|
// Format rent due date
|
|
const rentDueDate = `${location.yearMonth.month}/${location.rentDueDay}/${location.yearMonth.year}`;
|
|
|
|
// 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';
|
|
|
|
const html = loadAndRender('rent-due', {
|
|
'location.tenantName': location.tenantName || 'there',
|
|
'location.name': location.name,
|
|
'rentDueDate': rentDueDate,
|
|
'rentAmount': rentAmount,
|
|
'currency': currency,
|
|
'ownerName': ownerName,
|
|
'shareId': shareId
|
|
});
|
|
|
|
const success = await sendEmail({
|
|
to: location.tenantEmail,
|
|
subject: `Rent due for ${location.tenantName || 'your apartment'}`,
|
|
html
|
|
});
|
|
|
|
// Update location status
|
|
const newStatus = success ? 'sent' : 'failed';
|
|
await db.collection('lokacije').updateOne(
|
|
{ _id: new ObjectId(location._id as any) },
|
|
{ $set: { rentDueNotificationStatus: newStatus } }
|
|
);
|
|
|
|
if (success) {
|
|
sentCount++;
|
|
log(`Rent due notification sent to ${location.tenantEmail}`);
|
|
} else {
|
|
log(`Failed to send rent due notification to ${location.tenantEmail}`);
|
|
}
|
|
|
|
budget--;
|
|
}
|
|
|
|
log(`Sent ${sentCount} rent due notifications`);
|
|
return sentCount;
|
|
}
|
|
|
|
/**
|
|
* Send utility bills due notifications
|
|
* @param db Database instance
|
|
* @param budget Remaining email budget
|
|
* @returns Number of emails sent
|
|
*/
|
|
export async function sendUtilityBillsNotifications(db: Db, budget: number): Promise<number> {
|
|
if (budget <= 0) {
|
|
log('Budget exhausted, skipping utility bills notifications');
|
|
return 0;
|
|
}
|
|
|
|
const now = new Date();
|
|
const currentYear = now.getFullYear();
|
|
const currentMonth = now.getMonth() + 1;
|
|
|
|
log(`Fetching locations for utility bills: year=${currentYear}, month=${currentMonth}`);
|
|
|
|
const locations = await db.collection<BillingLocation>('lokacije')
|
|
.find({
|
|
'yearMonth.year': currentYear,
|
|
'yearMonth.month': currentMonth,
|
|
'tenantEmailStatus': EmailStatus.Verified,
|
|
'billFwdEnabled': true,
|
|
'billFwdStatus': 'pending'
|
|
})
|
|
.toArray();
|
|
|
|
log(`Found ${locations.length} locations needing utility bills notifications`);
|
|
|
|
let sentCount = 0;
|
|
|
|
for (const location of locations) {
|
|
if (budget <= 0) {
|
|
log('Budget exhausted during utility bills sending');
|
|
break;
|
|
}
|
|
|
|
if (!location.tenantEmail) {
|
|
log(`Skipping location ${location._id}: no tenant email`);
|
|
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());
|
|
|
|
// Calculate total amount from all bills
|
|
const totalAmountCents = (location.bills || []).reduce((sum, bill) => {
|
|
return sum + (bill.payedAmount || 0);
|
|
}, 0);
|
|
const totalAmount = (totalAmountCents / 100).toFixed(2);
|
|
const currency = userSettings?.currency || 'EUR';
|
|
|
|
const html = loadAndRender('util-bills-due', {
|
|
'location.tenantName': location.tenantName || 'there',
|
|
'location.name': location.name,
|
|
'totalAmount': totalAmount,
|
|
'currency': currency,
|
|
'ownerName': ownerName,
|
|
'shareId': shareId
|
|
});
|
|
|
|
const success = await sendEmail({
|
|
to: location.tenantEmail,
|
|
subject: `Utility bills due for ${location.tenantName || 'your apartment'}`,
|
|
html
|
|
});
|
|
|
|
// Update location status
|
|
const newStatus = success ? 'sent' : 'failed';
|
|
await db.collection('lokacije').updateOne(
|
|
{ _id: new ObjectId(location._id as any) },
|
|
{ $set: { billFwdStatus: newStatus } }
|
|
);
|
|
|
|
if (success) {
|
|
sentCount++;
|
|
log(`Utility bills notification sent to ${location.tenantEmail}`);
|
|
} else {
|
|
log(`Failed to send utility bills notification to ${location.tenantEmail}`);
|
|
}
|
|
|
|
budget--;
|
|
}
|
|
|
|
log(`Sent ${sentCount} utility bills notifications`);
|
|
return sentCount;
|
|
}
|