Files
Siteforge/cloudflare/email-worker.js
T
nmemmert 17c9cbbc8b 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>
2026-07-02 08:21:50 -04:00

209 lines
7.1 KiB
JavaScript

/**
* Cloudflare Email Worker — versebyversewithnate.us
*
* Receives inbound emails to hello@ and nate@, forwards to Gmail,
* and POSTs a parsed copy to the site admin inbox.
*
* CLOUDFLARE SETUP:
* 1. Go to Email > Email Routing > Routes in your Cloudflare dashboard
* 2. Add two "Custom address" rules:
* hello@versebyversewithnate.us → Send to Worker → this worker
* nate@versebyversewithnate.us → Send to Worker → this worker
* 3. Make sure nmemmert@gmail.com is listed as a verified destination address
* (Email Routing > Destination addresses)
*
* WORKER ENVIRONMENT VARIABLES (Workers & Pages > this worker > Settings > Variables):
* GMAIL_FORWARD_ADDRESS = nmemmert@gmail.com
* WEBHOOK_URL = https://versebyversewithnate.us/api/inbound-email
* WEBHOOK_SECRET = <a long random string — must match INBOUND_EMAIL_SECRET on the server>
*
* SERVER ENVIRONMENT VARIABLE (set in your server host / .env):
* INBOUND_EMAIL_SECRET = <same long random string as WEBHOOK_SECRET above>
*/
export default {
async email(message, env, ctx) {
const forwardPromise = message.forward(env.GMAIL_FORWARD_ADDRESS)
const rawEmail = await streamToText(message.raw)
const parsed = parseEmail(rawEmail, message)
const webhookPromise = fetch(env.WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Secret': env.WEBHOOK_SECRET,
},
body: JSON.stringify(parsed),
}).catch(err => console.error('[email-worker] webhook failed:', err.message))
await Promise.all([forwardPromise, webhookPromise])
},
}
async function streamToText(stream) {
const reader = stream.getReader()
const chunks = []
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
}
const bytes = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0))
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.length
}
return new TextDecoder().decode(bytes)
}
function parseEmail(raw, message) {
const { headers, body } = splitHeadersAndBody(raw)
const subject = decodeHeaderValue(headers['subject'] ?? '(no subject)')
const from = message.from ?? headers['from'] ?? ''
const to = message.to ?? headers['to'] ?? ''
const date = headers['date'] ?? new Date().toISOString()
const messageId = headers['message-id'] ?? ''
const { plainText, htmlBody } = extractBodies(headers['content-type'] ?? '', body)
return {
from,
to,
subject,
body: plainText,
htmlBody: htmlBody || null,
date,
messageId,
source: 'inbound-email',
}
}
// 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 }
}
// 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
}
const decoded = decodeBody(body, transferEncoding)
if (type === 'text/html' && htmlBody === null) htmlBody = decoded.trim()
if (type === 'text/plain' && plainText === null) plainText = decoded.trim()
}
visit(topContentType || 'text/plain', topBody, '')
if (plainText === null && htmlBody !== null) {
plainText = htmlBody.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/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).
// 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) {
// Handle encoded words: =?UTF-8?B?...?= or =?UTF-8?Q?...?=
return value.replace(/=\?([^?]+)\?([BQ])\?([^?]*)\?=/gi, (_, charset, encoding, encoded) => {
try {
if (encoding.toUpperCase() === 'B') {
const binary = atob(encoded)
return new TextDecoder(charset).decode(Uint8Array.from(binary, c => c.charCodeAt(0)))
}
if (encoding.toUpperCase() === 'Q') {
return encoded.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
}
} catch {
return encoded
}
return encoded
})
}