// Durable email queue with rate limiting. // Jobs are persisted in the `email_queue` DB table so they survive process // restarts. Emails are processed asynchronously in the background without // blocking the request thread. import { eq, and, asc, sql } from 'drizzle-orm'; import { db, dbGet, emailQueue } from '../db/index.js'; import { generateId, getNow } from './utils.js'; import { isRedisEnabled } from './redis.js'; import { getRateLimiter } from './stores/rateLimiter.js'; // ==================== Types ==================== export interface TemplateEmailJobParams { templateSlug: string; to: string; toName?: string; variables: Record; locale?: string; eventId?: string; sentBy?: string; } interface ClaimedJob { id: string; params: TemplateEmailJobParams; } export interface QueueStatus { queued: number; processing: boolean; sentInLastHour: number; maxPerHour: number; } // ==================== Queue State ==================== // Tracks send timestamps for the per-process (non-Redis) sliding-window rate // limit. The job backlog itself lives in the database, not in memory. const sentTimestamps: number[] = []; let processing = false; let processTimer: ReturnType | null = null; // Lazy reference to emailService to avoid circular imports let _emailService: any = null; function getEmailService() { if (!_emailService) { 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. * * Also performs crash recovery: any jobs left in the 'processing' state by a * previous (crashed) process are reset to 'pending' so they get retried. */ export function initEmailQueue(emailService: any): void { _emailService = emailService; recoverProcessingJobs() .then((recovered) => { if (recovered > 0) { console.log(`[EmailQueue] Recovered ${recovered} in-flight job(s) after restart`); } scheduleProcessing(); }) .catch((err) => console.error('[EmailQueue] Recovery failed:', err?.message || err)); console.log('[EmailQueue] Initialized'); } async function recoverProcessingJobs(): Promise { const result: any = await (db as any) .update(emailQueue) .set({ status: 'pending' }) .where(eq((emailQueue as any).status, 'processing')); return result?.changes ?? result?.rowCount ?? 0; } // ==================== 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 ==================== async function insertJob(id: string, params: TemplateEmailJobParams): Promise { await (db as any).insert(emailQueue).values({ id, params: JSON.stringify(params), status: 'pending', attempts: 0, createdAt: getNow(), }); } /** * Add a single email job to the queue. * Returns the job ID. Persistence happens asynchronously so the caller is not * blocked; processing is scheduled once the row is written. */ export function enqueueEmail(params: TemplateEmailJobParams): string { const id = generateId(); insertJob(id, params) .then(() => scheduleProcessing()) .catch((err) => console.error('[EmailQueue] Failed to enqueue email:', err?.message || err)); return id; } /** * Add multiple email jobs to the queue at once. * Returns array of job IDs. */ export function enqueueBulkEmails(paramsList: TemplateEmailJobParams[]): string[] { const ids = paramsList.map(() => generateId()); if (ids.length === 0) return ids; Promise.all(paramsList.map((params, i) => insertJob(ids[i], params))) .then(() => { console.log(`[EmailQueue] Queued ${ids.length} emails for background processing`); scheduleProcessing(); }) .catch((err) => console.error('[EmailQueue] Failed to enqueue bulk emails:', err?.message || err)); return ids; } /** * Get current queue status */ export async function getQueueStatus(): Promise { cleanOldTimestamps(); const row = await dbGet( (db as any) .select({ count: sql`count(*)` }) .from(emailQueue) .where(eq((emailQueue as any).status, 'pending')) ); return { queued: Number(row?.count || 0), 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()); } /** * Atomically claim the oldest pending job by flipping its status to * 'processing'. Returns null if there is nothing to do. The conditional update * (WHERE status='pending') guards against two workers claiming the same row. */ async function claimNextJob(): Promise { for (let attempt = 0; attempt < 5; attempt++) { const row = await dbGet( (db as any) .select({ id: (emailQueue as any).id, params: (emailQueue as any).params }) .from(emailQueue) .where(eq((emailQueue as any).status, 'pending')) .orderBy(asc((emailQueue as any).createdAt)) .limit(1) ); if (!row) return null; const result: any = await (db as any) .update(emailQueue) .set({ status: 'processing' }) .where(and( eq((emailQueue as any).id, row.id), eq((emailQueue as any).status, 'pending') )); const affected = result?.changes ?? result?.rowCount ?? 0; if (affected > 0) { try { return { id: row.id, params: JSON.parse(row.params) }; } catch { // Corrupt params: mark failed and move on rather than crash-looping. await markJob(row.id, 'failed', 'Invalid job params (JSON parse failed)'); continue; } } // Lost the race for this row; try the next pending one. } return null; } async function markJob(id: string, status: 'sent' | 'failed', error?: string | null): Promise { const update: any = { status, processedAt: getNow() }; if (status === 'failed') { update.attempts = sql`${(emailQueue as any).attempts} + 1`; if (error) update.lastError = error.slice(0, 1000); } await (db as any).update(emailQueue).set(update).where(eq((emailQueue as any).id, id)); } async function releaseJob(id: string): Promise { await (db as any) .update(emailQueue) .set({ status: 'pending' }) .where(eq((emailQueue as any).id, id)); } async function processNext(): Promise { let job: ClaimedJob | null; try { job = await claimNextJob(); } catch (error: any) { // Database error while claiming: back off and retry rather than stop. console.error('[EmailQueue] Failed to claim next job:', error?.message || error); processTimer = setTimeout(() => processNext(), 5_000); return; } if (!job) { processing = false; console.log('[EmailQueue] Queue empty. Processing stopped.'); return; } // Rate limit check. // - Without Redis: per-process sliding window. // - With Redis: a shared hourly counter so the cap applies across all // instances rather than once per replica. cleanOldTimestamps(); const maxPerHour = getMaxPerHour(); let waitMs = 0; if (isRedisEnabled()) { const result = await getRateLimiter().consume('email:hourly', maxPerHour, 3_600_000); if (!result.allowed) { waitMs = (result.retryAfter ?? 60) * 1000 + 500; // 500ms buffer } } else if (sentTimestamps.length >= maxPerHour) { // Calculate when the oldest timestamp in the window expires waitMs = sentTimestamps[0] + 3_600_000 - Date.now() + 500; // 500ms buffer } if (waitMs > 0) { // Put the claimed job back so it is retried after the cooldown. await releaseJob(job.id); console.log( `[EmailQueue] Rate limit reached (${maxPerHour}/hr). ` + `Pausing for ${Math.ceil(waitMs / 1000)}s.` ); processTimer = setTimeout(() => processNext(), waitMs); return; } try { const emailService = getEmailService(); await emailService.sendTemplateEmail(job.params); sentTimestamps.push(Date.now()); await markJob(job.id, 'sent'); console.log( `[EmailQueue] Sent email ${job.id} to ${job.params.to}. ` + `Sent this hour: ${sentTimestamps.length}/${maxPerHour}` ); } catch (error: any) { await markJob(job.id, 'failed', error?.message || String(error)); 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 just record it on the queue row and move on. } // Small delay between sends to be gentle on the email server processTimer = setTimeout(() => processNext(), 200); } /** * Stop processing (for graceful shutdown). In-flight and pending jobs remain * persisted in the database and resume on the next startup. */ export function stopQueue(): void { if (processTimer) { clearTimeout(processTimer); processTimer = null; } processing = false; console.log('[EmailQueue] Stopped. Pending jobs remain persisted in the database.'); }