Split oversized frontend API client, email service, and admin/booking pages into focused modules while preserving import surfaces, and add Redis-backed queues, stale booking cleanup, stronger auth, and scale deployment configs. Co-authored-by: Cursor <cursoragent@cursor.com>
308 lines
8.1 KiB
TypeScript
308 lines
8.1 KiB
TypeScript
// Template DB access, seeding, and the core template/custom send + logging logic.
|
|
|
|
import { db, dbGet, emailTemplates, emailLogs } from '../../db/index.js';
|
|
import { eq } from 'drizzle-orm';
|
|
import { getNow, generateId } from '../utils.js';
|
|
import { replaceTemplateVariables, wrapInBaseTemplate, defaultTemplates } from '../emailTemplates.js';
|
|
import { sendEmail } from './transport.js';
|
|
import { getCommonVariables } from './formatting.js';
|
|
|
|
/**
|
|
* Get a template by slug
|
|
*/
|
|
export async function getTemplate(slug: string): Promise<any | null> {
|
|
const template = await dbGet(
|
|
(db as any)
|
|
.select()
|
|
.from(emailTemplates)
|
|
.where(eq((emailTemplates as any).slug, slug))
|
|
);
|
|
|
|
return template || null;
|
|
}
|
|
|
|
/**
|
|
* Seed default templates if they don't exist, and update system templates with latest content
|
|
*/
|
|
export async function seedDefaultTemplates(): Promise<void> {
|
|
console.log('[Email] Checking for default templates...');
|
|
|
|
for (const template of defaultTemplates) {
|
|
const existing = await getTemplate(template.slug);
|
|
const now = getNow();
|
|
|
|
if (!existing) {
|
|
console.log(`[Email] Creating template: ${template.name}`);
|
|
|
|
await (db as any).insert(emailTemplates).values({
|
|
id: generateId(),
|
|
name: template.name,
|
|
slug: template.slug,
|
|
subject: template.subject,
|
|
subjectEs: template.subjectEs,
|
|
bodyHtml: template.bodyHtml,
|
|
bodyHtmlEs: template.bodyHtmlEs,
|
|
bodyText: template.bodyText,
|
|
bodyTextEs: template.bodyTextEs,
|
|
description: template.description,
|
|
variables: JSON.stringify(template.variables),
|
|
isSystem: template.isSystem ? 1 : 0,
|
|
isActive: 1,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
} else if (existing.isSystem) {
|
|
// Update system templates with latest content from defaults
|
|
console.log(`[Email] Updating system template: ${template.name}`);
|
|
|
|
await (db as any)
|
|
.update(emailTemplates)
|
|
.set({
|
|
subject: template.subject,
|
|
subjectEs: template.subjectEs,
|
|
bodyHtml: template.bodyHtml,
|
|
bodyHtmlEs: template.bodyHtmlEs,
|
|
bodyText: template.bodyText,
|
|
bodyTextEs: template.bodyTextEs,
|
|
description: template.description,
|
|
variables: JSON.stringify(template.variables),
|
|
updatedAt: now,
|
|
})
|
|
.where(eq((emailTemplates as any).slug, template.slug));
|
|
}
|
|
}
|
|
|
|
console.log('[Email] Default templates check complete');
|
|
}
|
|
|
|
/**
|
|
* Send an email using a template
|
|
*/
|
|
export async function sendTemplateEmail(params: {
|
|
templateSlug: string;
|
|
to: string;
|
|
toName?: string;
|
|
variables: Record<string, any>;
|
|
locale?: string;
|
|
eventId?: string;
|
|
sentBy?: string;
|
|
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
|
const { templateSlug, to, toName, variables, locale = 'en', eventId, sentBy } = params;
|
|
|
|
// Get template
|
|
const template = await getTemplate(templateSlug);
|
|
if (!template) {
|
|
return { success: false, error: `Template "${templateSlug}" not found` };
|
|
}
|
|
|
|
// Build variables
|
|
const allVariables = {
|
|
...getCommonVariables(),
|
|
lang: locale,
|
|
...variables,
|
|
};
|
|
|
|
// Get localized content
|
|
const subject = locale === 'es' && template.subjectEs
|
|
? template.subjectEs
|
|
: template.subject;
|
|
const bodyHtml = locale === 'es' && template.bodyHtmlEs
|
|
? template.bodyHtmlEs
|
|
: template.bodyHtml;
|
|
const bodyText = locale === 'es' && template.bodyTextEs
|
|
? template.bodyTextEs
|
|
: template.bodyText;
|
|
|
|
// Replace variables
|
|
const finalSubject = replaceTemplateVariables(subject, allVariables);
|
|
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables, true);
|
|
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
|
|
const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined;
|
|
|
|
// Create log entry
|
|
const logId = generateId();
|
|
const now = getNow();
|
|
|
|
await (db as any).insert(emailLogs).values({
|
|
id: logId,
|
|
templateId: template.id,
|
|
eventId: eventId || null,
|
|
recipientEmail: to,
|
|
recipientName: toName || null,
|
|
subject: finalSubject,
|
|
bodyHtml: finalBodyHtml,
|
|
status: 'pending',
|
|
sentBy: sentBy || null,
|
|
createdAt: now,
|
|
});
|
|
|
|
// Send email
|
|
const result = await sendEmail({
|
|
to,
|
|
subject: finalSubject,
|
|
html: finalBodyHtml,
|
|
text: finalBodyText,
|
|
});
|
|
|
|
// Update log with result
|
|
if (result.success) {
|
|
await (db as any)
|
|
.update(emailLogs)
|
|
.set({
|
|
status: 'sent',
|
|
sentAt: getNow(),
|
|
})
|
|
.where(eq((emailLogs as any).id, logId));
|
|
} else {
|
|
await (db as any)
|
|
.update(emailLogs)
|
|
.set({
|
|
status: 'failed',
|
|
errorMessage: result.error,
|
|
})
|
|
.where(eq((emailLogs as any).id, logId));
|
|
}
|
|
|
|
return {
|
|
success: result.success,
|
|
logId,
|
|
error: result.error
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Send a custom email (not from template)
|
|
*/
|
|
export async function sendCustomEmail(params: {
|
|
to: string;
|
|
toName?: string;
|
|
subject: string;
|
|
bodyHtml: string;
|
|
bodyText?: string;
|
|
replyTo?: string;
|
|
eventId?: string;
|
|
sentBy?: string | null;
|
|
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
|
const { to: rawTo, toName, subject: rawSubject, bodyHtml, bodyText, replyTo: rawReplyTo, eventId, sentBy = null } = params;
|
|
|
|
// Strip CR/LF from header-bound values to prevent email header injection
|
|
// (e.g. an attacker-supplied subject/replyTo smuggling extra headers/recipients).
|
|
const stripHeader = (v?: string) => (v ? v.replace(/[\r\n]+/g, ' ').trim() : v);
|
|
const to = stripHeader(rawTo) as string;
|
|
const subject = stripHeader(rawSubject) as string;
|
|
const replyTo = stripHeader(rawReplyTo);
|
|
|
|
const allVariables = {
|
|
...getCommonVariables(),
|
|
subject,
|
|
};
|
|
|
|
const finalBodyHtml = wrapInBaseTemplate(bodyHtml, allVariables);
|
|
|
|
// Create log entry
|
|
const logId = generateId();
|
|
const now = getNow();
|
|
|
|
await (db as any).insert(emailLogs).values({
|
|
id: logId,
|
|
templateId: null,
|
|
eventId: eventId || null,
|
|
recipientEmail: to,
|
|
recipientName: toName || null,
|
|
subject,
|
|
bodyHtml: finalBodyHtml,
|
|
status: 'pending',
|
|
sentBy: sentBy || null,
|
|
createdAt: now,
|
|
});
|
|
|
|
// Send email
|
|
const result = await sendEmail({
|
|
to,
|
|
subject,
|
|
html: finalBodyHtml,
|
|
text: bodyText,
|
|
replyTo,
|
|
});
|
|
|
|
// Update log
|
|
if (result.success) {
|
|
await (db as any)
|
|
.update(emailLogs)
|
|
.set({
|
|
status: 'sent',
|
|
sentAt: getNow(),
|
|
})
|
|
.where(eq((emailLogs as any).id, logId));
|
|
} else {
|
|
await (db as any)
|
|
.update(emailLogs)
|
|
.set({
|
|
status: 'failed',
|
|
errorMessage: result.error,
|
|
})
|
|
.where(eq((emailLogs as any).id, logId));
|
|
}
|
|
|
|
return {
|
|
success: result.success,
|
|
logId,
|
|
error: result.error
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resend an email from an existing log entry
|
|
*/
|
|
export async function resendFromLog(logId: string): Promise<{ success: boolean; error?: string }> {
|
|
const log = await dbGet<any>(
|
|
(db as any).select().from(emailLogs).where(eq((emailLogs as any).id, logId))
|
|
);
|
|
|
|
if (!log) {
|
|
return { success: false, error: 'Email log not found' };
|
|
}
|
|
|
|
if (!log.bodyHtml || !log.subject || !log.recipientEmail) {
|
|
return { success: false, error: 'Email log missing required data to resend' };
|
|
}
|
|
|
|
const result = await sendEmail({
|
|
to: log.recipientEmail,
|
|
subject: log.subject,
|
|
html: log.bodyHtml,
|
|
text: undefined,
|
|
});
|
|
|
|
const now = getNow();
|
|
const currentResendAttempts = (log.resendAttempts ?? 0) + 1;
|
|
|
|
if (result.success) {
|
|
await (db as any)
|
|
.update(emailLogs)
|
|
.set({
|
|
status: 'sent',
|
|
sentAt: now,
|
|
errorMessage: null,
|
|
resendAttempts: currentResendAttempts,
|
|
lastResentAt: now,
|
|
})
|
|
.where(eq((emailLogs as any).id, logId));
|
|
} else {
|
|
await (db as any)
|
|
.update(emailLogs)
|
|
.set({
|
|
status: 'failed',
|
|
errorMessage: result.error,
|
|
resendAttempts: currentResendAttempts,
|
|
lastResentAt: now,
|
|
})
|
|
.where(eq((emailLogs as any).id, logId));
|
|
}
|
|
|
|
return {
|
|
success: result.success,
|
|
error: result.error,
|
|
};
|
|
}
|