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:
+15
-6
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user