/** * 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 = * * SERVER ENVIRONMENT VARIABLE (set in your server host / .env): * INBOUND_EMAIL_SECRET = */ 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(/ /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). // 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 }) }