fix: honor part-type fallback when quoting replies #649
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
extractUserAuthoredText,
|
||||
formatRecipientEntry,
|
||||
expandRecipients,
|
||||
getQuoteBodies,
|
||||
} from '../email-composer-utils';
|
||||
|
||||
const FORWARDED_SEPARATOR = "---------- Forwarded message ----------";
|
||||
@@ -477,3 +478,47 @@ describe('contact group recipients (RFC 5322 group syntax)', () => {
|
||||
expect(expandRecipients([{ name: 'X', email: 'x@y.z' }])).toEqual([{ name: 'X', email: 'x@y.z' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getQuoteBodies", () => {
|
||||
const part = (partId: string, type: string) => ({ partId, blobId: "b", size: 1, type });
|
||||
|
||||
it("converts an HTML-only message's shared part into readable text (#649)", () => {
|
||||
const { body, htmlBody } = getQuoteBodies({
|
||||
textBody: [part("1", "text/html")],
|
||||
htmlBody: [part("1", "text/html")],
|
||||
bodyValues: { "1": { value: "<p>Hallo Jonas.</p><p>Zeile zwei<br>und drei</p>" } },
|
||||
});
|
||||
expect(body).toBe("Hallo Jonas.\n\nZeile zwei\nund drei");
|
||||
expect(htmlBody).toContain("<p>Hallo Jonas.</p>");
|
||||
});
|
||||
|
||||
it("drops htmlBody when it is really the text/plain part (#649)", () => {
|
||||
const text = "Hallo Herr Test,\n\ndas ist eine Test-Email.\n\nBeste Grüße";
|
||||
const { body, htmlBody } = getQuoteBodies({
|
||||
textBody: [part("1", "text/plain")],
|
||||
htmlBody: [part("1", "text/plain")],
|
||||
bodyValues: { "1": { value: text } },
|
||||
});
|
||||
expect(body).toBe(text);
|
||||
expect(htmlBody).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes both parts through when the message has real alternatives", () => {
|
||||
const { body, htmlBody } = getQuoteBodies({
|
||||
textBody: [part("t", "text/plain")],
|
||||
htmlBody: [part("h", "text/html")],
|
||||
bodyValues: {
|
||||
t: { value: "plain version" },
|
||||
h: { value: "<p>html version</p>" },
|
||||
},
|
||||
});
|
||||
expect(body).toBe("plain version");
|
||||
expect(htmlBody).toBe("<p>html version</p>");
|
||||
});
|
||||
|
||||
it("falls back to the preview when body values are missing", () => {
|
||||
const { body, htmlBody } = getQuoteBodies({ preview: "preview text" });
|
||||
expect(body).toBe("preview text");
|
||||
expect(htmlBody).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { isValidEmail } from "@/lib/validation";
|
||||
import { htmlToPlainText } from "@/lib/html-to-text";
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
import { Ellipsis, Lock, TriangleAlert } from "lucide-react";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
|
||||
const HTML_ESCAPE_MAP = {
|
||||
"&": "&",
|
||||
@@ -17,6 +18,41 @@ function escapeHtml(value: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the plain-text and HTML bodies of an original message for seeding a
|
||||
* reply/forward quote.
|
||||
*
|
||||
* Per RFC 8621 § 4.1.4 a message with only one body variant exposes that
|
||||
* single part in BOTH `textBody` and `htmlBody`. So for an HTML-only message
|
||||
* `textBody[0]` is the raw text/html source, and for a plain-text-only
|
||||
* message `htmlBody[0]` is the text/plain part. Quoting either verbatim
|
||||
* breaks the reply (#649): raw HTML tags end up in a plain-text quote, and
|
||||
* plain text rendered as HTML collapses all newlines. Route by each part's
|
||||
* actual MIME type instead: HTML listed under textBody is converted to
|
||||
* readable text, and plain text listed under htmlBody is dropped so the
|
||||
* composer's text path (escape + <br>) renders it.
|
||||
*/
|
||||
export function getQuoteBodies(
|
||||
email: Pick<Email, "textBody" | "htmlBody" | "bodyValues" | "preview">
|
||||
): { body: string; htmlBody?: string } {
|
||||
const textPart = email.textBody?.[0];
|
||||
const htmlPart = email.htmlBody?.[0];
|
||||
const textValue = textPart ? email.bodyValues?.[textPart.partId]?.value : undefined;
|
||||
const htmlValue = htmlPart ? email.bodyValues?.[htmlPart.partId]?.value : undefined;
|
||||
|
||||
const textPartIsHtml = textPart?.type?.toLowerCase() === "text/html";
|
||||
// A missing type is treated as HTML, matching the viewer's rendering path.
|
||||
const htmlPartIsHtml = !htmlPart?.type || htmlPart.type.toLowerCase() === "text/html";
|
||||
|
||||
const body = textValue
|
||||
? (textPartIsHtml ? htmlToPlainText(textValue, { paragraphSpacing: true }) : textValue)
|
||||
: (email.preview || "");
|
||||
return {
|
||||
body,
|
||||
htmlBody: htmlPartIsHtml ? htmlValue || undefined : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function plainTextToComposerBody(text: string): string {
|
||||
if (!text) return "";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user