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>
306 lines
9.4 KiB
TypeScript
306 lines
9.4 KiB
TypeScript
// 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<string, any>;
|
|
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<typeof setTimeout> | 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<number> {
|
|
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<void> {
|
|
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<QueueStatus> {
|
|
cleanOldTimestamps();
|
|
const row = await dbGet<any>(
|
|
(db as any)
|
|
.select({ count: sql<number>`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<ClaimedJob | null> {
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
const row = await dbGet<any>(
|
|
(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<void> {
|
|
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<void> {
|
|
await (db as any)
|
|
.update(emailQueue)
|
|
.set({ status: 'pending' })
|
|
.where(eq((emailQueue as any).id, id));
|
|
}
|
|
|
|
async function processNext(): Promise<void> {
|
|
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.');
|
|
}
|