diff --git a/cloudflare/email-worker.js b/cloudflare/email-worker.js index 24e96e8..0242796 100644 --- a/cloudflare/email-worker.js +++ b/cloudflare/email-worker.js @@ -84,35 +84,37 @@ function parseEmail(raw, message) { const date = headers['date'] ?? new Date().toISOString() const messageId = headers['message-id'] ?? '' - // Extract plain text body — skip MIME boundaries and HTML parts 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 { from, to, subject, body: plainText, + htmlBody: htmlBody || null, date, messageId, source: 'inbound-email', } } -function extractPlainText(raw, bodyLines) { - // Look for Content-Type: text/plain section in multipart emails - const textPlainMatch = /Content-Type: text\/plain[^\r\n]*\r?\n(?:[^\r\n]+\r?\n)*\r?\n([\s\S]*?)(?=--|\z)/i.exec(raw) - if (textPlainMatch) { - return decodeEmailBody(textPlainMatch[1]).trim() +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() + + 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 - 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() + return null } function decodeEmailBody(text) { diff --git a/server/routes/inbound-email.js b/server/routes/inbound-email.js index ca06a1d..e89e689 100644 --- a/server/routes/inbound-email.js +++ b/server/routes/inbound-email.js @@ -15,7 +15,7 @@ export function register(app) { 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') { res.status(400).json({ message: 'Missing from address.' }); return @@ -40,6 +40,7 @@ export function register(app) { name: fromName || fromEmail, email: fromEmail, message: [subject ? `Subject: ${subject}` : '', body ?? ''].filter(Boolean).join('\n\n'), + htmlBody: typeof htmlBody === 'string' && htmlBody.trim() ? htmlBody.trim() : null, messageType: 'general', subscribe: false, archived: false, diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index d800235..b20d0a7 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -628,6 +628,7 @@ interface ContactSubmission { archived?: boolean source?: 'contact-form' | 'download' | 'inbound-email' inboundTo?: string + htmlBody?: string | null } interface ContactReplyDraft { @@ -5051,7 +5052,20 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
{selected.message}
+ {selected.htmlBody ? ( +