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, '''); } /** * 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( (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 = `

${safeName} (${safeEmail}) sent a message:

${safeMessage}

Reply directly to this email to respond to ${safeName}.

`; 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( (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( (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( (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( (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;