1060 lines
36 KiB
TypeScript
1060 lines
36 KiB
TypeScript
import nodemailer from 'nodemailer';
|
|
import type { Transporter } from 'nodemailer';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { env } from '../config/env';
|
|
import { logger } from '../utils/logger';
|
|
import { siteSettingsService } from '../modules/settings/settings.service';
|
|
import { PrismaClient } from '@prisma/client';
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
interface SendEmailOptions {
|
|
to: string;
|
|
subject: string;
|
|
replyTo?: string;
|
|
html: string;
|
|
text: string;
|
|
}
|
|
|
|
interface SendEmailResult {
|
|
success: boolean;
|
|
messageId?: string;
|
|
testMode: boolean;
|
|
}
|
|
|
|
interface SendCampaignEmailOptions {
|
|
recipientEmail: string;
|
|
recipientName?: string;
|
|
recipientLevel?: string;
|
|
userEmail: string;
|
|
userName: string;
|
|
postalCode: string;
|
|
subject: string;
|
|
message: string;
|
|
campaignTitle: string;
|
|
}
|
|
|
|
// In-memory template cache (filesystem)
|
|
const templateCache = new Map<string, string>();
|
|
|
|
// Database template cache
|
|
interface DatabaseTemplate {
|
|
html: string;
|
|
text: string;
|
|
subject: string;
|
|
}
|
|
const databaseTemplateCache = new Map<string, DatabaseTemplate>();
|
|
|
|
class EmailService {
|
|
private transporter: Transporter;
|
|
|
|
constructor() {
|
|
this.transporter = this.createTransporter(env.SMTP_HOST, env.SMTP_PORT, env.SMTP_USER, env.SMTP_PASS);
|
|
}
|
|
|
|
private createTransporter(host: string, port: number, user: string, pass: string): Transporter {
|
|
const config: Record<string, unknown> = { host, port };
|
|
|
|
// Only set auth if user is provided (MailHog doesn't need auth)
|
|
if (user) {
|
|
config.auth = { user, pass };
|
|
}
|
|
|
|
return nodemailer.createTransport(config as nodemailer.TransportOptions);
|
|
}
|
|
|
|
/** Rebuild transporter from DB settings (with env fallback). Call after SMTP settings change. */
|
|
async rebuildTransporter(): Promise<void> {
|
|
try {
|
|
const settings = await siteSettingsService.get();
|
|
const provider = settings.smtpActiveProvider || 'mailhog';
|
|
|
|
let host: string, port: number, user: string, pass: string;
|
|
|
|
if (provider === 'mailhog') {
|
|
host = 'mailhog-changemaker';
|
|
port = 1025;
|
|
user = '';
|
|
pass = '';
|
|
} else {
|
|
host = settings.smtpHost || env.SMTP_HOST;
|
|
port = settings.smtpPort || env.SMTP_PORT;
|
|
user = settings.smtpUser || env.SMTP_USER;
|
|
pass = settings.smtpPass || env.SMTP_PASS;
|
|
}
|
|
|
|
this.transporter = this.createTransporter(host, port, user, pass);
|
|
logger.info(`SMTP transporter rebuilt: provider=${provider} ${host}:${port} (auth=${!!user})`);
|
|
} catch (err) {
|
|
logger.error('Failed to rebuild SMTP transporter, keeping current config:', err);
|
|
}
|
|
}
|
|
|
|
async testConnection(): Promise<boolean> {
|
|
try {
|
|
await this.transporter.verify();
|
|
logger.info('SMTP connection verified');
|
|
return true;
|
|
} catch (err) {
|
|
logger.error('SMTP connection failed:', err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load template from database (with caching)
|
|
*/
|
|
private async loadTemplateFromDatabase(key: string): Promise<DatabaseTemplate | null> {
|
|
// Check cache first
|
|
const cached = databaseTemplateCache.get(key);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
try {
|
|
const template = await prisma.emailTemplate.findUnique({
|
|
where: { key, isActive: true },
|
|
});
|
|
|
|
if (!template) {
|
|
return null;
|
|
}
|
|
|
|
const dbTemplate: DatabaseTemplate = {
|
|
html: template.htmlContent,
|
|
text: template.textContent,
|
|
subject: template.subjectLine,
|
|
};
|
|
|
|
// Cache for future use
|
|
databaseTemplateCache.set(key, dbTemplate);
|
|
|
|
logger.debug(`Loaded template "${key}" from database`);
|
|
return dbTemplate;
|
|
} catch (error) {
|
|
logger.error(`Failed to load template "${key}" from database:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load template - checks database first, falls back to filesystem
|
|
* @param name Template key (e.g., "campaign-email")
|
|
* @param type File type (html or txt) - only used for filesystem fallback
|
|
*/
|
|
loadTemplate(name: string, type: 'html' | 'txt'): string {
|
|
const key = `${name}.${type}`;
|
|
const cached = templateCache.get(key);
|
|
if (cached) return cached;
|
|
|
|
// Filesystem loading (legacy fallback)
|
|
const templatePath = path.join(process.cwd(), 'src', 'templates', 'email', `${name}.${type}`);
|
|
const content = fs.readFileSync(templatePath, 'utf-8');
|
|
templateCache.set(key, content);
|
|
logger.warn(`Template "${name}" loaded from filesystem (not in database)`);
|
|
return content;
|
|
}
|
|
|
|
/**
|
|
* Clear database template cache
|
|
* @param key Optional specific template key to clear. If not provided, clears all.
|
|
*/
|
|
clearDatabaseCache(key?: string): void {
|
|
if (key) {
|
|
databaseTemplateCache.delete(key);
|
|
logger.debug(`Cleared database cache for template: ${key}`);
|
|
} else {
|
|
databaseTemplateCache.clear();
|
|
logger.debug('Cleared all database template cache');
|
|
}
|
|
}
|
|
|
|
escapeHtml(unsafe: string): string {
|
|
return unsafe
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
/**
|
|
* Fetch video metadata from Media API
|
|
*/
|
|
private async getVideoMetadata(videoId: number): Promise<{
|
|
id: number;
|
|
title: string;
|
|
thumbnailUrl: string;
|
|
streamUrl: string;
|
|
durationSeconds?: number;
|
|
quality?: string;
|
|
viewCount?: number;
|
|
} | null> {
|
|
try {
|
|
const mediaApiUrl = env.MEDIA_API_PUBLIC_URL || 'http://media-api:4100';
|
|
const response = await fetch(`${mediaApiUrl}/api/videos/${videoId}/metadata`);
|
|
if (!response.ok) return null;
|
|
return await response.json() as {
|
|
id: number;
|
|
title: string;
|
|
thumbnailUrl: string;
|
|
streamUrl: string;
|
|
durationSeconds?: number;
|
|
quality?: string;
|
|
viewCount?: number;
|
|
};
|
|
} catch (error) {
|
|
logger.error(`Failed to fetch video ${videoId} metadata:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private formatDuration(seconds: number): string {
|
|
if (!seconds || seconds <= 0) return '0:00';
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
return `${m}:${String(s).padStart(2, '0')}`;
|
|
}
|
|
|
|
private formatViewCount(count: number): string {
|
|
if (!count || count <= 0) return '0 views';
|
|
if (count === 1) return '1 view';
|
|
if (count < 1000) return `${count} views`;
|
|
if (count < 1_000_000) return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}K views`;
|
|
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M views`;
|
|
}
|
|
|
|
async processTemplate(
|
|
template: string,
|
|
vars: Record<string, string>,
|
|
variableDefinitions?: Array<{ key: string; type: string; videoId?: number }>
|
|
): Promise<string> {
|
|
let result = template;
|
|
|
|
// Process VIDEO variables first (async)
|
|
if (variableDefinitions) {
|
|
for (const varDef of variableDefinitions) {
|
|
if (varDef.type === 'VIDEO' && varDef.videoId) {
|
|
const video = await this.getVideoMetadata(varDef.videoId);
|
|
if (video) {
|
|
const publicUrl = env.ADMIN_URL || 'http://localhost:3000';
|
|
const watchUrl = `${publicUrl}/gallery/watch/${video.id}`;
|
|
const duration = this.formatDuration(video.durationSeconds || 0);
|
|
const quality = video.quality ? this.escapeHtml(video.quality) : '';
|
|
const views = this.formatViewCount(video.viewCount || 0);
|
|
const metaLine = [duration, quality, views].filter(Boolean).join(' • ');
|
|
const videoHtml = `
|
|
<table cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; margin: 16px auto; border-radius: 8px; overflow: hidden; background-color: #1b2838;">
|
|
<tr>
|
|
<td style="padding: 0;">
|
|
<a href="${watchUrl}" style="display: block; text-decoration: none;">
|
|
<img src="${video.thumbnailUrl}"
|
|
alt="${this.escapeHtml(video.title)}"
|
|
width="480"
|
|
style="width: 100%; max-width: 480px; height: auto; display: block;" />
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 12px 16px;">
|
|
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
<tr>
|
|
<td style="color: #ffffff; font-size: 15px; font-weight: 600; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
|
${this.escapeHtml(video.title)}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding-top: 6px; color: #8899aa; font-size: 12px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
|
${metaLine}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding-top: 12px; text-align: center;">
|
|
<a href="${watchUrl}"
|
|
style="display: inline-block; padding: 10px 24px; background-color: #9d4edd; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
|
▶ Watch Video
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
`.trim();
|
|
|
|
result = result.replace(
|
|
new RegExp(`\\{\\{${varDef.key}\\}\\}`, 'g'),
|
|
videoHtml
|
|
);
|
|
} else {
|
|
// Fallback if video not found
|
|
result = result.replace(
|
|
new RegExp(`\\{\\{${varDef.key}\\}\\}`, 'g'),
|
|
`<p style="color: #999; font-style: italic;">[Video not available]</p>`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle {{#if VAR}}...{{/if}} conditionals
|
|
result = result.replace(/\{\{#if (\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (_match, varName, content) => {
|
|
return vars[varName] ? content : '';
|
|
});
|
|
|
|
// Handle TEXT {{VAR}} replacements with HTML escaping
|
|
for (const [key, value] of Object.entries(vars)) {
|
|
const escapedValue = this.escapeHtml(value);
|
|
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), escapedValue);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Process text template (for plain text emails)
|
|
*/
|
|
async processTextTemplate(
|
|
template: string,
|
|
vars: Record<string, string>,
|
|
variableDefinitions?: Array<{ key: string; type: string; videoId?: number }>
|
|
): Promise<string> {
|
|
let result = template;
|
|
|
|
// VIDEO variables in text emails: plain text link
|
|
if (variableDefinitions) {
|
|
for (const varDef of variableDefinitions) {
|
|
if (varDef.type === 'VIDEO' && varDef.videoId) {
|
|
const video = await this.getVideoMetadata(varDef.videoId);
|
|
if (video) {
|
|
const publicUrl = env.ADMIN_URL || 'http://localhost:3000';
|
|
const duration = this.formatDuration(video.durationSeconds || 0);
|
|
const quality = video.quality || '';
|
|
const views = this.formatViewCount(video.viewCount || 0);
|
|
const meta = [duration, quality, views].filter(Boolean).join(', ');
|
|
const textLink = `Watch "${video.title}" (${meta})\n${publicUrl}/gallery/watch/${video.id}`;
|
|
result = result.replace(
|
|
new RegExp(`\\{\\{${varDef.key}\\}\\}`, 'g'),
|
|
textLink
|
|
);
|
|
} else {
|
|
result = result.replace(
|
|
new RegExp(`\\{\\{${varDef.key}\\}\\}`, 'g'),
|
|
'[Video not available]'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process TEXT variables (no HTML escaping in plain text)
|
|
for (const [key, value] of Object.entries(vars)) {
|
|
result = result.replace(
|
|
new RegExp(`\\{\\{${key}\\}\\}`, 'g'),
|
|
value
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Process subject line template
|
|
* Same as processTemplate but without HTML escaping
|
|
*/
|
|
processSubject(subject: string, vars: Record<string, string>): string {
|
|
let result = subject;
|
|
|
|
// Handle {{VAR}} replacements (no HTML escaping for subject lines)
|
|
for (const [key, value] of Object.entries(vars)) {
|
|
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private async getFromField(): Promise<string> {
|
|
try {
|
|
const settings = await siteSettingsService.get();
|
|
const name = settings.emailFromName || env.SMTP_FROM_NAME;
|
|
const address = settings.smtpFromAddress || env.SMTP_FROM;
|
|
return `${name} <${address}>`;
|
|
} catch {
|
|
return `${env.SMTP_FROM_NAME} <${env.SMTP_FROM}>`;
|
|
}
|
|
}
|
|
|
|
private async getOrganizationName(): Promise<string> {
|
|
try {
|
|
const settings = await siteSettingsService.get();
|
|
return settings.organizationName || 'Changemaker Lite';
|
|
} catch {
|
|
return 'Changemaker Lite';
|
|
}
|
|
}
|
|
|
|
private async getEmailTestConfig(): Promise<{ testMode: boolean; testRecipient: string }> {
|
|
try {
|
|
const settings = await siteSettingsService.get();
|
|
return {
|
|
testMode: settings.emailTestMode,
|
|
testRecipient: settings.testEmailRecipient || env.TEST_EMAIL_RECIPIENT,
|
|
};
|
|
} catch {
|
|
return {
|
|
testMode: env.EMAIL_TEST_MODE === 'true',
|
|
testRecipient: env.TEST_EMAIL_RECIPIENT,
|
|
};
|
|
}
|
|
}
|
|
|
|
async sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
|
|
const { testMode, testRecipient } = await this.getEmailTestConfig();
|
|
const from = await this.getFromField();
|
|
|
|
let to = options.to;
|
|
let subject = options.subject;
|
|
|
|
if (testMode) {
|
|
subject = `[TEST - Original: ${options.to}] ${options.subject}`;
|
|
to = testRecipient;
|
|
}
|
|
|
|
try {
|
|
const info = await this.transporter.sendMail({
|
|
from,
|
|
to,
|
|
subject,
|
|
replyTo: options.replyTo,
|
|
html: options.html,
|
|
text: options.text,
|
|
});
|
|
|
|
logger.info(`Email sent: ${info.messageId} to=${to} testMode=${testMode}`);
|
|
|
|
return {
|
|
success: true,
|
|
messageId: info.messageId,
|
|
testMode,
|
|
};
|
|
} catch (err) {
|
|
logger.error('Failed to send email:', err);
|
|
return {
|
|
success: false,
|
|
testMode,
|
|
};
|
|
}
|
|
}
|
|
|
|
async sendCampaignEmail(options: SendCampaignEmailOptions): Promise<SendEmailResult> {
|
|
const orgName = await this.getOrganizationName();
|
|
const vars: Record<string, string> = {
|
|
CAMPAIGN_TITLE: options.campaignTitle,
|
|
MESSAGE: options.message,
|
|
USER_NAME: options.userName,
|
|
USER_EMAIL: options.userEmail,
|
|
POSTAL_CODE: options.postalCode,
|
|
RECIPIENT_NAME: options.recipientName || '',
|
|
RECIPIENT_LEVEL: options.recipientLevel || '',
|
|
ORGANIZATION_NAME: orgName,
|
|
TIMESTAMP: new Date().toISOString(),
|
|
};
|
|
|
|
// Try database first
|
|
const dbTemplate = await this.loadTemplateFromDatabase('campaign-email');
|
|
|
|
let html: string, text: string, subject: string;
|
|
|
|
if (dbTemplate) {
|
|
// Use database template
|
|
html = await this.processTemplate(dbTemplate.html, vars);
|
|
text = await this.processTemplate(dbTemplate.text, vars);
|
|
subject = this.processSubject(dbTemplate.subject, vars);
|
|
logger.debug('Using campaign email template from database');
|
|
} else {
|
|
// Fallback to filesystem
|
|
const htmlTemplate = this.loadTemplate('campaign-email', 'html');
|
|
const txtTemplate = this.loadTemplate('campaign-email', 'txt');
|
|
html = await this.processTemplate(htmlTemplate, vars);
|
|
text = await this.processTemplate(txtTemplate, vars);
|
|
subject = options.subject; // Use provided subject for filesystem fallback
|
|
logger.warn('Using campaign email template from filesystem (fallback)');
|
|
}
|
|
|
|
return this.sendEmail({
|
|
to: options.recipientEmail,
|
|
subject,
|
|
replyTo: options.userEmail,
|
|
html,
|
|
text,
|
|
});
|
|
}
|
|
|
|
/** Check whether production SMTP is configured (not mailhog test mode) */
|
|
async isSmtpConfigured(): Promise<boolean> {
|
|
try {
|
|
const settings = await siteSettingsService.get();
|
|
return settings.smtpActiveProvider === 'production' && !!settings.smtpHost;
|
|
} catch {
|
|
return env.SMTP_HOST !== 'mailhog-changemaker' && !!env.SMTP_HOST;
|
|
}
|
|
}
|
|
|
|
async sendVerificationEmail(options: {
|
|
recipientEmail: string;
|
|
recipientName: string;
|
|
verificationUrl: string;
|
|
}): Promise<SendEmailResult> {
|
|
const orgName = await this.getOrganizationName();
|
|
const vars: Record<string, string> = {
|
|
RECIPIENT_NAME: options.recipientName || 'there',
|
|
VERIFICATION_URL: options.verificationUrl,
|
|
ORGANIZATION_NAME: orgName,
|
|
EXPIRY_HOURS: '24',
|
|
};
|
|
|
|
const dbTemplate = await this.loadTemplateFromDatabase('email-verification');
|
|
|
|
let html: string, text: string, subject: string;
|
|
if (dbTemplate) {
|
|
html = await this.processTemplate(dbTemplate.html, vars);
|
|
text = await this.processTextTemplate(dbTemplate.text, vars);
|
|
subject = this.processSubject(dbTemplate.subject, vars);
|
|
} else {
|
|
const htmlTemplate = this.loadTemplate('email-verification', 'html');
|
|
const txtTemplate = this.loadTemplate('email-verification', 'txt');
|
|
html = await this.processTemplate(htmlTemplate, vars);
|
|
text = await this.processTextTemplate(txtTemplate, vars);
|
|
subject = `Verify your email — ${orgName}`;
|
|
}
|
|
|
|
return this.sendEmail({
|
|
to: options.recipientEmail,
|
|
subject,
|
|
html,
|
|
text,
|
|
});
|
|
}
|
|
|
|
async sendPasswordResetEmail(options: {
|
|
recipientEmail: string;
|
|
recipientName: string;
|
|
resetUrl: string;
|
|
}): Promise<SendEmailResult> {
|
|
const orgName = await this.getOrganizationName();
|
|
const vars: Record<string, string> = {
|
|
RECIPIENT_NAME: options.recipientName || 'there',
|
|
RESET_URL: options.resetUrl,
|
|
ORGANIZATION_NAME: orgName,
|
|
};
|
|
|
|
const dbTemplate = await this.loadTemplateFromDatabase('password-reset');
|
|
|
|
let html: string, text: string, subject: string;
|
|
if (dbTemplate) {
|
|
html = await this.processTemplate(dbTemplate.html, vars);
|
|
text = await this.processTextTemplate(dbTemplate.text, vars);
|
|
subject = this.processSubject(dbTemplate.subject, vars);
|
|
} else {
|
|
const htmlTemplate = this.loadTemplate('password-reset', 'html');
|
|
const txtTemplate = this.loadTemplate('password-reset', 'txt');
|
|
html = await this.processTemplate(htmlTemplate, vars);
|
|
text = await this.processTextTemplate(txtTemplate, vars);
|
|
subject = `Reset your password — ${orgName}`;
|
|
}
|
|
|
|
return this.sendEmail({
|
|
to: options.recipientEmail,
|
|
subject,
|
|
html,
|
|
text,
|
|
});
|
|
}
|
|
|
|
async sendAccountApprovedEmail(options: {
|
|
recipientEmail: string;
|
|
recipientName: string;
|
|
loginUrl: string;
|
|
}): Promise<SendEmailResult> {
|
|
const orgName = await this.getOrganizationName();
|
|
const vars: Record<string, string> = {
|
|
RECIPIENT_NAME: options.recipientName || 'there',
|
|
LOGIN_URL: options.loginUrl,
|
|
ORGANIZATION_NAME: orgName,
|
|
};
|
|
|
|
const dbTemplate = await this.loadTemplateFromDatabase('account-approved');
|
|
|
|
let html: string, text: string, subject: string;
|
|
if (dbTemplate) {
|
|
html = await this.processTemplate(dbTemplate.html, vars);
|
|
text = await this.processTextTemplate(dbTemplate.text, vars);
|
|
subject = this.processSubject(dbTemplate.subject, vars);
|
|
} else {
|
|
const htmlTemplate = this.loadTemplate('account-approved', 'html');
|
|
const txtTemplate = this.loadTemplate('account-approved', 'txt');
|
|
html = await this.processTemplate(htmlTemplate, vars);
|
|
text = await this.processTextTemplate(txtTemplate, vars);
|
|
subject = `Account approved — ${orgName}`;
|
|
}
|
|
|
|
return this.sendEmail({
|
|
to: options.recipientEmail,
|
|
subject,
|
|
html,
|
|
text,
|
|
});
|
|
}
|
|
|
|
async sendPendingApprovalNotification(options: {
|
|
adminEmails: string[];
|
|
newUserEmail: string;
|
|
newUserName: string;
|
|
}): Promise<void> {
|
|
const orgName = await this.getOrganizationName();
|
|
const adminUrl = env.ADMIN_URL || 'http://localhost:3000';
|
|
const vars: Record<string, string> = {
|
|
NEW_USER_NAME: options.newUserName || '(not provided)',
|
|
NEW_USER_EMAIL: options.newUserEmail,
|
|
REGISTERED_AT: new Date().toLocaleDateString('en-CA', { year: 'numeric', month: 'long', day: 'numeric' }),
|
|
ADMIN_URL: `${adminUrl}/app/users?status=PENDING_APPROVAL`,
|
|
ORGANIZATION_NAME: orgName,
|
|
};
|
|
|
|
const dbTemplate = await this.loadTemplateFromDatabase('account-pending-approval');
|
|
|
|
let html: string, text: string, subject: string;
|
|
if (dbTemplate) {
|
|
html = await this.processTemplate(dbTemplate.html, vars);
|
|
text = await this.processTextTemplate(dbTemplate.text, vars);
|
|
subject = this.processSubject(dbTemplate.subject, vars);
|
|
} else {
|
|
const htmlTemplate = this.loadTemplate('account-pending-approval', 'html');
|
|
const txtTemplate = this.loadTemplate('account-pending-approval', 'txt');
|
|
html = await this.processTemplate(htmlTemplate, vars);
|
|
text = await this.processTextTemplate(txtTemplate, vars);
|
|
subject = `New user awaiting approval — ${orgName}`;
|
|
}
|
|
|
|
for (const email of options.adminEmails) {
|
|
await this.sendEmail({
|
|
to: email,
|
|
subject,
|
|
html,
|
|
text,
|
|
});
|
|
}
|
|
}
|
|
|
|
async sendShiftSignupConfirmation(options: {
|
|
recipientEmail: string;
|
|
recipientName: string;
|
|
shiftTitle: string;
|
|
shiftDate: string;
|
|
shiftTime: string;
|
|
shiftLocation: string;
|
|
isNewUser: boolean;
|
|
tempPassword?: string;
|
|
loginUrl: string;
|
|
}): Promise<SendEmailResult> {
|
|
const orgName = await this.getOrganizationName();
|
|
const vars: Record<string, string> = {
|
|
USER_NAME: options.recipientName,
|
|
USER_EMAIL: options.recipientEmail,
|
|
SHIFT_TITLE: options.shiftTitle,
|
|
SHIFT_DATE: options.shiftDate,
|
|
SHIFT_TIME: options.shiftTime,
|
|
SHIFT_LOCATION: options.shiftLocation,
|
|
IS_NEW_USER: options.isNewUser ? 'true' : '',
|
|
TEMP_PASSWORD: options.tempPassword || '',
|
|
LOGIN_URL: options.loginUrl,
|
|
ORGANIZATION_NAME: orgName,
|
|
};
|
|
|
|
const dbTemplate = await this.loadTemplateFromDatabase('shift-signup-confirmation');
|
|
|
|
let html: string, text: string, subject: string;
|
|
if (dbTemplate) {
|
|
html = await this.processTemplate(dbTemplate.html, vars);
|
|
text = await this.processTextTemplate(dbTemplate.text, vars);
|
|
subject = this.processSubject(dbTemplate.subject, vars);
|
|
} else {
|
|
const htmlTemplate = this.loadTemplate('shift-signup-confirmation', 'html');
|
|
const txtTemplate = this.loadTemplate('shift-signup-confirmation', 'txt');
|
|
html = await this.processTemplate(htmlTemplate, vars);
|
|
text = await this.processTextTemplate(txtTemplate, vars);
|
|
subject = `Signup Confirmed — ${options.shiftTitle}`;
|
|
}
|
|
|
|
return this.sendEmail({
|
|
to: options.recipientEmail,
|
|
subject,
|
|
html,
|
|
text,
|
|
});
|
|
}
|
|
|
|
async sendShiftDetailsEmail(options: {
|
|
recipientEmail: string;
|
|
recipientName: string;
|
|
shiftTitle: string;
|
|
shiftDate: string;
|
|
shiftStartTime: string;
|
|
shiftEndTime: string;
|
|
shiftLocation: string;
|
|
shiftDescription: string;
|
|
currentVolunteers: number;
|
|
maxVolunteers: number;
|
|
shiftStatus: string;
|
|
}): Promise<SendEmailResult> {
|
|
const orgName = await this.getOrganizationName();
|
|
const vars: Record<string, string> = {
|
|
USER_NAME: options.recipientName,
|
|
SHIFT_TITLE: options.shiftTitle,
|
|
SHIFT_DATE: options.shiftDate,
|
|
SHIFT_START_TIME: options.shiftStartTime,
|
|
SHIFT_END_TIME: options.shiftEndTime,
|
|
SHIFT_LOCATION: options.shiftLocation,
|
|
SHIFT_DESCRIPTION: options.shiftDescription,
|
|
CURRENT_VOLUNTEERS: options.currentVolunteers.toString(),
|
|
MAX_VOLUNTEERS: options.maxVolunteers.toString(),
|
|
SHIFT_STATUS: options.shiftStatus,
|
|
ORGANIZATION_NAME: orgName,
|
|
};
|
|
|
|
const dbTemplate = await this.loadTemplateFromDatabase('shift-details');
|
|
|
|
let html: string, text: string, subject: string;
|
|
if (dbTemplate) {
|
|
html = await this.processTemplate(dbTemplate.html, vars);
|
|
text = await this.processTextTemplate(dbTemplate.text, vars);
|
|
subject = this.processSubject(dbTemplate.subject, vars);
|
|
} else {
|
|
const htmlTemplate = this.loadTemplate('shift-details', 'html');
|
|
const txtTemplate = this.loadTemplate('shift-details', 'txt');
|
|
html = await this.processTemplate(htmlTemplate, vars);
|
|
text = await this.processTextTemplate(txtTemplate, vars);
|
|
subject = `Shift Details — ${options.shiftTitle}`;
|
|
}
|
|
|
|
return this.sendEmail({
|
|
to: options.recipientEmail,
|
|
subject,
|
|
html,
|
|
text,
|
|
});
|
|
}
|
|
|
|
// ─── Notification Emails ────────────────────────────────────────────
|
|
|
|
async sendAdminShiftSignupAlert(options: {
|
|
adminEmails: string[];
|
|
shiftTitle: string;
|
|
shiftDate: string;
|
|
volunteerName: string;
|
|
volunteerEmail: string;
|
|
signupSource: string;
|
|
adminUrl: string;
|
|
}): Promise<void> {
|
|
const orgName = await this.getOrganizationName();
|
|
const vars: Record<string, string> = {
|
|
ORGANIZATION_NAME: orgName,
|
|
SHIFT_TITLE: options.shiftTitle,
|
|
SHIFT_DATE: options.shiftDate,
|
|
VOLUNTEER_NAME: options.volunteerName,
|
|
VOLUNTEER_EMAIL: options.volunteerEmail,
|
|
SIGNUP_SOURCE: options.signupSource,
|
|
ADMIN_URL: options.adminUrl,
|
|
};
|
|
|
|
const dbTemplate = await this.loadTemplateFromDatabase('admin-shift-signup-alert');
|
|
|
|
let html: string, text: string, subject: string;
|
|
if (dbTemplate) {
|
|
html = await this.processTemplate(dbTemplate.html, vars);
|
|
text = await this.processTextTemplate(dbTemplate.text, vars);
|
|
subject = this.processSubject(dbTemplate.subject, vars);
|
|
} else {
|
|
const htmlTemplate = this.loadTemplate('admin-shift-signup-alert', 'html');
|
|
const txtTemplate = this.loadTemplate('admin-shift-signup-alert', 'txt');
|
|
html = await this.processTemplate(htmlTemplate, vars);
|
|
text = await this.processTextTemplate(txtTemplate, vars);
|
|
subject = `New shift signup — ${options.shiftTitle}`;
|
|
}
|
|
|
|
for (const email of options.adminEmails) {
|
|
await this.sendEmail({ to: email, subject, html, text });
|
|
}
|
|
}
|
|
|
|
async sendAdminShiftCancellationAlert(options: {
|
|
adminEmails: string[];
|
|
shiftTitle: string;
|
|
shiftDate: string;
|
|
volunteerName: string;
|
|
volunteerEmail: string;
|
|
cancellationSource: string;
|
|
adminUrl: string;
|
|
}): Promise<void> {
|
|
const orgName = await this.getOrganizationName();
|
|
const vars: Record<string, string> = {
|
|
ORGANIZATION_NAME: orgName,
|
|
SHIFT_TITLE: options.shiftTitle,
|
|
SHIFT_DATE: options.shiftDate,
|
|
VOLUNTEER_NAME: options.volunteerName,
|
|
VOLUNTEER_EMAIL: options.volunteerEmail,
|
|
CANCELLATION_SOURCE: options.cancellationSource,
|
|
ADMIN_URL: options.adminUrl,
|
|
};
|
|
|
|
const dbTemplate = await this.loadTemplateFromDatabase('admin-shift-cancellation-alert');
|
|
|
|
let html: string, text: string, subject: string;
|
|
if (dbTemplate) {
|
|
html = await this.processTemplate(dbTemplate.html, vars);
|
|
text = await this.processTextTemplate(dbTemplate.text, vars);
|
|
subject = this.processSubject(dbTemplate.subject, vars);
|
|
} else {
|
|
const htmlTemplate = this.loadTemplate('admin-shift-cancellation-alert', 'html');
|
|
const txtTemplate = this.loadTemplate('admin-shift-cancellation-alert', 'txt');
|
|
html = await this.processTemplate(htmlTemplate, vars);
|
|
text = await this.processTextTemplate(txtTemplate, vars);
|
|
subject = `Shift cancellation — ${options.shiftTitle}`;
|
|
}
|
|
|
|
for (const email of options.adminEmails) {
|
|
await this.sendEmail({ to: email, subject, html, text });
|
|
}
|
|
}
|
|
|
|
async sendAdminResponseSubmittedAlert(options: {
|
|
adminEmails: string[];
|
|
campaignTitle: string;
|
|
representativeName: string;
|
|
responseType: string;
|
|
submitterName: string;
|
|
adminUrl: string;
|
|
}): Promise<void> {
|
|
const orgName = await this.getOrganizationName();
|
|
const vars: Record<string, string> = {
|
|
ORGANIZATION_NAME: orgName,
|
|
CAMPAIGN_TITLE: options.campaignTitle,
|
|
REPRESENTATIVE_NAME: options.representativeName,
|
|
RESPONSE_TYPE: options.responseType.replace(/_/g, ' '),
|
|
SUBMITTER_NAME: options.submitterName,
|
|
ADMIN_URL: options.adminUrl,
|
|
};
|
|
|
|
const dbTemplate = await this.loadTemplateFromDatabase('admin-response-submitted-alert');
|
|
|
|
let html: string, text: string, subject: string;
|
|
if (dbTemplate) {
|
|
html = await this.processTemplate(dbTemplate.html, vars);
|
|
text = await this.processTextTemplate(dbTemplate.text, vars);
|
|
subject = this.processSubject(dbTemplate.subject, vars);
|
|
} else {
|
|
const htmlTemplate = this.loadTemplate('admin-response-submitted-alert', 'html');
|
|
const txtTemplate = this.loadTemplate('admin-response-submitted-alert', 'txt');
|
|
html = await this.processTemplate(htmlTemplate, vars);
|
|
text = await this.processTextTemplate(txtTemplate, vars);
|
|
subject = `New response submitted — ${options.campaignTitle}`;
|
|
}
|
|
|
|
for (const email of options.adminEmails) {
|
|
await this.sendEmail({ to: email, subject, html, text });
|
|
}
|
|
}
|
|
|
|
async sendAdminSignRequestedAlert(options: {
|
|
adminEmails: string[];
|
|
volunteerName: string;
|
|
address: string;
|
|
shiftTitle: string;
|
|
signSize: string;
|
|
adminUrl: string;
|
|
}): Promise<void> {
|
|
const orgName = await this.getOrganizationName();
|
|
const vars: Record<string, string> = {
|
|
ORGANIZATION_NAME: orgName,
|
|
VOLUNTEER_NAME: options.volunteerName,
|
|
ADDRESS: options.address,
|
|
SHIFT_TITLE: options.shiftTitle,
|
|
SIGN_SIZE: options.signSize || 'Not specified',
|
|
ADMIN_URL: options.adminUrl,
|
|
};
|
|
|
|
const dbTemplate = await this.loadTemplateFromDatabase('admin-sign-requested-alert');
|
|
|
|
let html: string, text: string, subject: string;
|
|
if (dbTemplate) {
|
|
html = await this.processTemplate(dbTemplate.html, vars);
|
|
text = await this.processTextTemplate(dbTemplate.text, vars);
|
|
subject = this.processSubject(dbTemplate.subject, vars);
|
|
} else {
|
|
const htmlTemplate = this.loadTemplate('admin-sign-requested-alert', 'html');
|
|
const txtTemplate = this.loadTemplate('admin-sign-requested-alert', 'txt');
|
|
html = await this.processTemplate(htmlTemplate, vars);
|
|
text = await this.processTextTemplate(txtTemplate, vars);
|
|
subject = `Sign requested — ${options.address}`;
|
|
}
|
|
|
|
for (const email of options.adminEmails) {
|
|
await this.sendEmail({ to: email, subject, html, text });
|
|
}
|
|
}
|
|
|
|
async sendVolunteerSessionSummary(options: {
|
|
volunteerEmail: string;
|
|
volunteerName: string;
|
|
cutName: string;
|
|
sessionDate: string;
|
|
visitCount: number;
|
|
durationMinutes: number;
|
|
distanceKm: number;
|
|
outcomeBreakdown: Record<string, number>;
|
|
}): Promise<void> {
|
|
const orgName = await this.getOrganizationName();
|
|
|
|
// Build outcome breakdown as HTML table and plain text list
|
|
const outcomeEntries = Object.entries(options.outcomeBreakdown);
|
|
let outcomeHtml = '';
|
|
let outcomeText = '';
|
|
|
|
if (outcomeEntries.length > 0) {
|
|
outcomeHtml = '<table class="outcome-table"><tr><th>Outcome</th><th>Count</th></tr>';
|
|
outcomeText = 'Outcome Breakdown:\n';
|
|
for (const [outcome, count] of outcomeEntries) {
|
|
const label = outcome.replace(/_/g, ' ');
|
|
outcomeHtml += `<tr><td>${this.escapeHtml(label)}</td><td>${count}</td></tr>`;
|
|
outcomeText += ` ${label}: ${count}\n`;
|
|
}
|
|
outcomeHtml += '</table>';
|
|
}
|
|
|
|
const vars: Record<string, string> = {
|
|
ORGANIZATION_NAME: orgName,
|
|
VOLUNTEER_NAME: options.volunteerName,
|
|
CUT_NAME: options.cutName,
|
|
SESSION_DATE: options.sessionDate,
|
|
VISIT_COUNT: options.visitCount.toString(),
|
|
DURATION_MINUTES: options.durationMinutes.toString(),
|
|
DISTANCE_KM: options.distanceKm.toFixed(1),
|
|
OUTCOME_BREAKDOWN: outcomeHtml,
|
|
};
|
|
|
|
const dbTemplate = await this.loadTemplateFromDatabase('volunteer-session-summary');
|
|
|
|
let html: string, text: string, subject: string;
|
|
if (dbTemplate) {
|
|
html = await this.processTemplate(dbTemplate.html, vars);
|
|
// Use plain text breakdown for text version
|
|
vars.OUTCOME_BREAKDOWN = outcomeText;
|
|
text = await this.processTextTemplate(dbTemplate.text, vars);
|
|
subject = this.processSubject(dbTemplate.subject, vars);
|
|
} else {
|
|
const htmlTemplate = this.loadTemplate('volunteer-session-summary', 'html');
|
|
const txtTemplate = this.loadTemplate('volunteer-session-summary', 'txt');
|
|
html = await this.processTemplate(htmlTemplate, vars);
|
|
vars.OUTCOME_BREAKDOWN = outcomeText;
|
|
text = await this.processTextTemplate(txtTemplate, vars);
|
|
subject = `Canvass session summary — ${options.cutName}`;
|
|
}
|
|
|
|
await this.sendEmail({ to: options.volunteerEmail, subject, html, text });
|
|
}
|
|
|
|
async sendVolunteerCancellationAck(options: {
|
|
volunteerEmail: string;
|
|
volunteerName: string;
|
|
shiftTitle: string;
|
|
shiftDate: string;
|
|
shiftTime: string;
|
|
signupUrl: string;
|
|
}): Promise<void> {
|
|
const orgName = await this.getOrganizationName();
|
|
const vars: Record<string, string> = {
|
|
ORGANIZATION_NAME: orgName,
|
|
VOLUNTEER_NAME: options.volunteerName,
|
|
SHIFT_TITLE: options.shiftTitle,
|
|
SHIFT_DATE: options.shiftDate,
|
|
SHIFT_TIME: options.shiftTime,
|
|
SIGNUP_URL: options.signupUrl,
|
|
};
|
|
|
|
const dbTemplate = await this.loadTemplateFromDatabase('volunteer-cancellation-ack');
|
|
|
|
let html: string, text: string, subject: string;
|
|
if (dbTemplate) {
|
|
html = await this.processTemplate(dbTemplate.html, vars);
|
|
text = await this.processTextTemplate(dbTemplate.text, vars);
|
|
subject = this.processSubject(dbTemplate.subject, vars);
|
|
} else {
|
|
const htmlTemplate = this.loadTemplate('volunteer-cancellation-ack', 'html');
|
|
const txtTemplate = this.loadTemplate('volunteer-cancellation-ack', 'txt');
|
|
html = await this.processTemplate(htmlTemplate, vars);
|
|
text = await this.processTextTemplate(txtTemplate, vars);
|
|
subject = `Signup cancelled — ${options.shiftTitle}`;
|
|
}
|
|
|
|
await this.sendEmail({ to: options.volunteerEmail, subject, html, text });
|
|
}
|
|
|
|
async sendResponseVerification(options: {
|
|
recipientEmail: string;
|
|
campaignTitle: string;
|
|
responseType: string;
|
|
responseText: string;
|
|
submitterName: string;
|
|
verificationUrl: string;
|
|
reportUrl: string;
|
|
}): Promise<SendEmailResult> {
|
|
const orgName = await this.getOrganizationName();
|
|
const vars: Record<string, string> = {
|
|
CAMPAIGN_TITLE: options.campaignTitle,
|
|
RESPONSE_TYPE: options.responseType.replace(/_/g, ' '),
|
|
RESPONSE_TEXT: options.responseText.length > 500
|
|
? options.responseText.slice(0, 500) + '...'
|
|
: options.responseText,
|
|
SUBMITTER_NAME: options.submitterName,
|
|
SUBMITTED_DATE: new Date().toLocaleDateString('en-CA', { year: 'numeric', month: 'long', day: 'numeric' }),
|
|
VERIFICATION_URL: options.verificationUrl,
|
|
REPORT_URL: options.reportUrl,
|
|
ORGANIZATION_NAME: orgName,
|
|
TIMESTAMP: new Date().toISOString(),
|
|
};
|
|
|
|
// Try database first
|
|
const dbTemplate = await this.loadTemplateFromDatabase('response-verification');
|
|
|
|
let html: string, text: string, subject: string;
|
|
|
|
if (dbTemplate) {
|
|
// Use database template
|
|
html = await this.processTemplate(dbTemplate.html, vars);
|
|
text = await this.processTemplate(dbTemplate.text, vars);
|
|
subject = this.processSubject(dbTemplate.subject, vars);
|
|
logger.debug('Using response verification template from database');
|
|
} else {
|
|
// Fallback to filesystem
|
|
const htmlTemplate = this.loadTemplate('response-verification', 'html');
|
|
const txtTemplate = this.loadTemplate('response-verification', 'txt');
|
|
html = await this.processTemplate(htmlTemplate, vars);
|
|
text = await this.processTemplate(txtTemplate, vars);
|
|
subject = `Verify Response — ${options.campaignTitle}`;
|
|
logger.warn('Using response verification template from filesystem (fallback)');
|
|
}
|
|
|
|
return this.sendEmail({
|
|
to: options.recipientEmail,
|
|
subject,
|
|
html,
|
|
text,
|
|
});
|
|
}
|
|
}
|
|
|
|
export const emailService = new EmailService();
|