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;
+}