Add inbound email capture via Cloudflare Email Worker
- Cloudflare Email Worker forwards hello@ and nate@ to Gmail and POSTs parsed MIME email to /api/inbound-email as admin inbox entries - New server route authenticates via shared secret and deduplicates by Message-ID before storing inbound emails as contact submissions - Admin inbox shows "email" badge for inbound messages; reply composer opens blank with auto-subject "Re: [original]" and signature preview - Documents setup steps in cloudflare/email-worker.js and .env.example Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 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 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 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'] ?? ''
|
||||
|
||||
// Extract plain text body — skip MIME boundaries and HTML parts
|
||||
const bodyLines = lines.slice(bodyStart)
|
||||
const plainText = extractPlainText(raw, bodyLines)
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
body: plainText,
|
||||
date,
|
||||
messageId,
|
||||
source: 'inbound-email',
|
||||
}
|
||||
}
|
||||
|
||||
function extractPlainText(raw, bodyLines) {
|
||||
// Look for Content-Type: text/plain section in multipart emails
|
||||
const textPlainMatch = /Content-Type: text\/plain[^\r\n]*\r?\n(?:[^\r\n]+\r?\n)*\r?\n([\s\S]*?)(?=--|\z)/i.exec(raw)
|
||||
if (textPlainMatch) {
|
||||
return decodeEmailBody(textPlainMatch[1]).trim()
|
||||
}
|
||||
|
||||
// Fallback: join body lines, strip HTML tags if present
|
||||
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()
|
||||
}
|
||||
|
||||
return joined.trim()
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user