Render inbound email HTML body in admin inbox

Worker now extracts both text/plain and text/html MIME parts and sends
htmlBody in the webhook payload. Server stores it on the submission.
Admin inbox renders htmlBody in a sandboxed iframe when present, falling
back to plain text with whitespace preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-06-30 11:32:52 -04:00
parent 4b411f763f
commit 118314d0d7
3 changed files with 33 additions and 16 deletions
+12 -10
View File
@@ -84,35 +84,37 @@ function parseEmail(raw, message) {
const date = headers['date'] ?? new Date().toISOString() const date = headers['date'] ?? new Date().toISOString()
const messageId = headers['message-id'] ?? '' const messageId = headers['message-id'] ?? ''
// Extract plain text body — skip MIME boundaries and HTML parts
const bodyLines = lines.slice(bodyStart) const bodyLines = lines.slice(bodyStart)
const plainText = extractPlainText(raw, bodyLines) const plainText = extractPart(raw, bodyLines, 'text/plain')
const htmlBody = extractPart(raw, bodyLines, 'text/html')
return { return {
from, from,
to, to,
subject, subject,
body: plainText, body: plainText,
htmlBody: htmlBody || null,
date, date,
messageId, messageId,
source: 'inbound-email', source: 'inbound-email',
} }
} }
function extractPlainText(raw, bodyLines) { function extractPart(raw, bodyLines, contentType) {
// Look for Content-Type: text/plain section in multipart emails const escaped = contentType.replace('/', '\\/')
const textPlainMatch = /Content-Type: text\/plain[^\r\n]*\r?\n(?:[^\r\n]+\r?\n)*\r?\n([\s\S]*?)(?=--|\z)/i.exec(raw) const re = new RegExp(`Content-Type: ${escaped}[^\\r\\n]*\\r?\\n(?:[^\\r\\n]+\\r?\\n)*\\r?\\n([\\s\\S]*?)(?=--|$)`, 'i')
if (textPlainMatch) { const match = re.exec(raw)
return decodeEmailBody(textPlainMatch[1]).trim() if (match) return decodeEmailBody(match[1]).trim()
}
// Fallback: join body lines, strip HTML tags if present if (contentType === 'text/plain') {
const joined = bodyLines.join('\n') const joined = bodyLines.join('\n')
if (/<[a-z][\s\S]*>/i.test(joined)) { if (/<[a-z][\s\S]*>/i.test(joined)) {
return joined.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/\s{3,}/g, '\n\n').trim() return joined.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/\s{3,}/g, '\n\n').trim()
} }
return joined.trim() return joined.trim()
}
return null
} }
function decodeEmailBody(text) { function decodeEmailBody(text) {
+2 -1
View File
@@ -15,7 +15,7 @@ export function register(app) {
res.status(401).json({ message: 'Unauthorized.' }); return res.status(401).json({ message: 'Unauthorized.' }); return
} }
const { from, to, subject, body, date, messageId, source } = req.body ?? {} const { from, to, subject, body, htmlBody, date, messageId, source } = req.body ?? {}
if (!from || typeof from !== 'string') { if (!from || typeof from !== 'string') {
res.status(400).json({ message: 'Missing from address.' }); return res.status(400).json({ message: 'Missing from address.' }); return
@@ -40,6 +40,7 @@ export function register(app) {
name: fromName || fromEmail, name: fromName || fromEmail,
email: fromEmail, email: fromEmail,
message: [subject ? `Subject: ${subject}` : '', body ?? ''].filter(Boolean).join('\n\n'), message: [subject ? `Subject: ${subject}` : '', body ?? ''].filter(Boolean).join('\n\n'),
htmlBody: typeof htmlBody === 'string' && htmlBody.trim() ? htmlBody.trim() : null,
messageType: 'general', messageType: 'general',
subscribe: false, subscribe: false,
archived: false, archived: false,
+15 -1
View File
@@ -628,6 +628,7 @@ interface ContactSubmission {
archived?: boolean archived?: boolean
source?: 'contact-form' | 'download' | 'inbound-email' source?: 'contact-form' | 'download' | 'inbound-email'
inboundTo?: string inboundTo?: string
htmlBody?: string | null
} }
interface ContactReplyDraft { interface ContactReplyDraft {
@@ -5051,7 +5052,20 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</div> </div>
<div className="admin-email-body"> <div className="admin-email-body">
<p>{selected.message}</p> {selected.htmlBody ? (
<iframe
srcDoc={selected.htmlBody}
sandbox="allow-same-origin"
style={{ width: '100%', minHeight: '320px', border: 'none', background: '#fff', borderRadius: '4px' }}
onLoad={e => {
const iframe = e.currentTarget
const h = iframe.contentDocument?.documentElement?.scrollHeight
if (h) iframe.style.height = `${h}px`
}}
/>
) : (
<p style={{ whiteSpace: 'pre-wrap' }}>{selected.message}</p>
)}
</div> </div>
<div className="admin-actions admin-actions--maintenance"> <div className="admin-actions admin-actions--maintenance">