d0c949070f
Inbound emails from some clients (Outlook, mobile) send htmlBody as base64. Decode it on the server before storing (future emails) and on the client before passing to the iframe srcDoc (existing stored emails). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
84 lines
3.3 KiB
JavaScript
84 lines
3.3 KiB
JavaScript
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
|
import { state } from '../state.js'
|
|
import { queueContactSubmissionsWrite } from '../data.js'
|
|
import { MAX_CONTACT_SUBMISSIONS } from '../config.js'
|
|
|
|
// RFC 5322 msg-id: "<" printable-ASCII-no-whitespace ">"
|
|
const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/
|
|
|
|
function decodeHtmlBody(raw) {
|
|
if (typeof raw !== 'string' || !raw.trim()) return null
|
|
const s = raw.trim()
|
|
if (s.startsWith('<')) return s // already plain HTML
|
|
try {
|
|
const decoded = Buffer.from(s.replace(/\s+/g, ''), 'base64').toString('utf8')
|
|
return decoded.trimStart().startsWith('<') ? decoded : s
|
|
} catch {
|
|
return s
|
|
}
|
|
}
|
|
|
|
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, source } = req.body ?? {}
|
|
|
|
if (!from || typeof from !== 'string') {
|
|
res.status(400).json({ message: 'Missing from address.' }); return
|
|
}
|
|
|
|
// Extract display name and email address from "Name <email>" format
|
|
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() : ''
|
|
|
|
// Deduplicate by messageId if provided
|
|
if (normalizedMessageId) {
|
|
const exists = state.contactSubmissions.some(s => s.messageId === normalizedMessageId)
|
|
if (exists) {
|
|
res.json({ ok: true, duplicate: true }); return
|
|
}
|
|
}
|
|
|
|
const submission = {
|
|
id: randomUUID(),
|
|
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: [subject ? `Subject: ${subject}` : '', body ?? ''].filter(Boolean).join('\n\n'),
|
|
htmlBody: decodeHtmlBody(htmlBody),
|
|
messageType: 'general',
|
|
subscribe: false,
|
|
archived: false,
|
|
source: 'inbound-email',
|
|
inboundTo: typeof to === 'string' ? to : '',
|
|
messageId: normalizedMessageId,
|
|
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: ${subject ?? '(none)'}`)
|
|
res.json({ ok: true })
|
|
})
|
|
}
|