Files
Spanglish/backend/src/routes/contacts.ts
T
MichilisandCursor 613bd7be1d Refactor monolithic modules and harden booking, email, and auth infrastructure.
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>
2026-06-25 07:12:59 +00:00

271 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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', publicFormLimit, 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', publicFormLimit, zValidator('json', z.object({ email: z.string().email().max(254) })), 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))
);
// 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<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');
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;