426 lines
17 KiB
JavaScript
426 lines
17 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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
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 videoHtml = `
|
|
<table cellpadding="0" cellspacing="0" border="0" style="max-width: 600px; margin: 0 auto;">
|
|
<tr>
|
|
<td>
|
|
<a href="${publicUrl}/media/${video.id}" style="display: block; text-decoration: none;">
|
|
<img src="${video.thumbnailUrl}"
|
|
alt="${this.escapeHtml(video.title)}"
|
|
style="width: 100%; max-width: 600px; height: auto; display: block; border-radius: 8px;" />
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding-top: 12px; text-align: center;">
|
|
<a href="${publicUrl}/media/${video.id}"
|
|
style="display: inline-block; padding: 12px 24px; background-color: #0066cc; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">
|
|
▶ Watch Video: ${this.escapeHtml(video.title)}
|
|
</a>
|
|
</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 textLink = `Watch Video: ${video.title}\n${publicUrl}/media/${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 = this.processTemplate(dbTemplate.html, vars);
|
|
text = 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 = this.processTemplate(htmlTemplate, vars);
|
|
text = 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,
|
|
});
|
|
}
|
|
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 = this.processTemplate(dbTemplate.html, vars);
|
|
text = 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 = this.processTemplate(htmlTemplate, vars);
|
|
text = 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,
|
|
});
|
|
}
|
|
}
|
|
exports.emailService = new EmailService();
|
|
//# sourceMappingURL=email.service.js.map
|