fix: render plain-text-only mail as text in the mobile thread view #489

This commit is contained in:
Linus Rath
2026-08-31 20:41:45 +02:00
parent a88b1e0848
commit a69b3651fd
4 changed files with 149 additions and 49 deletions
+4 -29
View File
@@ -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 <br> 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) {
+3 -20
View File
@@ -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 <br> 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) {
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { getRenderableHtmlBody } from '@/lib/email-body-selection';
type PartialEmail = Parameters<typeof getRenderableHtmlBody>[0];
function makeEmail(parts: {
html?: { partId: string; type: string };
text?: { partId: string; type: string };
values: Record<string, string>;
}): 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': '<p>hello</p>' },
});
expect(getRenderableHtmlBody(email)).toBe('<p>hello</p>');
});
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': '<div><b>rich</b> content</div>' },
});
expect(getRenderableHtmlBody(email)).toBe('<div><b>rich</b> content</div>');
});
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': '<html><body>line one line two</body></html>' },
});
expect(getRenderableHtmlBody(email)).toBeNull();
});
it('keeps a distinct HTML alternative that carries real formatting', () => {
const html = '<html><body><p>line one</p><p><b>line two</b></p></body></html>';
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();
});
});
+35
View File
@@ -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<Email, 'htmlBody' | 'textBody' | 'bodyValues'>
): 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 <br>
// 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;
}