From 3f2f27321cd43fafbb33e341ec70823a1f27ef64 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:08:33 +0100 Subject: [PATCH] fix: keep an unquoted cid: image from swallowing the rest of the message --- components/email/email-viewer.tsx | 19 ++++--------------- components/email/thread-conversation-view.tsx | 16 ++++------------ lib/__tests__/attachment-visibility.test.ts | 19 ++++++++++++++++++- lib/attachment-visibility.ts | 17 ++++++++++++++--- 4 files changed, 40 insertions(+), 31 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index cc179c90f..53b5d9c46 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -4,9 +4,9 @@ import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCal 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, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; +import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { getRenderableHtmlBody } from "@/lib/email-body-selection"; -import { collectReferencedCids, isEmbeddedInBody, normalizeCid } from "@/lib/attachment-visibility"; +import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode"; import { fitEmailBodyWidth } from "@/lib/email-fit-width"; @@ -2492,12 +2492,7 @@ export function EmailViewer({ // 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) { - htmlContent = htmlContent.replace( - /\bcid:([^"'\s)]+)/gi, - (_match, cidRef) => { - return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; - } - ); + htmlContent = rewriteCidReferences(htmlContent, cidBlobUrls, TRANSPARENT_BLOCKED_PIXEL); } // Sanitize (no dark mode color transforms - emails render true-to-life @@ -2594,13 +2589,7 @@ export function EmailViewer({ }; }; if (pluginRenderedHtml) { - const htmlWithCidUrls = pluginRenderedHtml.replace( - /\bcid:([^"'\s)]+)/gi, - (_match, cidRef) => { - return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; - } - ); - return renderHtml(htmlWithCidUrls); + return renderHtml(rewriteCidReferences(pluginRenderedHtml, cidBlobUrls, TRANSPARENT_BLOCKED_PIXEL)); } if (pluginRenderedText) { return { html: plainToHtml(pluginRenderedText), isHtml: false, hasStyleTag: false, externalBlocked: false }; diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 5047813a2..4cde880b6 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react"; import DOMPurify from "dompurify"; import { Email, ThreadGroup } from "@/lib/jmap/types"; -import { EMAIL_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, emailIframeCsp, isOpenableLinkHref, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizeEmailBodyForIframe, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; +import { EMAIL_SANITIZE_CONFIG, TRANSPARENT_BLOCKED_PIXEL, blockExternalResourcesOnNode, collapseBlockedImageContainers, emailIframeCsp, isOpenableLinkHref, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizeEmailBodyForIframe, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { resolveExternalLink } from "@/lib/plugin-hook-result"; import { stripBidiControls } from "@/lib/bidi"; import { requestInternalMailto } from "@/lib/protocol-handlers/mailto"; @@ -11,7 +11,7 @@ import { emailDisplayDate } from "@/lib/email-date"; import { getRenderableHtmlBody } from "@/lib/email-body-selection"; import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code"; import { VerificationCodeChip } from "./verification-code-chip"; -import { collectReferencedCids, isEmbeddedInBody, normalizeCid } from "@/lib/attachment-visibility"; +import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { fitEmailBodyWidth } from "@/lib/email-fit-width"; import { DARK_PENDING_CLASS, applyEmailDarkMode, emailDarkModeCss } from "@/lib/email-dark-mode"; @@ -617,10 +617,7 @@ export function ConversationEmailBody({ // Plugin-rendered bodies get the reader's sanitizer and remote-content // blocking, like any other mail body (#797). if (pluginBody.html) { - const withCids = pluginBody.html.replace( - /\bcid:([^"'\s)]+)/gi, - (_match, cidRef) => pluginBody.cidUrls[cidRef] || cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', - ); + const withCids = rewriteCidReferences(pluginBody.html, { ...cidBlobUrls, ...pluginBody.cidUrls }, TRANSPARENT_BLOCKED_PIXEL); const { html, blockedExternalContent } = sanitizeEmailBodyForIframe(withCids, !allowExternal); if (blockedExternalContent) { if (!hasBlockedContent) setHasBlockedContent(true); @@ -645,12 +642,7 @@ export function ConversationEmailBody({ // 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) { - htmlContent = htmlContent.replace( - /\bcid:([^"'\s)]+)/gi, - (_match, cidRef) => { - return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; - } - ); + htmlContent = rewriteCidReferences(htmlContent, cidBlobUrls, TRANSPARENT_BLOCKED_PIXEL); } let blockedExternalContent = false; diff --git a/lib/__tests__/attachment-visibility.test.ts b/lib/__tests__/attachment-visibility.test.ts index d922b180b..fc163236b 100644 --- a/lib/__tests__/attachment-visibility.test.ts +++ b/lib/__tests__/attachment-visibility.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { collectReferencedCids, isEmbeddedInBody } from '@/lib/attachment-visibility'; +import { collectReferencedCids, isEmbeddedInBody, rewriteCidReferences } from '@/lib/attachment-visibility'; describe('collectReferencedCids', () => { it('finds cid: references wherever the body uses them', () => { @@ -72,3 +72,20 @@ describe('isEmbeddedInBody', () => { expect(isEmbeddedInBody({ cid: 'logoImage', type: 'application/octet-stream' }, none)).toBe(false); }); }); + +describe('rewriteCidReferences', () => { + it('swaps a cid: reference for its URL and an unknown one for the placeholder', () => { + expect(rewriteCidReferences('', { logo: 'blob:x/1' }, 'data:,p')) + .toBe(''); + }); + + it('ends an unquoted reference at the tag instead of swallowing the markup after it', () => { + const html = '

Hello

'; + expect(rewriteCidReferences(html, { logo: 'blob:x/1' }, 'data:,p')).toBe('

Hello

'); + expect(collectReferencedCids(html)).toEqual(new Set(['logo'])); + }); + + it('never resolves a reference through the object prototype', () => { + expect(rewriteCidReferences('', {}, 'data:,p')).toBe(''); + }); +}); diff --git a/lib/attachment-visibility.ts b/lib/attachment-visibility.ts index 85fc8e746..bcd05c4b0 100644 --- a/lib/attachment-visibility.ts +++ b/lib/attachment-visibility.ts @@ -14,10 +14,21 @@ */ /** - * The exact pattern the viewers rewrite to blob URLs, so "referenced" here - * means "rendered" and a chip is only ever hidden for a part the body shows. + * The exact pattern the viewers rewrite to blob URLs (rewriteCidReferences), + * so "referenced" here means "rendered" and a chip is only ever hidden for a + * part the body shows. A reference ends at `<`/`>` too: an unquoted + * `` would otherwise take the rest of the markup with it. */ -const CID_REFERENCE = /\bcid:([^"'\s)]+)/gi; +const CID_REFERENCE = /\bcid:([^"'\s)<>]+)/gi; + +/** + * Swaps every `cid:` reference in a raw HTML body for the URL fetched for that + * part, or `fallback` when there is none. + */ +export function rewriteCidReferences(html: string, urls: Readonly>, fallback: string): string { + return html.replace(CID_REFERENCE, (_match, cid: string) => + (Object.prototype.hasOwnProperty.call(urls, cid) ? urls[cid] : fallback)); +} const EMPTY: ReadonlySet = new Set();