From a69b3651fd41bb9d36d9eeab96d7a394deedb4a7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:41:45 +0200 Subject: [PATCH] fix: render plain-text-only mail as text in the mobile thread view #489 --- components/email/email-viewer.tsx | 33 +----- components/email/thread-conversation-view.tsx | 23 +--- lib/__tests__/email-body-selection.test.ts | 107 ++++++++++++++++++ lib/email-body-selection.ts | 35 ++++++ 4 files changed, 149 insertions(+), 49 deletions(-) create mode 100644 lib/__tests__/email-body-selection.test.ts create mode 100644 lib/email-body-selection.ts diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 9af8480d7..8599c3bf2 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -5,7 +5,7 @@ import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { applyNewTabToAnchor, escapeHtml, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; -import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; +import { getRenderableHtmlBody } from "@/lib/email-body-selection"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { withBasePath } from "@/lib/browser-navigation"; import { buildContactsPath, buildMailPath } from "@/lib/deep-links"; @@ -1726,35 +1726,10 @@ export function EmailViewer({ // Check if we have body values if (email.bodyValues) { - // Check if HTML content exists and if it's actually rich HTML or just plain text wrapper - let useHtmlVersion = false; - let htmlContent = ''; + // Rich HTML, or just a plain-text wrapper the server dressed up as HTML? + let htmlContent = getRenderableHtmlBody(email); - if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) { - htmlContent = email.bodyValues[email.htmlBody[0].partId].value; - // Per RFC 8621 ยง 4.1.4, when a message has only one alternative the server - // exposes the same part in both htmlBody and textBody. The shared part may - // actually be text/plain (plain-text-only mail) - rendering that as HTML - // collapses newlines and skips linkification, so route by the part's type. - const htmlPart = email.htmlBody[0]; - if (htmlPart.type && htmlPart.type.toLowerCase() !== 'text/html') { - useHtmlVersion = false; - } else { - // Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting). - // Server-generated HTML from text/plain emails often lacks
tags, collapsing newlines. - const textPartId = email.textBody?.[0]?.partId; - const htmlPartId = htmlPart.partId; - const hasDistinctTextBody = !!textPartId && textPartId !== htmlPartId && !!email.bodyValues[textPartId]; - if (hasDistinctTextBody && htmlContent) { - useHtmlVersion = hasMeaningfulHtmlBody(htmlContent); - } else { - useHtmlVersion = !!htmlContent; - } - } - } - - // If we should use HTML version and it exists - if (useHtmlVersion && htmlContent) { + if (htmlContent) { // Replace cid: references with authenticated blob URLs (fetched via useEffect) // This prevents browser auth dialogs that occur when loading raw JMAP download URLs if (email.attachments) { diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 26de3e08d..d3a1c8ee2 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import DOMPurify from "dompurify"; import { Email, ThreadGroup } from "@/lib/jmap/types"; import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; -import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; +import { getRenderableHtmlBody } from "@/lib/email-body-selection"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform"; import { useThemeStore } from "@/stores/theme-store"; @@ -326,26 +326,9 @@ function EmailCard({ if (!email) return { html: "", isHtml: false }; if (email.bodyValues) { - let useHtmlVersion = false; - let htmlContent = ''; + let htmlContent = getRenderableHtmlBody(email); - if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) { - htmlContent = email.bodyValues[email.htmlBody[0].partId].value; - // Prefer textBody when HTML is auto-generated minimal wrapper (no rich formatting). - // Server-generated HTML from text/plain emails often lacks
tags, collapsing newlines. - // Per RFC 8621, an HTML-only email exposes the same partId in both htmlBody and textBody - - // in that case there is no real plain-text alternative, so always render the HTML. - const textPartId = email.textBody?.[0]?.partId; - const htmlPartId = email.htmlBody[0].partId; - const hasDistinctTextBody = !!textPartId && textPartId !== htmlPartId && !!email.bodyValues[textPartId]; - if (hasDistinctTextBody && htmlContent) { - useHtmlVersion = hasMeaningfulHtmlBody(htmlContent); - } else { - useHtmlVersion = !!htmlContent; - } - } - - if (useHtmlVersion && htmlContent) { + if (htmlContent) { // Replace cid: references with authenticated blob URLs (fetched via useEffect) // This prevents browser auth dialogs that occur when loading raw JMAP download URLs if (email.attachments) { diff --git a/lib/__tests__/email-body-selection.test.ts b/lib/__tests__/email-body-selection.test.ts new file mode 100644 index 000000000..70210bbfc --- /dev/null +++ b/lib/__tests__/email-body-selection.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { getRenderableHtmlBody } from '@/lib/email-body-selection'; + +type PartialEmail = Parameters[0]; + +function makeEmail(parts: { + html?: { partId: string; type: string }; + text?: { partId: string; type: string }; + values: Record; +}): PartialEmail { + const part = (p: { partId: string; type: string }) => ({ + partId: p.partId, + blobId: `blob-${p.partId}`, + size: 1, + type: p.type, + }); + return { + htmlBody: parts.html ? [part(parts.html)] : [], + textBody: parts.text ? [part(parts.text)] : [], + bodyValues: Object.fromEntries( + Object.entries(parts.values).map(([k, v]) => [k, { value: v, isEncodingProblem: false, isTruncated: false }]) + ), + } as PartialEmail; +} + +describe('getRenderableHtmlBody', () => { + it('renders as plain text when the shared part is text/plain (#489)', () => { + // Plain-text-only mail: RFC 8621 puts the same part in htmlBody and textBody. + const email = makeEmail({ + html: { partId: '1', type: 'text/plain' }, + text: { partId: '1', type: 'text/plain' }, + values: { '1': 'line one\nline two\nline three' }, + }); + + expect(getRenderableHtmlBody(email)).toBeNull(); + }); + + it('matches the media type case-insensitively', () => { + const email = makeEmail({ + html: { partId: '1', type: 'TEXT/HTML' }, + text: { partId: '1', type: 'TEXT/HTML' }, + values: { '1': '

hello

' }, + }); + + expect(getRenderableHtmlBody(email)).toBe('

hello

'); + }); + + it('renders HTML-only mail as HTML even though the part is shared', () => { + const email = makeEmail({ + html: { partId: '1', type: 'text/html' }, + text: { partId: '1', type: 'text/html' }, + values: { '1': '
rich content
' }, + }); + + expect(getRenderableHtmlBody(email)).toBe('
rich content
'); + }); + + it('prefers the distinct text part when the HTML alternative is a bare wrapper', () => { + const email = makeEmail({ + html: { partId: '2', type: 'text/html' }, + text: { partId: '1', type: 'text/plain' }, + values: { '1': 'line one\nline two', '2': 'line one line two' }, + }); + + expect(getRenderableHtmlBody(email)).toBeNull(); + }); + + it('keeps a distinct HTML alternative that carries real formatting', () => { + const html = '

line one

line two

'; + const email = makeEmail({ + html: { partId: '2', type: 'text/html' }, + text: { partId: '1', type: 'text/plain' }, + values: { '1': 'line one\nline two', '2': html }, + }); + + expect(getRenderableHtmlBody(email)).toBe(html); + }); + + it('returns null when there is no HTML body at all', () => { + const email = makeEmail({ + text: { partId: '1', type: 'text/plain' }, + values: { '1': 'just text' }, + }); + + expect(getRenderableHtmlBody(email)).toBeNull(); + }); + + it('returns null when the HTML part has no fetched body value', () => { + const email = makeEmail({ + html: { partId: '2', type: 'text/html' }, + text: { partId: '1', type: 'text/plain' }, + values: { '1': 'just text' }, + }); + + expect(getRenderableHtmlBody(email)).toBeNull(); + }); + + it('returns null for an empty HTML body value', () => { + const email = makeEmail({ + html: { partId: '2', type: 'text/html' }, + text: { partId: '1', type: 'text/plain' }, + values: { '1': 'just text', '2': '' }, + }); + + expect(getRenderableHtmlBody(email)).toBeNull(); + }); +}); diff --git a/lib/email-body-selection.ts b/lib/email-body-selection.ts new file mode 100644 index 000000000..02f5515ea --- /dev/null +++ b/lib/email-body-selection.ts @@ -0,0 +1,35 @@ +import { Email } from '@/lib/jmap/types'; +import { hasMeaningfulHtmlBody } from '@/lib/signature-utils'; + +/** + * Pick the HTML body an email should be rendered from, or null when it should be + * rendered as plain text instead. Shared by the desktop viewer and the mobile + * thread view so the two cannot drift apart again (#489: the thread view was + * missing the part-type guard and rendered plain-text-only mail as HTML, + * collapsing every line break). + */ +export function getRenderableHtmlBody( + email: Pick +): string | null { + const htmlPart = email.htmlBody?.[0]; + if (!htmlPart?.partId || !email.bodyValues?.[htmlPart.partId]) return null; + + const htmlContent = email.bodyValues[htmlPart.partId].value; + if (!htmlContent) return null; + + // Per RFC 8621, a single-part email exposes the same part in both htmlBody and + // textBody. That shared part may actually be text/plain (plain-text-only mail); + // rendering it as HTML collapses newlines and skips linkification, so route by + // the part's media type rather than by which list it appears in. + if (htmlPart.type && htmlPart.type.toLowerCase() !== 'text/html') return null; + + // Prefer textBody when the HTML is an auto-generated minimal wrapper (no rich + // formatting). Server-generated HTML from text/plain emails often lacks
+ // tags, which would collapse newlines just the same. + const textPartId = email.textBody?.[0]?.partId; + const hasDistinctTextBody = + !!textPartId && textPartId !== htmlPart.partId && !!email.bodyValues[textPartId]; + if (hasDistinctTextBody && !hasMeaningfulHtmlBody(htmlContent)) return null; + + return htmlContent; +}