mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: render plain-text-only mail as text in the mobile thread view #489
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user