From edba184bc092f8a5f3698e57af96b4cfcc3bf608 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:30:18 +0100 Subject: [PATCH] fix: keep an Outlook-style reply from loading the quoted message's remote images --- .../email/__tests__/quoted-html.test.ts | 24 +++++++++++++++++++ components/email/quoted-html.ts | 11 +++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/components/email/__tests__/quoted-html.test.ts b/components/email/__tests__/quoted-html.test.ts index 6dbdea83f..9596f54a6 100644 --- a/components/email/__tests__/quoted-html.test.ts +++ b/components/email/__tests__/quoted-html.test.ts @@ -6,6 +6,30 @@ import { buildQuotedHtmlBlock, QuotedHtml, serializeEditorContent } from '../quo const createEditor = (content: string) => new Editor({ extensions: [StarterKit, QuotedHtml], content }); describe('HTML quote borders', () => { + it('flattens quotes without parsing the original into the page, where its images would load', () => { + // A browser fetches an as soon as it is parsed into an element of + // the live document, attached or not: that fires the tracker the remote + // content setting holds back. + const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML')!; + const liveParses: string[] = []; + Object.defineProperty(Element.prototype, 'innerHTML', { + ...descriptor, + set(this: Element, value: string) { + if (this.ownerDocument === document && this.tagName !== 'TEMPLATE') liveParses.push(value); + descriptor.set!.call(this, value); + }, + }); + let html = ''; + try { + html = buildQuotedHtmlBlock('
Hi
', { showQuoteBorder: false }); + } finally { + Object.defineProperty(Element.prototype, 'innerHTML', descriptor); + } + expect(liveParses.filter((value) => value.includes('tracker.example'))).toEqual([]); + expect(html).toContain(''); + expect(html).not.toContain('blockquote'); + }); + it('keeps normal quote styling by default', () => { const original = '
Original reply
'; const editor = createEditor(buildQuotedHtmlBlock(original)); diff --git a/components/email/quoted-html.ts b/components/email/quoted-html.ts index ad92c90bf..f61a9b0bf 100644 --- a/components/email/quoted-html.ts +++ b/components/email/quoted-html.ts @@ -303,14 +303,17 @@ export function buildQuotedHtmlBlock( if (!showQuoteBorder) { // Outlook-style replies flatten quote levels, including earlier replies. // Work on sanitized DOM so tables, links and signatures remain intact. - const container = document.createElement("div"); + // A template's content is inert: parsed into a plain div, the original's + // remote images would load here, past the parking the island does. + const container = document.createElement("template"); container.innerHTML = html; - container.querySelectorAll("blockquote").forEach((quote) => { - const block = document.createElement("div"); + const content = container.content; + content.querySelectorAll("blockquote").forEach((quote) => { + const block = content.ownerDocument.createElement("div"); block.append(...Array.from(quote.childNodes)); quote.replaceWith(block); }); - container.querySelectorAll(`[${QUOTED_HTML_MARKER}]`).forEach((quote) => { + content.querySelectorAll(`[${QUOTED_HTML_MARKER}]`).forEach((quote) => { quote.setAttribute("data-quote-border", "false"); quote.style.borderLeft = "none"; quote.style.paddingLeft = "0";