Refactor monolithic modules and harden booking, email, and auth infrastructure.
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>
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user