import { randomUUID, timingSafeEqual } from 'node:crypto' import { state } from '../state.js' import { queueContactSubmissionsWrite } from '../data.js' import { MAX_CONTACT_SUBMISSIONS } from '../config.js' const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/ const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024 // 5 MB total per email function decodeHtmlBody(raw) { if (typeof raw !== 'string' || !raw.trim()) return null const s = raw.trim() if (s.startsWith('<')) return s try { const decoded = Buffer.from(s.replace(/\s+/g, ''), 'base64').toString('utf8') return decoded.trimStart().startsWith('<') ? decoded : s } catch { return s } } function normalizeSubject(subject) { return (subject ?? '').toLowerCase().replace(/^(re|fwd?):\s*/i, '').trim() } function resolveThreadId(fromEmail, normalizedSubject, inReplyTo) { // 1. Exact In-Reply-To match if (inReplyTo) { const parent = state.contactSubmissions.find(s => s.messageId === inReplyTo) if (parent?.threadId) return parent.threadId } // 2. Same sender + matching subject within 30 days const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000 const match = state.contactSubmissions.find(s => s.email === fromEmail && normalizedSubject && normalizeSubject(s.message.match(/^Subject:\s*(.+)/m)?.[1] ?? '') === normalizedSubject && new Date(s.submittedAt).getTime() > cutoff, ) if (match?.threadId) return match.threadId // 3. New thread return randomUUID() } function parseAttachments(raw) { if (!Array.isArray(raw)) return [] let totalBytes = 0 const out = [] for (const a of raw.slice(0, 10)) { if (!a || typeof a.filename !== 'string') continue const dataStr = typeof a.content === 'string' ? a.content : '' const size = typeof a.size === 'number' ? a.size : Math.floor(dataStr.length * 0.75) if (totalBytes + size > MAX_ATTACHMENT_BYTES) continue totalBytes += size out.push({ id: randomUUID(), filename: a.filename.slice(0, 255), contentType: typeof a.contentType === 'string' ? a.contentType : 'application/octet-stream', size, data: dataStr, }) } return out } export function register(app) { app.post('/api/inbound-email', (req, res) => { const secret = process.env.INBOUND_EMAIL_SECRET if (!secret) { res.status(503).json({ message: 'Inbound email not configured.' }); return } const provided = req.get('x-webhook-secret') ?? '' const a = Buffer.from(provided, 'utf8') const b = Buffer.from(secret, 'utf8') if (!provided || a.length !== b.length || !timingSafeEqual(a, b)) { res.status(401).json({ message: 'Unauthorized.' }); return } const { from, to, subject, body, htmlBody, date, messageId, inReplyTo, attachments: rawAttachments, source } = req.body ?? {} if (!from || typeof from !== 'string') { res.status(400).json({ message: 'Missing from address.' }); return } const fromMatch = /^(.*?)\s*<([^>]+)>$/.exec(from.trim()) const fromEmail = fromMatch ? fromMatch[2].trim() : from.trim() const fromName = fromMatch ? fromMatch[1].trim() : from.trim() const normalizedMessageId = typeof messageId === 'string' && MESSAGE_ID_RE.test(messageId.trim()) ? messageId.trim() : '' const normalizedInReplyTo = typeof inReplyTo === 'string' && MESSAGE_ID_RE.test(inReplyTo.trim()) ? inReplyTo.trim() : '' if (normalizedMessageId) { const exists = state.contactSubmissions.some(s => s.messageId === normalizedMessageId) if (exists) { res.json({ ok: true, duplicate: true }); return } } const subjectStr = typeof subject === 'string' ? subject.trim() : '' const threadId = resolveThreadId(fromEmail, normalizeSubject(subjectStr), normalizedInReplyTo) const submission = { id: randomUUID(), threadId, submittedAt: (typeof date === 'string' || typeof date === 'number') && Number.isFinite(Date.parse(date)) ? new Date(date).toISOString() : new Date().toISOString(), name: fromName || fromEmail, email: fromEmail, message: [subjectStr ? `Subject: ${subjectStr}` : '', body ?? ''].filter(Boolean).join('\n\n'), htmlBody: decodeHtmlBody(htmlBody), messageType: 'general', subscribe: false, archived: false, starred: false, snoozedUntil: null, source: 'inbound-email', inboundTo: typeof to === 'string' ? to : '', messageId: normalizedMessageId, attachments: parseAttachments(rawAttachments), emailStatus: { welcome: { status: 'not-applicable', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null }, adminNotification: { status: 'not-applicable', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null }, adminReply: { status: 'pending', lastEventAt: null, lastEventType: null, resendEmailId: null, error: null }, }, } state.contactSubmissions.unshift(submission) state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS) queueContactSubmissionsWrite() console.log(`[inbound-email] received from ${fromEmail} — subject: ${subjectStr || '(none)'} — thread: ${threadId}`) res.json({ ok: true }) }) }