Email queue + async sending; legal settings and placeholders
- Add in-memory email queue with rate limiting (MAX_EMAILS_PER_HOUR) - Bulk send to event attendees now queues and returns immediately - Frontend shows 'Emails are being sent in the background' - Legal pages, settings, and placeholders updates Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+100
-3
@@ -10,6 +10,7 @@ import {
|
||||
defaultTemplates,
|
||||
type DefaultTemplate
|
||||
} from './emailTemplates.js';
|
||||
import { enqueueBulkEmails, type TemplateEmailJobParams } from './emailQueue.js';
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
|
||||
@@ -1173,6 +1174,100 @@ export const emailService = {
|
||||
errors,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Queue emails for event attendees (non-blocking).
|
||||
* Adds all matching recipients to the background email queue and returns immediately.
|
||||
* Rate limiting and actual sending is handled by the email queue.
|
||||
*/
|
||||
async queueEventEmails(params: {
|
||||
eventId: string;
|
||||
templateSlug: string;
|
||||
customVariables?: Record<string, any>;
|
||||
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||
sentBy: string;
|
||||
}): Promise<{ success: boolean; queuedCount: number; error?: string }> {
|
||||
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
|
||||
|
||||
// Validate event exists
|
||||
const event = await dbGet<any>(
|
||||
(db as any)
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq((events as any).id, eventId))
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
return { success: false, queuedCount: 0, error: 'Event not found' };
|
||||
}
|
||||
|
||||
// Validate template exists
|
||||
const template = await this.getTemplate(templateSlug);
|
||||
if (!template) {
|
||||
return { success: false, queuedCount: 0, error: `Template "${templateSlug}" not found` };
|
||||
}
|
||||
|
||||
// Get tickets based on filter
|
||||
let ticketQuery = (db as any)
|
||||
.select()
|
||||
.from(tickets)
|
||||
.where(eq((tickets as any).eventId, eventId));
|
||||
|
||||
if (recipientFilter !== 'all') {
|
||||
ticketQuery = ticketQuery.where(
|
||||
and(
|
||||
eq((tickets as any).eventId, eventId),
|
||||
eq((tickets as any).status, recipientFilter)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const eventTickets = await dbAll<any>(ticketQuery);
|
||||
|
||||
if (eventTickets.length === 0) {
|
||||
return { success: true, queuedCount: 0, error: 'No recipients found' };
|
||||
}
|
||||
|
||||
// Get site timezone for proper date/time formatting
|
||||
const timezone = await this.getSiteTimezone();
|
||||
|
||||
// Build individual email jobs for the queue
|
||||
const jobs: TemplateEmailJobParams[] = eventTickets.map((ticket: any) => {
|
||||
const locale = ticket.preferredLanguage || 'en';
|
||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||
|
||||
return {
|
||||
templateSlug,
|
||||
to: ticket.attendeeEmail,
|
||||
toName: fullName,
|
||||
locale,
|
||||
eventId: event.id,
|
||||
sentBy,
|
||||
variables: {
|
||||
attendeeName: fullName,
|
||||
attendeeEmail: ticket.attendeeEmail,
|
||||
ticketId: ticket.id,
|
||||
eventTitle,
|
||||
eventDate: this.formatDate(event.startDatetime, locale, timezone),
|
||||
eventTime: this.formatTime(event.startDatetime, locale, timezone),
|
||||
eventLocation: event.location,
|
||||
eventLocationUrl: event.locationUrl || '',
|
||||
...customVariables,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Enqueue all emails for background processing
|
||||
enqueueBulkEmails(jobs);
|
||||
|
||||
console.log(`[Email] Queued ${jobs.length} emails for event "${event.title}" (filter: ${recipientFilter})`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
queuedCount: jobs.length,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a custom email (not from template)
|
||||
@@ -1183,10 +1278,11 @@ export const emailService = {
|
||||
subject: string;
|
||||
bodyHtml: string;
|
||||
bodyText?: string;
|
||||
replyTo?: string;
|
||||
eventId?: string;
|
||||
sentBy: string;
|
||||
sentBy?: string | null;
|
||||
}): Promise<{ success: boolean; logId?: string; error?: string }> {
|
||||
const { to, toName, subject, bodyHtml, bodyText, eventId, sentBy } = params;
|
||||
const { to, toName, subject, bodyHtml, bodyText, replyTo, eventId, sentBy = null } = params;
|
||||
|
||||
const allVariables = {
|
||||
...this.getCommonVariables(),
|
||||
@@ -1208,7 +1304,7 @@ export const emailService = {
|
||||
subject,
|
||||
bodyHtml: finalBodyHtml,
|
||||
status: 'pending',
|
||||
sentBy,
|
||||
sentBy: sentBy || null,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
@@ -1218,6 +1314,7 @@ export const emailService = {
|
||||
subject,
|
||||
html: finalBodyHtml,
|
||||
text: bodyText,
|
||||
replyTo,
|
||||
});
|
||||
|
||||
// Update log
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// In-memory email queue with rate limiting
|
||||
// Processes emails asynchronously in the background without blocking the request thread
|
||||
|
||||
import { generateId } from './utils.js';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface EmailJob {
|
||||
id: string;
|
||||
type: 'template';
|
||||
params: TemplateEmailJobParams;
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
export interface TemplateEmailJobParams {
|
||||
templateSlug: string;
|
||||
to: string;
|
||||
toName?: string;
|
||||
variables: Record<string, any>;
|
||||
locale?: string;
|
||||
eventId?: string;
|
||||
sentBy?: string;
|
||||
}
|
||||
|
||||
export interface QueueStatus {
|
||||
queued: number;
|
||||
processing: boolean;
|
||||
sentInLastHour: number;
|
||||
maxPerHour: number;
|
||||
}
|
||||
|
||||
// ==================== Queue State ====================
|
||||
|
||||
const queue: EmailJob[] = [];
|
||||
const sentTimestamps: number[] = [];
|
||||
let processing = false;
|
||||
let processTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Lazy reference to emailService to avoid circular imports
|
||||
let _emailService: any = null;
|
||||
|
||||
function getEmailService() {
|
||||
if (!_emailService) {
|
||||
// Dynamic import to avoid circular dependency
|
||||
throw new Error('[EmailQueue] Email service not initialized. Call initEmailQueue() first.');
|
||||
}
|
||||
return _emailService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the email queue with a reference to the email service.
|
||||
* Must be called once at startup.
|
||||
*/
|
||||
export function initEmailQueue(emailService: any): void {
|
||||
_emailService = emailService;
|
||||
console.log('[EmailQueue] Initialized');
|
||||
}
|
||||
|
||||
// ==================== Rate Limiting ====================
|
||||
|
||||
function getMaxPerHour(): number {
|
||||
return parseInt(process.env.MAX_EMAILS_PER_HOUR || '30', 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up timestamps older than 1 hour
|
||||
*/
|
||||
function cleanOldTimestamps(): void {
|
||||
const oneHourAgo = Date.now() - 3_600_000;
|
||||
while (sentTimestamps.length > 0 && sentTimestamps[0] <= oneHourAgo) {
|
||||
sentTimestamps.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Queue Operations ====================
|
||||
|
||||
/**
|
||||
* Add a single email job to the queue.
|
||||
* Returns the job ID.
|
||||
*/
|
||||
export function enqueueEmail(params: TemplateEmailJobParams): string {
|
||||
const id = generateId();
|
||||
queue.push({
|
||||
id,
|
||||
type: 'template',
|
||||
params,
|
||||
addedAt: Date.now(),
|
||||
});
|
||||
scheduleProcessing();
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple email jobs to the queue at once.
|
||||
* Returns array of job IDs.
|
||||
*/
|
||||
export function enqueueBulkEmails(paramsList: TemplateEmailJobParams[]): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const params of paramsList) {
|
||||
const id = generateId();
|
||||
queue.push({
|
||||
id,
|
||||
type: 'template',
|
||||
params,
|
||||
addedAt: Date.now(),
|
||||
});
|
||||
ids.push(id);
|
||||
}
|
||||
if (ids.length > 0) {
|
||||
console.log(`[EmailQueue] Queued ${ids.length} emails for background processing`);
|
||||
scheduleProcessing();
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current queue status
|
||||
*/
|
||||
export function getQueueStatus(): QueueStatus {
|
||||
cleanOldTimestamps();
|
||||
return {
|
||||
queued: queue.length,
|
||||
processing,
|
||||
sentInLastHour: sentTimestamps.length,
|
||||
maxPerHour: getMaxPerHour(),
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Processing ====================
|
||||
|
||||
function scheduleProcessing(): void {
|
||||
if (processing) return;
|
||||
processing = true;
|
||||
// Start processing on next tick to not block the caller
|
||||
setImmediate(() => processNext());
|
||||
}
|
||||
|
||||
async function processNext(): Promise<void> {
|
||||
if (queue.length === 0) {
|
||||
processing = false;
|
||||
console.log('[EmailQueue] Queue empty. Processing stopped.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limit check
|
||||
cleanOldTimestamps();
|
||||
const maxPerHour = getMaxPerHour();
|
||||
|
||||
if (sentTimestamps.length >= maxPerHour) {
|
||||
// Calculate when the oldest timestamp in the window expires
|
||||
const waitMs = sentTimestamps[0] + 3_600_000 - Date.now() + 500; // 500ms buffer
|
||||
console.log(
|
||||
`[EmailQueue] Rate limit reached (${maxPerHour}/hr). ` +
|
||||
`Pausing for ${Math.ceil(waitMs / 1000)}s. ${queue.length} email(s) remaining.`
|
||||
);
|
||||
processTimer = setTimeout(() => processNext(), waitMs);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dequeue and process
|
||||
const job = queue.shift()!;
|
||||
|
||||
try {
|
||||
const emailService = getEmailService();
|
||||
await emailService.sendTemplateEmail(job.params);
|
||||
sentTimestamps.push(Date.now());
|
||||
console.log(
|
||||
`[EmailQueue] Sent email ${job.id} to ${job.params.to}. ` +
|
||||
`Queue: ${queue.length} remaining. Sent this hour: ${sentTimestamps.length}/${maxPerHour}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
`[EmailQueue] Failed to send email ${job.id} to ${job.params.to}:`,
|
||||
error?.message || error
|
||||
);
|
||||
// The sendTemplateEmail method already logs the failure in the email_logs table,
|
||||
// so we don't need to retry here. The error is logged and we move on.
|
||||
}
|
||||
|
||||
// Small delay between sends to be gentle on the email server
|
||||
processTimer = setTimeout(() => processNext(), 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop processing (for graceful shutdown)
|
||||
*/
|
||||
export function stopQueue(): void {
|
||||
if (processTimer) {
|
||||
clearTimeout(processTimer);
|
||||
processTimer = null;
|
||||
}
|
||||
processing = false;
|
||||
console.log(`[EmailQueue] Stopped. ${queue.length} email(s) remaining in queue.`);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { getLegalSettingsValues } from '../routes/legal-settings.js';
|
||||
|
||||
/**
|
||||
* Strict whitelist of supported placeholders.
|
||||
* Only these placeholders will be replaced in legal page content.
|
||||
* Unknown placeholders remain unchanged.
|
||||
*/
|
||||
const SUPPORTED_PLACEHOLDERS = new Set([
|
||||
'COMPANY_NAME',
|
||||
'LEGAL_ENTITY_NAME',
|
||||
'RUC_NUMBER',
|
||||
'COMPANY_ADDRESS',
|
||||
'COMPANY_CITY',
|
||||
'COMPANY_COUNTRY',
|
||||
'SUPPORT_EMAIL',
|
||||
'LEGAL_EMAIL',
|
||||
'GOVERNING_LAW',
|
||||
'JURISDICTION_CITY',
|
||||
'CURRENT_YEAR',
|
||||
'LAST_UPDATED_DATE',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Replace legal placeholders in content using strict whitelist mapping.
|
||||
*
|
||||
* Rules:
|
||||
* - Only supported placeholders are replaced
|
||||
* - Unknown placeholders remain unchanged
|
||||
* - Missing values are replaced with empty string
|
||||
* - No code execution or dynamic evaluation
|
||||
* - Replacement is pure string substitution
|
||||
*
|
||||
* @param content - The markdown/text content containing {{PLACEHOLDER}} tokens
|
||||
* @param updatedAt - The page's updated_at timestamp (for LAST_UPDATED_DATE)
|
||||
* @returns Content with placeholders replaced
|
||||
*/
|
||||
export async function replaceLegalPlaceholders(
|
||||
content: string,
|
||||
updatedAt?: string
|
||||
): Promise<string> {
|
||||
if (!content) return content;
|
||||
|
||||
// Fetch legal settings values from DB
|
||||
const settingsValues = await getLegalSettingsValues();
|
||||
|
||||
// Build the full replacement map
|
||||
const replacements: Record<string, string> = { ...settingsValues };
|
||||
|
||||
// Dynamic values
|
||||
replacements['CURRENT_YEAR'] = new Date().getFullYear().toString();
|
||||
|
||||
if (updatedAt) {
|
||||
try {
|
||||
const date = new Date(updatedAt);
|
||||
if (!isNaN(date.getTime())) {
|
||||
replacements['LAST_UPDATED_DATE'] = date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
} else {
|
||||
replacements['LAST_UPDATED_DATE'] = updatedAt;
|
||||
}
|
||||
} catch {
|
||||
replacements['LAST_UPDATED_DATE'] = updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
// Replace only whitelisted placeholders using a single regex pass
|
||||
// Matches {{PLACEHOLDER_NAME}} where PLACEHOLDER_NAME is uppercase letters and underscores
|
||||
return content.replace(/\{\{([A-Z_]+)\}\}/g, (match, placeholderName) => {
|
||||
// Only replace if the placeholder is in the whitelist
|
||||
if (!SUPPORTED_PLACEHOLDERS.has(placeholderName)) {
|
||||
return match; // Unknown placeholder - leave unchanged
|
||||
}
|
||||
|
||||
// Return the value or empty string if missing
|
||||
return replacements[placeholderName] ?? '';
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user