diff --git a/.claude/launch.json b/.claude/launch.json index 1d748f3..8691f71 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -7,6 +7,13 @@ "runtimeArgs": ["--env-file=.env", "server.js"], "port": 4173, "autoPort": false + }, + { + "name": "siteforge-vite", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5173, + "autoPort": false } ] } diff --git a/cloudflare/email-worker.js b/cloudflare/email-worker.js index 0242796..229ecd2 100644 --- a/cloudflare/email-worker.js +++ b/cloudflare/email-worker.js @@ -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) { diff --git a/server/config.js b/server/config.js index 55ba9d0..20c1e24 100644 --- a/server/config.js +++ b/server/config.js @@ -85,6 +85,9 @@ export const DEFAULT_RESEND_FROM = 'Verse by Verse with Nate options in src/AdminPage.tsx. +export const ADMIN_REPLY_FROM_OPTIONS = [DEFAULT_RESEND_FROM, NATE_RESEND_FROM] export const DEFAULT_SEO = { title: 'Verse by Verse with Nate', diff --git a/server/data.js b/server/data.js index 9ef97b9..c1ca487 100644 --- a/server/data.js +++ b/server/data.js @@ -1048,6 +1048,10 @@ function sanitizeLoadedContactSubmissions(value) { subscribe: entry.subscribe === true, archived: entry.archived === true, emailStatus: normalizeContactEmailStatus(entry.emailStatus, entry.subscribe === true), + source: entry.source === 'inbound-email' || entry.source === 'download' ? entry.source : 'contact-form', + htmlBody: typeof entry.htmlBody === 'string' && entry.htmlBody.trim() ? entry.htmlBody : null, + inboundTo: typeof entry.inboundTo === 'string' ? entry.inboundTo : '', + messageId: typeof entry.messageId === 'string' ? entry.messageId : '', })) } diff --git a/server/routes/contact.js b/server/routes/contact.js index 99d0a2b..5392d0d 100644 --- a/server/routes/contact.js +++ b/server/routes/contact.js @@ -8,6 +8,7 @@ import { USE_RESEND_AUTOMATION_WELCOME, DEFAULT_SEO, ADMIN_REPLY_FROM, + ADMIN_REPLY_FROM_OPTIONS, } from '../config.js' import { state } from '../state.js' import { @@ -39,6 +40,9 @@ import { syncContactToResend, } from '../email.js' +// RFC 5322 msg-id: "<" printable-ASCII-no-whitespace ">" +const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/ + function upsertContactEmailStatus(submissionId, stream, patch) { if (!submissionId || typeof submissionId !== 'string') return if (!stream || typeof stream !== 'string') return @@ -479,11 +483,7 @@ export function register(app) { const html = buildAdminReplyTemplate({ recipientName, message }) const replyToAddress = getResendReplyToAddress() const defaultFrom = getResendFromAddress() || ADMIN_REPLY_FROM - const ALLOWED_FROM = [ - 'Verse by Verse with Nate ', - 'Verse by Verse with Nate ', - ] - const fromAddress = ALLOWED_FROM.includes(requestedFrom) ? requestedFrom : defaultFrom + const fromAddress = ADMIN_REPLY_FROM_OPTIONS.includes(requestedFrom) ? requestedFrom : defaultFrom const text = `Hi ${recipientName},\n\n${message}\n\nGrace and peace,\nVerse by Verse with Nate\n${replyToAddress}` const resend = new Resend(process.env.RESEND_API_KEY) @@ -502,7 +502,7 @@ export function register(app) { ], headers: { 'X-Contact-Submission-Id': submission.id, - ...(submission.messageId ? { + ...(typeof submission.messageId === 'string' && MESSAGE_ID_RE.test(submission.messageId) ? { 'In-Reply-To': submission.messageId, 'References': submission.messageId, } : {}), diff --git a/server/routes/inbound-email.js b/server/routes/inbound-email.js index e89e689..04803f4 100644 --- a/server/routes/inbound-email.js +++ b/server/routes/inbound-email.js @@ -1,8 +1,11 @@ -import { randomUUID } from 'node:crypto' +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]+>$/ + export function register(app) { app.post('/api/inbound-email', (req, res) => { const secret = process.env.INBOUND_EMAIL_SECRET @@ -11,7 +14,9 @@ export function register(app) { } const provided = req.get('x-webhook-secret') ?? '' - if (!provided || provided !== 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 } @@ -26,9 +31,11 @@ export function register(app) { 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 (messageId && typeof messageId === 'string' && messageId.trim()) { - const exists = state.contactSubmissions.some(s => s.messageId === messageId.trim()) + if (normalizedMessageId) { + const exists = state.contactSubmissions.some(s => s.messageId === normalizedMessageId) if (exists) { res.json({ ok: true, duplicate: true }); return } @@ -46,7 +53,7 @@ export function register(app) { archived: false, source: 'inbound-email', inboundTo: typeof to === 'string' ? to : '', - messageId: typeof messageId === 'string' ? messageId.trim() : '', + 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 }, diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 2fe4ce2..ce6bf2e 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -10,6 +10,12 @@ import { AnalyticsPanel } from './components/AnalyticsPanel' import { AdminCollapsibleCard } from './components/AdminCollapsibleCard' import { useAutosave } from './hooks/useAutosave' +// Addresses an admin may send a reply from — must stay in sync with ADMIN_REPLY_FROM_OPTIONS in server/config.js. +const REPLY_FROM_OPTIONS = [ + { value: 'Verse by Verse with Nate ', label: 'hello@versebyversewithnate.us' }, + { value: 'Verse by Verse with Nate ', label: 'nate@versebyversewithnate.us' }, +] + interface SortableLessonSectionProps { section: ColossiansStudySection study: StudyProgram @@ -1428,7 +1434,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { setContactStatus('ready') } - // Poll for new messages every 30 seconds — only prepend genuinely new ones + // Poll for new messages every 30 seconds — prepend new ones and refresh fields (e.g. archived) on existing ones useEffect(() => { const interval = setInterval(async () => { try { @@ -1437,9 +1443,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const data = await r.json() as { submissions?: ContactSubmission[] } const fresh = Array.isArray(data.submissions) ? data.submissions : [] setContactSubmissions(prev => { + const freshById = new Map(fresh.map(s => [s.id, s])) const existingIds = new Set(prev.map(s => s.id)) + const merged = prev.map(s => freshById.get(s.id) ?? s) const newOnes = fresh.filter(s => !existingIds.has(s.id)) - return newOnes.length > 0 ? [...newOnes, ...prev] : prev + return newOnes.length > 0 ? [...newOnes, ...merged] : merged }) } catch { /* silent — don't disrupt the UI */ } }, 30_000) @@ -2755,8 +2763,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { // Default reply-from to whichever address the email was sent to const inboundTo = submission.inboundTo ?? '' const defaultFrom = inboundTo.includes('nate@') - ? 'Verse by Verse with Nate ' - : 'Verse by Verse with Nate ' + ? REPLY_FROM_OPTIONS[1].value + : REPLY_FROM_OPTIONS[0].value setContactReplyDraft({ submissionId: submission.id, recipientName: submission.name, @@ -5150,8 +5158,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { value={contactReplyDraft.fromAddress} onChange={e => setContactReplyDraft(draft => draft ? { ...draft, fromAddress: e.target.value } : draft)} > - - + {REPLY_FROM_OPTIONS.map(option => ( + + ))}