Fix inbound-email data loss, MIME truncation, and header injection
- server/data.js: preserve source/htmlBody/inboundTo/messageId across server restarts (sanitizeLoadedContactSubmissions was silently dropping them on reload from disk) - cloudflare/email-worker.js: rewrite MIME parsing to split on the actual boundary marker instead of any literal "--", unfold multi-line headers, and correctly recombine multi-byte UTF-8 in quoted-printable decoding - server/routes/inbound-email.js: validate Message-ID against RFC 5322 grammar before storing/using it, and compare the webhook secret with timingSafeEqual to match the rest of the codebase's auth checks - server/routes/contact.js: re-validate messageId at the point it's injected into outgoing In-Reply-To/References headers; move the allowed reply-from addresses into a shared config constant - src/AdminPage.tsx: 30s inbox poll now syncs field updates (e.g. archived) on already-loaded submissions instead of only appending new ones; consolidate the duplicated from-address list - .claude/launch.json: add a vite dev server preview config used to verify these changes Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+100
-35
@@ -60,23 +60,7 @@ async function streamToText(stream) {
|
||||
}
|
||||
|
||||
function parseEmail(raw, message) {
|
||||
const lines = raw.split(/\r?\n/)
|
||||
|
||||
// Parse headers (everything before the first blank line)
|
||||
const headers = {}
|
||||
let bodyStart = 0
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim() === '') {
|
||||
bodyStart = i + 1
|
||||
break
|
||||
}
|
||||
const colon = lines[i].indexOf(':')
|
||||
if (colon > 0) {
|
||||
const key = lines[i].slice(0, colon).trim().toLowerCase()
|
||||
const val = lines[i].slice(colon + 1).trim()
|
||||
if (!headers[key]) headers[key] = val
|
||||
}
|
||||
}
|
||||
const { headers, body } = splitHeadersAndBody(raw)
|
||||
|
||||
const subject = decodeHeaderValue(headers['subject'] ?? '(no subject)')
|
||||
const from = message.from ?? headers['from'] ?? ''
|
||||
@@ -84,9 +68,7 @@ function parseEmail(raw, message) {
|
||||
const date = headers['date'] ?? new Date().toISOString()
|
||||
const messageId = headers['message-id'] ?? ''
|
||||
|
||||
const bodyLines = lines.slice(bodyStart)
|
||||
const plainText = extractPart(raw, bodyLines, 'text/plain')
|
||||
const htmlBody = extractPart(raw, bodyLines, 'text/html')
|
||||
const { plainText, htmlBody } = extractBodies(headers['content-type'] ?? '', body)
|
||||
|
||||
return {
|
||||
from,
|
||||
@@ -100,28 +82,111 @@ function parseEmail(raw, message) {
|
||||
}
|
||||
}
|
||||
|
||||
function extractPart(raw, bodyLines, contentType) {
|
||||
const escaped = contentType.replace('/', '\\/')
|
||||
const re = new RegExp(`Content-Type: ${escaped}[^\\r\\n]*\\r?\\n(?:[^\\r\\n]+\\r?\\n)*\\r?\\n([\\s\\S]*?)(?=--|$)`, 'i')
|
||||
const match = re.exec(raw)
|
||||
if (match) return decodeEmailBody(match[1]).trim()
|
||||
// Splits an RFC 5322 message (or MIME part) into its unfolded header map and raw body string.
|
||||
function splitHeadersAndBody(raw) {
|
||||
const match = /\r?\n\r?\n/.exec(raw)
|
||||
const headerBlock = match ? raw.slice(0, match.index) : raw
|
||||
const body = match ? raw.slice(match.index + match[0].length) : ''
|
||||
// RFC 2822 header folding: a line starting with SP/TAB continues the previous header line.
|
||||
const unfolded = headerBlock.replace(/\r?\n[ \t]+/g, ' ')
|
||||
const headers = {}
|
||||
for (const line of unfolded.split(/\r?\n/)) {
|
||||
const colon = line.indexOf(':')
|
||||
if (colon <= 0) continue
|
||||
const key = line.slice(0, colon).trim().toLowerCase()
|
||||
const val = line.slice(colon + 1).trim()
|
||||
if (!headers[key]) headers[key] = val
|
||||
}
|
||||
return { headers, body }
|
||||
}
|
||||
|
||||
if (contentType === 'text/plain') {
|
||||
const joined = bodyLines.join('\n')
|
||||
if (/<[a-z][\s\S]*>/i.test(joined)) {
|
||||
return joined.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/\s{3,}/g, '\n\n').trim()
|
||||
// Recursively walks a (possibly multipart) MIME body and returns the first text/plain and text/html parts found.
|
||||
function extractBodies(topContentType, topBody) {
|
||||
let plainText = null
|
||||
let htmlBody = null
|
||||
|
||||
function visit(contentType, body, transferEncoding) {
|
||||
const type = (contentType.split(';')[0] || 'text/plain').trim().toLowerCase()
|
||||
|
||||
if (type.startsWith('multipart/')) {
|
||||
const boundary = getBoundary(contentType)
|
||||
if (!boundary) return
|
||||
for (const part of splitOnBoundary(body, boundary)) {
|
||||
const { headers, body: partBody } = splitHeadersAndBody(part)
|
||||
visit(headers['content-type'] ?? 'text/plain', partBody, headers['content-transfer-encoding'] ?? '')
|
||||
}
|
||||
return
|
||||
}
|
||||
return joined.trim()
|
||||
|
||||
const decoded = decodeBody(body, transferEncoding)
|
||||
if (type === 'text/html' && htmlBody === null) htmlBody = decoded.trim()
|
||||
if (type === 'text/plain' && plainText === null) plainText = decoded.trim()
|
||||
}
|
||||
|
||||
return null
|
||||
visit(topContentType || 'text/plain', topBody, '')
|
||||
|
||||
if (plainText === null && htmlBody !== null) {
|
||||
plainText = htmlBody.replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/\s{3,}/g, '\n\n').trim()
|
||||
}
|
||||
if (plainText === null) plainText = ''
|
||||
|
||||
return { plainText, htmlBody }
|
||||
}
|
||||
|
||||
function getBoundary(contentType) {
|
||||
const match = /boundary\s*=\s*"([^"]+)"|boundary\s*=\s*([^;\s]+)/i.exec(contentType)
|
||||
if (!match) return null
|
||||
return match[1] ?? match[2]
|
||||
}
|
||||
|
||||
// Splits a multipart body on its boundary markers, ignoring the preamble/epilogue.
|
||||
function splitOnBoundary(body, boundary) {
|
||||
const escaped = boundary.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const re = new RegExp(`(?:^|\\r?\\n)--${escaped}(--)?(?:\\r?\\n|$)`, 'g')
|
||||
const parts = []
|
||||
let lastIndex = 0
|
||||
let started = false
|
||||
let match
|
||||
while ((match = re.exec(body)) !== null) {
|
||||
if (started) parts.push(body.slice(lastIndex, match.index))
|
||||
started = true
|
||||
lastIndex = match.index + match[0].length
|
||||
if (match[1]) break // final boundary: "--boundary--"
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
function decodeBody(text, transferEncoding) {
|
||||
const enc = (transferEncoding || '').trim().toLowerCase()
|
||||
if (enc === 'base64') {
|
||||
try {
|
||||
const binary = atob(text.replace(/\s+/g, ''))
|
||||
return new TextDecoder('utf-8').decode(Uint8Array.from(binary, c => c.charCodeAt(0)))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
if (enc === 'quoted-printable') {
|
||||
return decodeEmailBody(text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function decodeEmailBody(text) {
|
||||
// Handle quoted-printable encoding (=XX hex sequences and soft line breaks)
|
||||
return text
|
||||
.replace(/=\r?\n/g, '')
|
||||
.replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
||||
// Handle quoted-printable encoding (=XX hex sequences and soft line breaks).
|
||||
// Decode into raw bytes first, then run through TextDecoder so multi-byte
|
||||
// UTF-8 sequences split across multiple =XX escapes recombine correctly.
|
||||
const unfolded = text.replace(/=\r?\n/g, '')
|
||||
const bytes = []
|
||||
for (let i = 0; i < unfolded.length; i++) {
|
||||
if (unfolded[i] === '=' && /^[0-9A-Fa-f]{2}$/.test(unfolded.slice(i + 1, i + 3))) {
|
||||
bytes.push(parseInt(unfolded.slice(i + 1, i + 3), 16))
|
||||
i += 2
|
||||
} else {
|
||||
bytes.push(unfolded.charCodeAt(i))
|
||||
}
|
||||
}
|
||||
return new TextDecoder('utf-8').decode(Uint8Array.from(bytes))
|
||||
}
|
||||
|
||||
function decodeHeaderValue(value) {
|
||||
|
||||
Reference in New Issue
Block a user