- 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>
423 lines
12 KiB
TypeScript
423 lines
12 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
|
|
import { eq, desc, and, sql } from 'drizzle-orm';
|
|
import { requireAuth } from '../lib/auth.js';
|
|
import { getNow, generateId } from '../lib/utils.js';
|
|
import emailService from '../lib/email.js';
|
|
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
|
|
import { getQueueStatus } from '../lib/emailQueue.js';
|
|
|
|
const emailsRouter = new Hono();
|
|
|
|
// ==================== Template Routes ====================
|
|
|
|
// Get all email templates
|
|
emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) => {
|
|
const templates = await dbAll<any>(
|
|
(db as any).select().from(emailTemplates).orderBy(desc((emailTemplates as any).createdAt))
|
|
);
|
|
|
|
// Parse variables JSON for each template
|
|
const parsedTemplates = templates.map((t: any) => ({
|
|
...t,
|
|
variables: t.variables ? JSON.parse(t.variables) : [],
|
|
isSystem: Boolean(t.isSystem),
|
|
isActive: Boolean(t.isActive),
|
|
}));
|
|
|
|
return c.json({ templates: parsedTemplates });
|
|
});
|
|
|
|
// Get single email template
|
|
emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
|
const { id } = c.req.param();
|
|
|
|
const template = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(emailTemplates)
|
|
.where(eq((emailTemplates as any).id, id))
|
|
);
|
|
|
|
if (!template) {
|
|
return c.json({ error: 'Template not found' }, 404);
|
|
}
|
|
|
|
return c.json({
|
|
template: {
|
|
...template,
|
|
variables: template.variables ? JSON.parse(template.variables) : [],
|
|
isSystem: Boolean(template.isSystem),
|
|
isActive: Boolean(template.isActive),
|
|
}
|
|
});
|
|
});
|
|
|
|
// Create new email template
|
|
emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
|
|
const body = await c.req.json();
|
|
const { name, slug, subject, subjectEs, bodyHtml, bodyHtmlEs, bodyText, bodyTextEs, description, variables } = body;
|
|
|
|
if (!name || !slug || !subject || !bodyHtml) {
|
|
return c.json({ error: 'Name, slug, subject, and bodyHtml are required' }, 400);
|
|
}
|
|
|
|
// Check if slug already exists
|
|
const existing = await dbGet<any>(
|
|
(db as any).select().from(emailTemplates).where(eq((emailTemplates as any).slug, slug))
|
|
);
|
|
|
|
if (existing) {
|
|
return c.json({ error: 'Template with this slug already exists' }, 400);
|
|
}
|
|
|
|
const now = getNow();
|
|
const template = {
|
|
id: generateId(),
|
|
name,
|
|
slug,
|
|
subject,
|
|
subjectEs: subjectEs || null,
|
|
bodyHtml,
|
|
bodyHtmlEs: bodyHtmlEs || null,
|
|
bodyText: bodyText || null,
|
|
bodyTextEs: bodyTextEs || null,
|
|
description: description || null,
|
|
variables: variables ? JSON.stringify(variables) : null,
|
|
isSystem: 0,
|
|
isActive: 1,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
await (db as any).insert(emailTemplates).values(template);
|
|
|
|
return c.json({
|
|
template: {
|
|
...template,
|
|
variables: variables || [],
|
|
isSystem: false,
|
|
isActive: true,
|
|
},
|
|
message: 'Template created successfully'
|
|
}, 201);
|
|
});
|
|
|
|
// Update email template
|
|
emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
|
|
const { id } = c.req.param();
|
|
const body = await c.req.json();
|
|
|
|
const existing = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(emailTemplates)
|
|
.where(eq((emailTemplates as any).id, id))
|
|
);
|
|
|
|
if (!existing) {
|
|
return c.json({ error: 'Template not found' }, 404);
|
|
}
|
|
|
|
const updateData: any = { updatedAt: getNow() };
|
|
|
|
// Only allow updating certain fields for system templates
|
|
const systemProtectedFields = ['slug', 'isSystem'];
|
|
|
|
const allowedFields = ['name', 'subject', 'subjectEs', 'bodyHtml', 'bodyHtmlEs', 'bodyText', 'bodyTextEs', 'description', 'variables', 'isActive'];
|
|
if (!existing.isSystem) {
|
|
allowedFields.push('slug');
|
|
}
|
|
|
|
for (const field of allowedFields) {
|
|
if (body[field] !== undefined) {
|
|
if (field === 'variables') {
|
|
updateData[field] = JSON.stringify(body[field]);
|
|
} else if (field === 'isActive') {
|
|
updateData[field] = body[field] ? 1 : 0;
|
|
} else {
|
|
updateData[field] = body[field];
|
|
}
|
|
}
|
|
}
|
|
|
|
await (db as any)
|
|
.update(emailTemplates)
|
|
.set(updateData)
|
|
.where(eq((emailTemplates as any).id, id));
|
|
|
|
const updated = await dbGet<any>(
|
|
(db as any)
|
|
.select()
|
|
.from(emailTemplates)
|
|
.where(eq((emailTemplates as any).id, id))
|
|
);
|
|
|
|
return c.json({
|
|
template: {
|
|
...updated,
|
|
variables: updated.variables ? JSON.parse(updated.variables) : [],
|
|
isSystem: Boolean(updated.isSystem),
|
|
isActive: Boolean(updated.isActive),
|
|
},
|
|
message: 'Template updated successfully'
|
|
});
|
|
});
|
|
|
|
// Delete email template (only non-system templates)
|
|
emailsRouter.delete('/templates/:id', requireAuth(['admin']), async (c) => {
|
|
const { id } = c.req.param();
|
|
|
|
const template = await dbGet<any>(
|
|
(db as any).select().from(emailTemplates).where(eq((emailTemplates as any).id, id))
|
|
);
|
|
|
|
if (!template) {
|
|
return c.json({ error: 'Template not found' }, 404);
|
|
}
|
|
|
|
if (template.isSystem) {
|
|
return c.json({ error: 'Cannot delete system templates' }, 400);
|
|
}
|
|
|
|
await (db as any)
|
|
.delete(emailTemplates)
|
|
.where(eq((emailTemplates as any).id, id));
|
|
|
|
return c.json({ message: 'Template deleted successfully' });
|
|
});
|
|
|
|
// Get available template variables
|
|
emailsRouter.get('/templates/:slug/variables', requireAuth(['admin', 'organizer']), async (c) => {
|
|
const { slug } = c.req.param();
|
|
const variables = getTemplateVariables(slug);
|
|
return c.json({ variables });
|
|
});
|
|
|
|
// ==================== Email Sending Routes ====================
|
|
|
|
// Send email using template to event attendees (non-blocking, queued)
|
|
emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), async (c) => {
|
|
const { eventId } = c.req.param();
|
|
const user = (c as any).get('user');
|
|
const body = await c.req.json();
|
|
const { templateSlug, customVariables, recipientFilter } = body;
|
|
|
|
if (!templateSlug) {
|
|
return c.json({ error: 'Template slug is required' }, 400);
|
|
}
|
|
|
|
// Queue emails for background processing instead of sending synchronously
|
|
const result = await emailService.queueEventEmails({
|
|
eventId,
|
|
templateSlug,
|
|
customVariables,
|
|
recipientFilter: recipientFilter || 'confirmed',
|
|
sentBy: user?.id,
|
|
});
|
|
|
|
return c.json(result);
|
|
});
|
|
|
|
// Send custom email to specific recipients
|
|
emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), async (c) => {
|
|
const user = (c as any).get('user');
|
|
const body = await c.req.json();
|
|
const { to, toName, subject, bodyHtml, bodyText, eventId } = body;
|
|
|
|
if (!to || !subject || !bodyHtml) {
|
|
return c.json({ error: 'Recipient (to), subject, and bodyHtml are required' }, 400);
|
|
}
|
|
|
|
const result = await emailService.sendCustomEmail({
|
|
to,
|
|
toName,
|
|
subject,
|
|
bodyHtml,
|
|
bodyText,
|
|
eventId,
|
|
sentBy: user?.id,
|
|
});
|
|
|
|
return c.json(result);
|
|
});
|
|
|
|
// Preview email (render template without sending)
|
|
emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) => {
|
|
const body = await c.req.json();
|
|
const { templateSlug, variables, locale } = body;
|
|
|
|
if (!templateSlug) {
|
|
return c.json({ error: 'Template slug is required' }, 400);
|
|
}
|
|
|
|
const template = await emailService.getTemplate(templateSlug);
|
|
if (!template) {
|
|
return c.json({ error: 'Template not found' }, 404);
|
|
}
|
|
|
|
const { replaceTemplateVariables, wrapInBaseTemplate } = await import('../lib/emailTemplates.js');
|
|
|
|
const allVariables = {
|
|
...emailService.getCommonVariables(),
|
|
lang: locale || 'en',
|
|
...variables,
|
|
};
|
|
|
|
const subject = locale === 'es' && template.subjectEs
|
|
? template.subjectEs
|
|
: template.subject;
|
|
const bodyHtml = locale === 'es' && template.bodyHtmlEs
|
|
? template.bodyHtmlEs
|
|
: template.bodyHtml;
|
|
|
|
const finalSubject = replaceTemplateVariables(subject, allVariables);
|
|
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables);
|
|
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
|
|
|
|
return c.json({
|
|
subject: finalSubject,
|
|
bodyHtml: finalBodyHtml,
|
|
});
|
|
});
|
|
|
|
// ==================== Email Logs Routes ====================
|
|
|
|
// Get email logs
|
|
emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
|
|
const eventId = c.req.query('eventId');
|
|
const status = c.req.query('status');
|
|
const limit = parseInt(c.req.query('limit') || '50');
|
|
const offset = parseInt(c.req.query('offset') || '0');
|
|
|
|
let query = (db as any).select().from(emailLogs);
|
|
|
|
const conditions = [];
|
|
if (eventId) {
|
|
conditions.push(eq((emailLogs as any).eventId, eventId));
|
|
}
|
|
if (status) {
|
|
conditions.push(eq((emailLogs as any).status, status));
|
|
}
|
|
|
|
if (conditions.length > 0) {
|
|
query = query.where(and(...conditions));
|
|
}
|
|
|
|
const logs = await dbAll(
|
|
query
|
|
.orderBy(desc((emailLogs as any).createdAt))
|
|
.limit(limit)
|
|
.offset(offset)
|
|
);
|
|
|
|
// Get total count
|
|
let countQuery = (db as any)
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(emailLogs);
|
|
|
|
if (conditions.length > 0) {
|
|
countQuery = countQuery.where(and(...conditions));
|
|
}
|
|
|
|
const totalResult = await dbGet<any>(countQuery);
|
|
const total = totalResult?.count || 0;
|
|
|
|
return c.json({
|
|
logs,
|
|
pagination: {
|
|
total,
|
|
limit,
|
|
offset,
|
|
hasMore: offset + logs.length < total,
|
|
}
|
|
});
|
|
});
|
|
|
|
// Get single email log
|
|
emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
|
const { id } = c.req.param();
|
|
|
|
const log = await dbGet<any>(
|
|
(db as any).select().from(emailLogs).where(eq((emailLogs as any).id, id))
|
|
);
|
|
|
|
if (!log) {
|
|
return c.json({ error: 'Email log not found' }, 404);
|
|
}
|
|
|
|
return c.json({ log });
|
|
});
|
|
|
|
// Get email stats
|
|
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
|
|
const eventId = c.req.query('eventId');
|
|
|
|
let baseCondition = eventId ? eq((emailLogs as any).eventId, eventId) : undefined;
|
|
|
|
const totalQuery = baseCondition
|
|
? (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(baseCondition)
|
|
: (db as any).select({ count: sql<number>`count(*)` }).from(emailLogs);
|
|
|
|
const total = (await dbGet<any>(totalQuery))?.count || 0;
|
|
|
|
const sentCondition = baseCondition
|
|
? and(baseCondition, eq((emailLogs as any).status, 'sent'))
|
|
: eq((emailLogs as any).status, 'sent');
|
|
const sent = (await dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(sentCondition)))?.count || 0;
|
|
|
|
const failedCondition = baseCondition
|
|
? and(baseCondition, eq((emailLogs as any).status, 'failed'))
|
|
: eq((emailLogs as any).status, 'failed');
|
|
const failed = (await dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(failedCondition)))?.count || 0;
|
|
|
|
const pendingCondition = baseCondition
|
|
? and(baseCondition, eq((emailLogs as any).status, 'pending'))
|
|
: eq((emailLogs as any).status, 'pending');
|
|
const pending = (await dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(emailLogs).where(pendingCondition)))?.count || 0;
|
|
|
|
return c.json({
|
|
stats: {
|
|
total,
|
|
sent,
|
|
failed,
|
|
pending,
|
|
}
|
|
});
|
|
});
|
|
|
|
// Seed default templates (admin only)
|
|
emailsRouter.post('/seed-templates', requireAuth(['admin']), async (c) => {
|
|
await emailService.seedDefaultTemplates();
|
|
return c.json({ message: 'Default templates seeded successfully' });
|
|
});
|
|
|
|
// ==================== Configuration Routes ====================
|
|
|
|
// Get email provider info
|
|
emailsRouter.get('/config', requireAuth(['admin']), async (c) => {
|
|
const providerInfo = emailService.getProviderInfo();
|
|
return c.json(providerInfo);
|
|
});
|
|
|
|
// Test email configuration
|
|
emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
|
|
const body = await c.req.json();
|
|
const { to } = body;
|
|
|
|
if (!to) {
|
|
return c.json({ error: 'Recipient email (to) is required' }, 400);
|
|
}
|
|
|
|
const result = await emailService.testConnection(to);
|
|
return c.json(result);
|
|
});
|
|
|
|
// Get email queue status
|
|
emailsRouter.get('/queue/status', requireAuth(['admin']), async (c) => {
|
|
const status = getQueueStatus();
|
|
return c.json({ status });
|
|
});
|
|
|
|
export default emailsRouter;
|