changemaker.lite/api/dist/services/email.service.js

938 lines
41 KiB
JavaScript

"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.emailService = void 0;
const nodemailer_1 = __importDefault(require("nodemailer"));
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const env_1 = require("../config/env");
const logger_1 = require("../utils/logger");
const settings_service_1 = require("../modules/settings/settings.service");
const client_1 = require("@prisma/client");
const prisma = new client_1.PrismaClient();
// In-memory template cache (filesystem)
const templateCache = new Map();
const databaseTemplateCache = new Map();
class EmailService {
transporter;
constructor() {
this.transporter = this.createTransporter(env_1.env.SMTP_HOST, env_1.env.SMTP_PORT, env_1.env.SMTP_USER, env_1.env.SMTP_PASS);
}
createTransporter(host, port, user, pass) {
const config = { host, port };
// Only set auth if user is provided (MailHog doesn't need auth)
if (user) {
config.auth = { user, pass };
}
return nodemailer_1.default.createTransport(config);
}
/** Rebuild transporter from DB settings (with env fallback). Call after SMTP settings change. */
async rebuildTransporter() {
try {
const settings = await settings_service_1.siteSettingsService.get();
const provider = settings.smtpActiveProvider || 'mailhog';
let host, port, user, pass;
if (provider === 'mailhog') {
host = 'mailhog-changemaker';
port = 1025;
user = '';
pass = '';
}
else {
host = settings.smtpHost || env_1.env.SMTP_HOST;
port = settings.smtpPort || env_1.env.SMTP_PORT;
user = settings.smtpUser || env_1.env.SMTP_USER;
pass = settings.smtpPass || env_1.env.SMTP_PASS;
}
this.transporter = this.createTransporter(host, port, user, pass);
logger_1.logger.info(`SMTP transporter rebuilt: provider=${provider} ${host}:${port} (auth=${!!user})`);
}
catch (err) {
logger_1.logger.error('Failed to rebuild SMTP transporter, keeping current config:', err);
}
}
async testConnection() {
try {
await this.transporter.verify();
logger_1.logger.info('SMTP connection verified');
return true;
}
catch (err) {
logger_1.logger.error('SMTP connection failed:', err);
return false;
}
}
/**
* Load template from database (with caching)
*/
async loadTemplateFromDatabase(key) {
// 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 = {
html: template.htmlContent,
text: template.textContent,
subject: template.subjectLine,
};
// Cache for future use
databaseTemplateCache.set(key, dbTemplate);
logger_1.logger.debug(`Loaded template "${key}" from database`);
return dbTemplate;
}
catch (error) {
logger_1.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, type) {
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_1.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) {
if (key) {
databaseTemplateCache.delete(key);
logger_1.logger.debug(`Cleared database cache for template: ${key}`);
}
else {
databaseTemplateCache.clear();
logger_1.logger.debug('Cleared all database template cache');
}
}
escapeHtml(unsafe) {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
/**
* Fetch video metadata from Media API
*/
async getVideoMetadata(videoId) {
try {
const mediaApiUrl = env_1.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();
}
catch (error) {
logger_1.logger.error(`Failed to fetch video ${videoId} metadata:`, error);
return null;
}
}
formatDuration(seconds) {
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')}`;
}
formatViewCount(count) {
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, vars, variableDefinitions) {
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_1.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(' &bull; ');
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;">
&#9654; 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, vars, variableDefinitions) {
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_1.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, vars) {
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;
}
async getFromField() {
try {
const settings = await settings_service_1.siteSettingsService.get();
const name = settings.emailFromName || env_1.env.SMTP_FROM_NAME;
const address = settings.smtpFromAddress || env_1.env.SMTP_FROM;
return `${name} <${address}>`;
}
catch {
return `${env_1.env.SMTP_FROM_NAME} <${env_1.env.SMTP_FROM}>`;
}
}
async getOrganizationName() {
try {
const settings = await settings_service_1.siteSettingsService.get();
return settings.organizationName || 'Changemaker Lite';
}
catch {
return 'Changemaker Lite';
}
}
async getEmailTestConfig() {
try {
const settings = await settings_service_1.siteSettingsService.get();
return {
testMode: settings.emailTestMode,
testRecipient: settings.testEmailRecipient || env_1.env.TEST_EMAIL_RECIPIENT,
};
}
catch {
return {
testMode: env_1.env.EMAIL_TEST_MODE === 'true',
testRecipient: env_1.env.TEST_EMAIL_RECIPIENT,
};
}
}
async sendEmail(options) {
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_1.logger.info(`Email sent: ${info.messageId} to=${to} testMode=${testMode}`);
return {
success: true,
messageId: info.messageId,
testMode,
};
}
catch (err) {
logger_1.logger.error('Failed to send email:', err);
return {
success: false,
testMode,
};
}
}
async sendCampaignEmail(options) {
const orgName = await this.getOrganizationName();
const vars = {
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, text, subject;
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_1.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_1.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() {
try {
const settings = await settings_service_1.siteSettingsService.get();
return settings.smtpActiveProvider === 'production' && !!settings.smtpHost;
}
catch {
return env_1.env.SMTP_HOST !== 'mailhog-changemaker' && !!env_1.env.SMTP_HOST;
}
}
async sendVerificationEmail(options) {
const orgName = await this.getOrganizationName();
const vars = {
RECIPIENT_NAME: options.recipientName || 'there',
VERIFICATION_URL: options.verificationUrl,
ORGANIZATION_NAME: orgName,
EXPIRY_HOURS: '24',
};
const dbTemplate = await this.loadTemplateFromDatabase('email-verification');
let html, text, subject;
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) {
const orgName = await this.getOrganizationName();
const vars = {
RECIPIENT_NAME: options.recipientName || 'there',
RESET_URL: options.resetUrl,
ORGANIZATION_NAME: orgName,
};
const dbTemplate = await this.loadTemplateFromDatabase('password-reset');
let html, text, subject;
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) {
const orgName = await this.getOrganizationName();
const vars = {
RECIPIENT_NAME: options.recipientName || 'there',
LOGIN_URL: options.loginUrl,
ORGANIZATION_NAME: orgName,
};
const dbTemplate = await this.loadTemplateFromDatabase('account-approved');
let html, text, subject;
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) {
const orgName = await this.getOrganizationName();
const adminUrl = env_1.env.ADMIN_URL || 'http://localhost:3000';
const vars = {
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, text, subject;
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) {
const orgName = await this.getOrganizationName();
const vars = {
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, text, subject;
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) {
const orgName = await this.getOrganizationName();
const vars = {
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, text, subject;
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) {
const orgName = await this.getOrganizationName();
const vars = {
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, text, subject;
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) {
const orgName = await this.getOrganizationName();
const vars = {
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, text, subject;
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) {
const orgName = await this.getOrganizationName();
const vars = {
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, text, subject;
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) {
const orgName = await this.getOrganizationName();
const vars = {
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, text, subject;
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) {
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 = {
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, text, subject;
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) {
const orgName = await this.getOrganizationName();
const vars = {
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, text, subject;
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 sendVolunteerShiftThankYou(options) {
const orgName = await this.getOrganizationName();
const vars = {
ORGANIZATION_NAME: orgName,
VOLUNTEER_NAME: options.volunteerName,
SHIFT_TITLE: options.shiftTitle,
SHIFT_DATE: options.shiftDate,
SHIFT_TIME: options.shiftTime,
SHIFT_LOCATION: options.shiftLocation,
SIGNUP_URL: options.signupUrl,
};
const dbTemplate = await this.loadTemplateFromDatabase('volunteer-shift-thank-you');
let html, text, subject;
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-shift-thank-you', 'html');
const txtTemplate = this.loadTemplate('volunteer-shift-thank-you', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTextTemplate(txtTemplate, vars);
subject = `Thank you for volunteering — ${options.shiftTitle}`;
}
await this.sendEmail({ to: options.volunteerEmail, subject, html, text });
}
async sendVolunteerReengagement(options) {
const orgName = await this.getOrganizationName();
const vars = {
ORGANIZATION_NAME: orgName,
VOLUNTEER_NAME: options.volunteerName,
LAST_ACTIVITY_DATE: options.lastActivityDate,
LAST_ACTIVITY_TYPE: options.lastActivityType,
SIGNUP_URL: options.signupUrl,
};
const dbTemplate = await this.loadTemplateFromDatabase('volunteer-reengagement');
let html, text, subject;
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-reengagement', 'html');
const txtTemplate = this.loadTemplate('volunteer-reengagement', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTextTemplate(txtTemplate, vars);
subject = `We miss you — ${orgName}`;
}
await this.sendEmail({ to: options.volunteerEmail, subject, html, text });
}
async sendResponseVerification(options) {
const orgName = await this.getOrganizationName();
const vars = {
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, text, subject;
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_1.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_1.logger.warn('Using response verification template from filesystem (fallback)');
}
return this.sendEmail({
to: options.recipientEmail,
subject,
html,
text,
});
}
async sendProfileLinkEmail(options) {
const vars = {
RECIPIENT_NAME: options.recipientName,
PROFILE_URL: options.profileUrl,
ORGANIZATION_NAME: options.organizationName,
EXTRA_NOTES: options.extraNotes || '',
};
const dbTemplate = await this.loadTemplateFromDatabase('profile-link');
let html, text, subject;
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('profile-link', 'html');
const txtTemplate = this.loadTemplate('profile-link', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTextTemplate(txtTemplate, vars);
subject = `Your Profile — ${options.organizationName}`;
}
return this.sendEmail({
to: options.recipientEmail,
subject,
html,
text,
});
}
}
exports.emailService = new EmailService();
//# sourceMappingURL=email.service.js.map