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:
+16
-14
@@ -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()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
return joined.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: join body lines, strip HTML tags if present
|
return null
|
||||||
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) {
|
function decodeEmailBody(text) {
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user