contact.ts103 lines · main
1import { ValidationError } from '@briven/shared';
2import { Hono } from 'hono';
3import { z } from 'zod';
4
5import { contactTopics } from '../db/schema.js';
6import { sendTicketCreatedConfirmation } from '../lib/email.js';
7import { log } from '../lib/logger.js';
8import { ipKey, rateLimit } from '../middleware/rate-limit.js';
9import { audit, hashIp } from '../services/audit.js';
10import { createContactMessage } from '../services/contact.js';
11import { renderTicketNumber } from '../services/support-tickets.js';
12import type { AppEnv } from '../types/app-env.js';
13
14/**
15 * Public, unauthenticated contact intake. Lives on its own Hono router
16 * (no requireAuth) so anyone can reach it from the /contact marketing
17 * page. Rate-limited by IP (5/hour) to keep the endpoint from being a
18 * spam vector. The sender's email is collected + stored so an operator
19 * can reply privately — it is never echoed back in the response.
20 */
21export const contactPublicRouter = new Hono<AppEnv>();
22
23export const contactSchema = z.object({
24 name: z.string().trim().min(1).max(200),
25 email: z.string().trim().min(1).max(320).email(),
26 topic: z.enum(contactTopics),
27 // Free-text "what's this about" line. Optional — the topic-only flow
28 // and older clients submit without it.
29 subject: z.string().trim().max(200).optional(),
30 message: z.string().trim().min(1).max(8000),
31 // Visitor country auto-detected on the /contact page (locked field).
32 // Optional + capped; a hint for the operator, never a gate.
33 country: z.string().trim().max(100).optional(),
34});
35
36contactPublicRouter.post(
37 '/v1/contact',
38 rateLimit({
39 scope: 'contact-public',
40 limit: 5,
41 windowMs: 60 * 60_000,
42 key: ipKey,
43 }),
44 async (c) => {
45 const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null;
46 const parsed = contactSchema.safeParse(body);
47 if (!parsed.success) {
48 return c.json(
49 { code: 'validation_failed', message: parsed.error.issues[0]?.message ?? 'invalid body' },
50 400,
51 );
52 }
53 try {
54 const { id: requestId, ticketNumber } = await createContactMessage({
55 name: parsed.data.name,
56 email: parsed.data.email,
57 topic: parsed.data.topic,
58 subject: parsed.data.subject ?? null,
59 message: parsed.data.message,
60 country: parsed.data.country ?? null,
61 ipHash: hashIpFromReq(c.req.raw.headers.get('x-forwarded-for')),
62 userAgent: c.req.header('user-agent') ?? null,
63 });
64 const rendered = renderTicketNumber(ticketNumber);
65 await audit({
66 actorId: null,
67 projectId: null,
68 action: 'contact_message.public_create',
69 ipHash: hashIpFromReq(c.req.raw.headers.get('x-forwarded-for')),
70 userAgent: c.req.header('user-agent') ?? null,
71 metadata: { requestId, topic: parsed.data.topic, ticketNumber: rendered },
72 });
73 // Confirmation email with the ticket number — only for tagged
74 // submissions that became a ticket. Fire-and-forget so a slow
75 // mittera never blocks the public POST response.
76 if (ticketNumber && rendered) {
77 void sendTicketCreatedConfirmation(parsed.data.email, rendered).catch((err) => {
78 log.error('ticket_created_email_failed', {
79 requestId,
80 error: err instanceof Error ? err.message : String(err),
81 });
82 });
83 }
84 // We deliberately return only the reference id + the public ticket
85 // number — no other PII echo to a public endpoint, and never the
86 // email back to a curl caller.
87 return c.json({ requestId, ticketNumber: rendered }, 201);
88 } catch (err) {
89 if (err instanceof ValidationError) {
90 return c.json({ code: 'validation_failed', message: err.message }, 400);
91 }
92 log.error('contact_message_create_failed', {
93 error: err instanceof Error ? err.message : String(err),
94 });
95 throw err;
96 }
97 },
98);
99
100function hashIpFromReq(forwarded: string | null): string | null {
101 const ip = forwarded ? forwarded.split(',')[0]!.trim() : null;
102 return hashIp(ip);
103}