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, sanitizeHtml } from '../lib/utils.js'; import { emailService } from '../lib/email.js'; import { rateLimitMiddleware } from '../lib/rateLimit.js'; const contactsRouter = new Hono(); // Per-IP rate limit for public, unauthenticated write endpoints (contact form, // newsletter subscribe/unsubscribe) to prevent spam and email flooding. const publicFormLimit = rateLimitMiddleware({ max: 5, windowMs: 10 * 60 * 1000, prefix: 'contacts' }); // ==================== Sanitization Helpers ==================== /** * 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).max(200), email: z.string().email().max(254), message: z.string().min(10).max(5000), }); const subscribeSchema = z.object({ email: z.string().email().max(254), name: z.string().max(200).optional(), }); const updateContactSchema = z.object({ status: z.enum(['new', 'read', 'replied']), }); // Submit contact form (public) contactsRouter.post('/', publicFormLimit, 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', publicFormLimit, 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', publicFormLimit, zValidator('json', z.object({ email: z.string().email().max(254) })), 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)) ); // Always return the same response whether or not the address exists, to avoid // leaking which emails are subscribed (enumeration). if (existing && existing.status !== 'unsubscribed') { await (db as any) .update(emailSubscribers) .set({ status: 'unsubscribed' }) .where(eq((emailSubscribers as any).id, existing.id)); } return c.json({ message: 'If this email was subscribed, it has been unsubscribed.' }); }); // Get all contacts (admin) contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => { const status = c.req.query('status'); const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '100', 10) || 100, 1), 500); const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0); 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)).limit(limit).offset(offset) ); 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'); const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '100', 10) || 100, 1), 1000); const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0); 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)).limit(limit).offset(offset) ); return c.json({ subscribers: result }); }); export default contactsRouter;