Files
Spanglish/backend/src/routes/contacts.ts
Michilis b9f46b02cc Email queue + async sending; legal settings and placeholders
- 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>
2026-02-12 21:03:49 +00:00

271 lines
7.9 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 } 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
/**
* 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;