- 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>
271 lines
7.9 KiB
TypeScript
271 lines
7.9 KiB
TypeScript
import { Hono } from 'hono';
|
||
import { zValidator } from '@hono/zod-validator';
|
||
import { z } from 'zod';
|
||
import { db, dbGet, dbAll, contacts, emailSubscribers, legalSettings } from '../db/index.js';
|
||
import { eq, desc } from 'drizzle-orm';
|
||
import { requireAuth } from '../lib/auth.js';
|
||
import { generateId, getNow } from '../lib/utils.js';
|
||
import { emailService } from '../lib/email.js';
|
||
|
||
const contactsRouter = new Hono();
|
||
|
||
// ==================== Sanitization Helpers ====================
|
||
|
||
/**
|
||
* Sanitize a string to prevent HTML injection
|
||
* Escapes HTML special characters
|
||
*/
|
||
function sanitizeHtml(str: string): string {
|
||
return str
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
/**
|
||
* Sanitize email header values to prevent email header injection
|
||
* Strips newlines and carriage returns that could be used to inject headers
|
||
*/
|
||
function sanitizeHeaderValue(str: string): string {
|
||
return str.replace(/[\r\n]/g, '').trim();
|
||
}
|
||
|
||
const createContactSchema = z.object({
|
||
name: z.string().min(2),
|
||
email: z.string().email(),
|
||
message: z.string().min(10),
|
||
});
|
||
|
||
const subscribeSchema = z.object({
|
||
email: z.string().email(),
|
||
name: z.string().optional(),
|
||
});
|
||
|
||
const updateContactSchema = z.object({
|
||
status: z.enum(['new', 'read', 'replied']),
|
||
});
|
||
|
||
// Submit contact form (public)
|
||
contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
|
||
const data = c.req.valid('json');
|
||
const now = getNow();
|
||
const id = generateId();
|
||
|
||
// Sanitize header-sensitive values to prevent email header injection
|
||
const sanitizedEmail = sanitizeHeaderValue(data.email);
|
||
const sanitizedName = sanitizeHeaderValue(data.name);
|
||
|
||
const newContact = {
|
||
id,
|
||
name: sanitizedName,
|
||
email: sanitizedEmail,
|
||
message: data.message,
|
||
status: 'new' as const,
|
||
createdAt: now,
|
||
};
|
||
|
||
// Always store the message in admin, regardless of email outcome
|
||
await (db as any).insert(contacts).values(newContact);
|
||
|
||
// Send email notification to support email (non-blocking)
|
||
try {
|
||
// Retrieve support_email from legal_settings
|
||
const settings = await dbGet<any>(
|
||
(db as any).select().from(legalSettings).limit(1)
|
||
);
|
||
|
||
const supportEmail = settings?.supportEmail;
|
||
|
||
if (supportEmail) {
|
||
const websiteUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
|
||
|
||
// Sanitize all values for HTML display
|
||
const safeName = sanitizeHtml(sanitizedName);
|
||
const safeEmail = sanitizeHtml(sanitizedEmail);
|
||
const safeMessage = sanitizeHtml(data.message);
|
||
|
||
const subject = `New Contact Form Message – ${websiteUrl}`;
|
||
|
||
const bodyHtml = `
|
||
<p><strong>${safeName}</strong> (${safeEmail}) sent a message:</p>
|
||
<div style="padding: 16px 20px; background-color: #f8fafc; border-left: 4px solid #3b82f6; margin: 16px 0; white-space: pre-wrap; font-size: 15px; line-height: 1.6;">${safeMessage}</div>
|
||
<p style="color: #64748b; font-size: 13px;">Reply directly to this email to respond to ${safeName}.</p>
|
||
`;
|
||
|
||
const bodyText = [
|
||
`${sanitizedName} (${sanitizedEmail}) sent a message:`,
|
||
'',
|
||
data.message,
|
||
'',
|
||
`Reply directly to this email to respond to ${sanitizedName}.`,
|
||
].join('\n');
|
||
|
||
const emailResult = await emailService.sendCustomEmail({
|
||
to: supportEmail,
|
||
subject,
|
||
bodyHtml,
|
||
bodyText,
|
||
replyTo: sanitizedEmail,
|
||
});
|
||
|
||
if (!emailResult.success) {
|
||
console.error('[Contact Form] Failed to send email notification:', emailResult.error);
|
||
}
|
||
} else {
|
||
console.warn('[Contact Form] No support email configured in legal settings – skipping email notification');
|
||
}
|
||
} catch (emailError: any) {
|
||
// Log the error but do NOT break the contact form UX
|
||
console.error('[Contact Form] Error sending email notification:', emailError?.message || emailError);
|
||
}
|
||
|
||
return c.json({ message: 'Message sent successfully' }, 201);
|
||
});
|
||
|
||
// Subscribe to newsletter (public)
|
||
contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c) => {
|
||
const data = c.req.valid('json');
|
||
|
||
// Check if already subscribed
|
||
const existing = await dbGet<any>(
|
||
(db as any)
|
||
.select()
|
||
.from(emailSubscribers)
|
||
.where(eq((emailSubscribers as any).email, data.email))
|
||
);
|
||
|
||
if (existing) {
|
||
if (existing.status === 'unsubscribed') {
|
||
// Resubscribe
|
||
await (db as any)
|
||
.update(emailSubscribers)
|
||
.set({ status: 'active' })
|
||
.where(eq((emailSubscribers as any).id, existing.id));
|
||
|
||
return c.json({ message: 'Successfully resubscribed' });
|
||
}
|
||
return c.json({ message: 'Already subscribed' });
|
||
}
|
||
|
||
const now = getNow();
|
||
const id = generateId();
|
||
|
||
const newSubscriber = {
|
||
id,
|
||
email: data.email,
|
||
name: data.name || null,
|
||
status: 'active' as const,
|
||
createdAt: now,
|
||
};
|
||
|
||
await (db as any).insert(emailSubscribers).values(newSubscriber);
|
||
|
||
return c.json({ message: 'Successfully subscribed' }, 201);
|
||
});
|
||
|
||
// Unsubscribe from newsletter (public)
|
||
contactsRouter.post('/unsubscribe', zValidator('json', z.object({ email: z.string().email() })), async (c) => {
|
||
const { email } = c.req.valid('json');
|
||
|
||
const existing = await dbGet<any>(
|
||
(db as any).select().from(emailSubscribers).where(eq((emailSubscribers as any).email, email))
|
||
);
|
||
|
||
if (!existing) {
|
||
return c.json({ error: 'Email not found' }, 404);
|
||
}
|
||
|
||
await (db as any)
|
||
.update(emailSubscribers)
|
||
.set({ status: 'unsubscribed' })
|
||
.where(eq((emailSubscribers as any).id, existing.id));
|
||
|
||
return c.json({ message: 'Successfully unsubscribed' });
|
||
});
|
||
|
||
// Get all contacts (admin)
|
||
contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||
const status = c.req.query('status');
|
||
|
||
let query = (db as any).select().from(contacts);
|
||
|
||
if (status) {
|
||
query = query.where(eq((contacts as any).status, status));
|
||
}
|
||
|
||
const result = await dbAll(query.orderBy(desc((contacts as any).createdAt)));
|
||
|
||
return c.json({ contacts: result });
|
||
});
|
||
|
||
// Get single contact (admin)
|
||
contactsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
|
||
const id = c.req.param('id');
|
||
|
||
const contact = await dbGet(
|
||
(db as any)
|
||
.select()
|
||
.from(contacts)
|
||
.where(eq((contacts as any).id, id))
|
||
);
|
||
|
||
if (!contact) {
|
||
return c.json({ error: 'Contact not found' }, 404);
|
||
}
|
||
|
||
return c.json({ contact });
|
||
});
|
||
|
||
// Update contact status (admin)
|
||
contactsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updateContactSchema), async (c) => {
|
||
const id = c.req.param('id');
|
||
const data = c.req.valid('json');
|
||
|
||
const existing = await dbGet<any>(
|
||
(db as any).select().from(contacts).where(eq((contacts as any).id, id))
|
||
);
|
||
|
||
if (!existing) {
|
||
return c.json({ error: 'Contact not found' }, 404);
|
||
}
|
||
|
||
await (db as any)
|
||
.update(contacts)
|
||
.set({ status: data.status })
|
||
.where(eq((contacts as any).id, id));
|
||
|
||
const updated = await dbGet<any>(
|
||
(db as any).select().from(contacts).where(eq((contacts as any).id, id))
|
||
);
|
||
|
||
return c.json({ contact: updated });
|
||
});
|
||
|
||
// Delete contact (admin)
|
||
contactsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
||
const id = c.req.param('id');
|
||
|
||
await (db as any).delete(contacts).where(eq((contacts as any).id, id));
|
||
|
||
return c.json({ message: 'Contact deleted successfully' });
|
||
});
|
||
|
||
// Get all subscribers (admin)
|
||
contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), async (c) => {
|
||
const status = c.req.query('status');
|
||
|
||
let query = (db as any).select().from(emailSubscribers);
|
||
|
||
if (status) {
|
||
query = query.where(eq((emailSubscribers as any).status, status));
|
||
}
|
||
|
||
const result = await dbAll(query.orderBy(desc((emailSubscribers as any).createdAt)));
|
||
|
||
return c.json({ subscribers: result });
|
||
});
|
||
|
||
export default contactsRouter;
|