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:
nmemmert
2026-07-02 08:21:50 -04:00
parent fbb9a24f32
commit 17c9cbbc8b
7 changed files with 147 additions and 52 deletions
+15 -6
View File
@@ -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 <hello@versebyversewithnate.us>', label: 'hello@versebyversewithnate.us' },
{ value: 'Verse by Verse with Nate <nate@versebyversewithnate.us>', 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 <nate@versebyversewithnate.us>'
: 'Verse by Verse with Nate <hello@versebyversewithnate.us>'
? 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)}
>
<option value="Verse by Verse with Nate <hello@versebyversewithnate.us>">hello@versebyversewithnate.us</option>
<option value="Verse by Verse with Nate <nate@versebyversewithnate.us>">nate@versebyversewithnate.us</option>
{REPLY_FROM_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className="admin-field">