From ec0ce3d23bf96abdebf4b07a4c42ed99fbd0ae4f Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Sat, 23 May 2026 06:41:16 -0700 Subject: [PATCH 1/8] fix(mail): avoid remote avatars in MCP embeds --- .../mail/app/components/layout/AppLayout.tsx | 89 +++++++++++-------- 1 file changed, 53 insertions(+), 36 deletions(-) diff --git a/templates/mail/app/components/layout/AppLayout.tsx b/templates/mail/app/components/layout/AppLayout.tsx index 72041bf78..db9896a61 100644 --- a/templates/mail/app/components/layout/AppLayout.tsx +++ b/templates/mail/app/components/layout/AppLayout.tsx @@ -79,6 +79,41 @@ import { const BARE_ROUTES = new Set(["/email"]); const COMPOSE_FULLSCREEN_PARAM = "composeFullscreen"; +function isMcpEmbedSurface(): boolean { + if (typeof window === "undefined") return false; + return new URLSearchParams(window.location.search).get("embedded") === "1"; +} + +function AccountAvatar({ + email, + photoUrl, + imageClassName, + fallbackClassName, +}: { + email: string; + photoUrl?: string | null; + imageClassName: string; + fallbackClassName: string; +}) { + const [imageFailed, setImageFailed] = useState(false); + const shouldLoadRemoteAvatar = + !!photoUrl && !isMcpEmbedSurface() && !imageFailed; + + if (shouldLoadRemoteAvatar) { + return ( + setImageFailed(true)} + /> + ); + } + + return
{email[0]?.toUpperCase()}
; +} + /** * Routes that render the slim "standard layout" chrome instead of the full * inbox chrome (tabs, search bar, account stack, compose pen, draft queue @@ -1196,18 +1231,12 @@ function AppLayoutInner({ children }: AppLayoutProps) { zIndex: accounts.length - i, }} > - {account.photoUrl ? ( - - ) : ( -
- {account.email[0]?.toUpperCase()} -
- )} + ); })} @@ -1344,18 +1373,12 @@ function AppLayoutInner({ children }: AppLayoutProps) { isActive ? "opacity-100" : "opacity-30", )} > - {account.photoUrl ? ( - - ) : ( -
- {account.email[0]?.toUpperCase()} -
- )} + {account.email} @@ -2148,18 +2171,12 @@ function AccountPopover({ )} - {account.photoUrl ? ( - - ) : ( -
- {account.email[0]?.toUpperCase()} -
- )} + {account.email} From 8ba6a1a269524228b9738b74459c3c7e235eb1b1 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Sat, 23 May 2026 06:52:02 -0700 Subject: [PATCH 2/8] chore(mail): update stale 'compose.json' comment to reference compose-{id} app-state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compose draft hasn't lived in a file for many releases — it's stored in the SQL 'application_state' table under the 'compose-{id}' key. The ComposeEditor comment still claimed a file path that no longer exists, which shows up in greps for 'compose.json' as a false hit when verifying that the host bridge no longer leaks file-read wording to ChatGPT. Update the comment to match reality. --- templates/mail/app/components/email/ComposeEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/mail/app/components/email/ComposeEditor.tsx b/templates/mail/app/components/email/ComposeEditor.tsx index 905890005..fdd1c3aa5 100644 --- a/templates/mail/app/components/email/ComposeEditor.tsx +++ b/templates/mail/app/components/email/ComposeEditor.tsx @@ -142,7 +142,7 @@ export const ComposeEditor = forwardRef< }, }); - // Sync content from outside (when agent updates compose.json) + // Sync content from outside (when the agent updates compose-{id} app-state) useEffect(() => { if (!editor || editor.isDestroyed) return; const currentMd = (editor.storage as any).markdown.getMarkdown(); From 7edc5700052b4ed79fd7caf02df6b95ac5c47283 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Sat, 23 May 2026 07:02:30 -0700 Subject: [PATCH 3/8] fix(mail): suppress remote images in MCP embeds --- .../mail/app/components/email/EmailList.tsx | 24 ++++++---- .../mail/app/components/email/EmailThread.tsx | 47 ++++++++++++------- .../components/email/IntegrationsSidebar.tsx | 14 ++++-- .../mail/app/components/layout/AppLayout.tsx | 6 +-- templates/mail/app/lib/mcp-embed.spec.ts | 30 ++++++++++++ templates/mail/app/lib/mcp-embed.ts | 5 ++ 6 files changed, 92 insertions(+), 34 deletions(-) create mode 100644 templates/mail/app/lib/mcp-embed.spec.ts create mode 100644 templates/mail/app/lib/mcp-embed.ts diff --git a/templates/mail/app/components/email/EmailList.tsx b/templates/mail/app/components/email/EmailList.tsx index d93597b53..f9f72faaa 100644 --- a/templates/mail/app/components/email/EmailList.tsx +++ b/templates/mail/app/components/email/EmailList.tsx @@ -35,6 +35,7 @@ import { useQueryClient, type InfiniteData } from "@tanstack/react-query"; import { ensureThread, warmThreads } from "@/lib/thread-cache"; import { GoogleConnectBanner } from "@/components/GoogleConnectBanner"; import { Spinner } from "@/components/ui/spinner"; +import { isMcpEmbedSurface } from "@/lib/mcp-embed"; import type { EmailMessage } from "@shared/types"; import { DropdownMenu, @@ -126,6 +127,7 @@ function emptyStateHintForView(view: string): string { export function InboxZero() { const [loaded, setLoaded] = useState(false); + const isEmbedded = isMcpEmbedSurface(); // Toggle class on root so the header can go transparent useEffect(() => { @@ -145,15 +147,19 @@ export function InboxZero() { return (
{/* Background image — fixed so it extends behind header + agent sidebar for blur */} - setLoaded(true)} - className={cn( - "fixed inset-0 h-full w-full object-cover", - loaded ? "opacity-100" : "opacity-0", - )} - /> + {isEmbedded ? ( +
+ ) : ( + setLoaded(true)} + className={cn( + "fixed inset-0 h-full w-full object-cover", + loaded ? "opacity-100" : "opacity-0", + )} + /> + )} {/* Persistent scrims keep white chrome readable across bright photos. */}
diff --git a/templates/mail/app/components/email/EmailThread.tsx b/templates/mail/app/components/email/EmailThread.tsx index adf128636..6bdef30f1 100644 --- a/templates/mail/app/components/email/EmailThread.tsx +++ b/templates/mail/app/components/email/EmailThread.tsx @@ -35,6 +35,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { ensureThread, warmThreads } from "@/lib/thread-cache"; import { getResolvedTheme } from "@/lib/theme"; import { appApiPath } from "@/lib/api-path"; +import { isMcpEmbedSurface } from "@/lib/mcp-embed"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { setUndoAction } from "@/hooks/use-undo"; import { toast } from "sonner"; @@ -2657,6 +2658,7 @@ function HtmlEmailBody({ const IFRAME_BG = hasDesignedBg || !isDark ? IFRAME_BG_LIGHT : IFRAME_BG_DARK; const { data: settings } = useSettings(); const updateSettings = useUpdateSettings(); + const isEmbedded = isMcpEmbedSurface(); const imagePolicy = settings?.imagePolicy ?? "show"; const trustedSenders = settings?.trustedSenders ?? []; @@ -2669,8 +2671,9 @@ function HtmlEmailBody({ const [showImagesForThread, setShowImagesForThread] = useState(false); // Determine effective policy for this email - const effectivePolicy = - isTrusted || showImagesForThread + const effectivePolicy = isEmbedded + ? "block-all" + : isTrusted || showImagesForThread ? imagePolicy === "block-all" ? "block-trackers" // trusted senders still get tracker blocking if policy isn't "show" : imagePolicy @@ -3355,27 +3358,37 @@ function HtmlEmailBody({ }, [activeLocalIdx, searchTerm]); const showBanner = - effectivePolicy === "block-all" && blockedCount > 0 && !showImagesForThread; + effectivePolicy === "block-all" && + blockedCount > 0 && + (isEmbedded || !showImagesForThread); return (
{showBanner && (
- Images blocked. - - {senderEmail && ( - + + {isEmbedded + ? "Remote images hidden in this embed." + : "Images blocked."} + + {!isEmbedded && ( + <> + + {senderEmail && ( + + )} + )}
)} diff --git a/templates/mail/app/components/email/IntegrationsSidebar.tsx b/templates/mail/app/components/email/IntegrationsSidebar.tsx index 4b34e2d5e..2959d84a8 100644 --- a/templates/mail/app/components/email/IntegrationsSidebar.tsx +++ b/templates/mail/app/components/email/IntegrationsSidebar.tsx @@ -36,6 +36,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { isMcpEmbedSurface } from "@/lib/mcp-embed"; function safeExternalHref(value?: string | null): string | null { if (!value) return null; @@ -716,19 +717,26 @@ function ApolloSection({ email }: { email: string }) { const location = [person.city, person.state, person.country] .filter(Boolean) .join(", "); + const isEmbedded = isMcpEmbedSurface(); + const shouldLoadRemotePhoto = person.photo_url && !isEmbedded; + const shouldLoadRemoteLogo = person.organization?.logo_url && !isEmbedded; return ( <> {/* Name & title */}
- {person.photo_url && ( + {shouldLoadRemotePhoto ? ( - )} + ) : person.photo_url ? ( +
+ {name[0]?.toUpperCase()} +
+ ) : null}

{name} @@ -756,7 +764,7 @@ function ApolloSection({ email }: { email: string }) {
- {person.organization.logo_url ? ( + {shouldLoadRemoteLogo ? ( { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns false outside the browser", () => { + expect(isMcpEmbedSurface()).toBe(false); + }); + + it("detects ticketed MCP embed routes", () => { + vi.stubGlobal("window", { location: { search: "?embedded=1" } }); + + expect(isMcpEmbedSurface()).toBe(true); + }); + + it("accepts true for legacy embed query values", () => { + vi.stubGlobal("window", { location: { search: "?embedded=true" } }); + + expect(isMcpEmbedSurface()).toBe(true); + }); + + it("ignores ordinary routes", () => { + vi.stubGlobal("window", { location: { search: "?view=inbox" } }); + + expect(isMcpEmbedSurface()).toBe(false); + }); +}); diff --git a/templates/mail/app/lib/mcp-embed.ts b/templates/mail/app/lib/mcp-embed.ts new file mode 100644 index 000000000..dd5117ecb --- /dev/null +++ b/templates/mail/app/lib/mcp-embed.ts @@ -0,0 +1,5 @@ +export function isMcpEmbedSurface(): boolean { + if (typeof window === "undefined") return false; + const value = new URLSearchParams(window.location.search).get("embedded"); + return value === "1" || value === "true"; +} From 5811a324f12b0ab3b31c46acc0a44a58c2bf88e3 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Sat, 23 May 2026 07:14:00 -0700 Subject: [PATCH 4/8] fix(mail): block remote CSS image URLs in rendered emails Expand the existing image-block policy to also cover @import and url() in
Hello
'; + + const [html, blockedCount] = processHtmlImages(input, "show"); + + expect(blockedCount).toBe(0); + expect(html).toContain("https://cdn.example.com/hero.png"); + expect(html).toContain("https://cdn.example.com/card.png"); + }); + + it("blocks remote CSS image URLs in style attributes", () => { + const input = + '
Hello
'; + + const [html, blockedCount] = processHtmlImages(input, "block-all"); + + expect(blockedCount).toBe(1); + expect(html).not.toContain("https://cdn.example.com/card.png"); + expect(html).toContain("url(cid:badge)"); + }); + + it("blocks remote CSS image URLs and imports in style tags", () => { + const input = [ + '", + "

Hello

", + ].join(""); + + const [html, blockedCount] = processHtmlImages(input, "block-all"); + + expect(blockedCount).toBe(2); + expect(html).not.toContain("https://cdn.example.com/email.css"); + expect(html).not.toContain("https://cdn.example.com/hero.png"); + expect(html).toContain("data:image/png;base64,abcd"); + }); + + it("removes legacy remote background resources", () => { + const input = + '
Hi
'; + + const [html, blockedCount] = processHtmlImages(input, "block-all"); + + expect(blockedCount).toBe(2); + expect(html).not.toContain("https://cdn.example.com/bg.png"); + expect(html).not.toContain("https://cdn.example.com/poster.png"); + }); +}); diff --git a/templates/mail/app/components/email/EmailThread.tsx b/templates/mail/app/components/email/EmailThread.tsx index 6bdef30f1..ee76d3d37 100644 --- a/templates/mail/app/components/email/EmailThread.tsx +++ b/templates/mail/app/components/email/EmailThread.tsx @@ -2587,8 +2587,37 @@ function sanitizeEmailHtml(html: string): SanitizedEmailHtml { }; } +function isInlineImageUrl(value: string): boolean { + const lower = decodeHtmlEntities(value) + .trim() + .replace(/[\s\u0000-\u001f\u007f]+/g, "") + .toLowerCase(); + return lower.startsWith("data:image/") || lower.startsWith("cid:"); +} + +function stripCssRemoteResources(css: string): [string, number] { + let blocked = 0; + const withoutImports = css.replace( + /@import\s+(?:url\(\s*)?(['"]?)[\s\S]*?\1\s*\)?[^;]*;?/gi, + () => { + blocked++; + return ""; + }, + ); + const withoutRemoteUrls = withoutImports.replace( + /url\(\s*(['"]?)([\s\S]*?)\1\s*\)/gi, + (match, _quote: string, rawUrl: string) => { + if (isInlineImageUrl(rawUrl)) return match; + blocked++; + return "none"; + }, + ); + + return [withoutRemoteUrls, blocked]; +} + /** Strip images from HTML based on policy. Returns [processedHtml, imageCount]. */ -function processHtmlImages( +export function processHtmlImages( html: string, policy: "show" | "block-trackers" | "block-all", ): [string, number] { @@ -2596,12 +2625,15 @@ function processHtmlImages( const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); - const images = doc.querySelectorAll("img"); + const template = doc.createElement("template"); + template.innerHTML = html; + const root = template.content; + const images = root.querySelectorAll("img"); let blocked = 0; images.forEach((img) => { const src = img.getAttribute("src") || ""; - if (!src || src.startsWith("data:") || src.startsWith("cid:")) return; + if (!src || isInlineImageUrl(src)) return; if (policy === "block-all") { img.removeAttribute("src"); @@ -2615,15 +2647,15 @@ function processHtmlImages( // Also strip tracking pixel style tags (1x1 images via CSS background) if (policy === "block-trackers" || policy === "block-all") { - doc.querySelectorAll('img[width="1"][height="1"]').forEach((img) => { + root.querySelectorAll('img[width="1"][height="1"]').forEach((img) => { img.remove(); blocked++; }); - doc.querySelectorAll('img[width="0"]').forEach((img) => { + root.querySelectorAll('img[width="0"]').forEach((img) => { img.remove(); blocked++; }); - doc + root .querySelectorAll( 'img[style*="display:none"], img[style*="display: none"]', ) @@ -2633,7 +2665,46 @@ function processHtmlImages( }); } - return [doc.body.innerHTML, blocked]; + if (policy === "block-all") { + root.querySelectorAll("[style]").forEach((el) => { + const [style, count] = stripCssRemoteResources( + el.getAttribute("style") ?? "", + ); + if (count === 0) return; + blocked += count; + if (style.trim()) { + el.setAttribute("style", style); + } else { + el.removeAttribute("style"); + } + }); + + root.querySelectorAll("style").forEach((styleEl) => { + const [css, count] = stripCssRemoteResources(styleEl.textContent ?? ""); + if (count === 0) return; + blocked += count; + if (css.trim()) { + styleEl.textContent = css; + } else { + styleEl.remove(); + } + }); + + root + .querySelectorAll( + "[background], [poster], source[src], video[src], audio[src], track[src]", + ) + .forEach((el) => { + for (const attrName of ["background", "poster", "src"]) { + const value = el.getAttribute(attrName); + if (!value || isInlineImageUrl(value)) continue; + el.removeAttribute(attrName); + blocked++; + } + }); + } + + return [template.innerHTML, blocked]; } function HtmlEmailBody({ @@ -2679,10 +2750,21 @@ function HtmlEmailBody({ : imagePolicy : imagePolicy; - const [processedHtml, blockedCount] = useMemo( - () => processHtmlImages(sanitizedHtml.bodyHtml, effectivePolicy), - [sanitizedHtml.bodyHtml, effectivePolicy], - ); + const processedEmailHtml = useMemo(() => { + const [headHtml, headBlockedCount] = processHtmlImages( + sanitizedHtml.headHtml, + effectivePolicy, + ); + const [bodyHtml, bodyBlockedCount] = processHtmlImages( + sanitizedHtml.bodyHtml, + effectivePolicy, + ); + return { + headHtml, + bodyHtml, + blockedCount: headBlockedCount + bodyBlockedCount, + }; + }, [sanitizedHtml.headHtml, sanitizedHtml.bodyHtml, effectivePolicy]); const handleAlwaysTrust = () => { if (!senderDomain) return; @@ -2778,10 +2860,10 @@ function HtmlEmailBody({ - ${sanitizedHtml.headHtml} + ${processedEmailHtml.headHtml} -${processedHtml} +${processedEmailHtml.bodyHtml} `); doc.close(); @@ -3263,8 +3345,8 @@ function HtmlEmailBody({ images.forEach((img) => img.removeEventListener("load", resize)); }; }, [ - processedHtml, - sanitizedHtml.headHtml, + processedEmailHtml.bodyHtml, + processedEmailHtml.headHtml, isDark, useDarkIframeCss, IFRAME_BG, @@ -3334,7 +3416,7 @@ function HtmlEmailBody({ // Small delay to ensure iframe DOM is ready after a processedHtml rewrite const timer = setTimeout(injectHighlights, 60); return () => clearTimeout(timer); - }, [searchTerm, processedHtml]); + }, [searchTerm, processedEmailHtml.bodyHtml]); // Update which mark is "active" and scroll it into view useEffect(() => { @@ -3359,7 +3441,7 @@ function HtmlEmailBody({ const showBanner = effectivePolicy === "block-all" && - blockedCount > 0 && + processedEmailHtml.blockedCount > 0 && (isEmbedded || !showImagesForThread); return ( From 3125bb27623dc9357d8f500f6fa69cef750676be Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Sat, 23 May 2026 07:18:07 -0700 Subject: [PATCH 5/8] refactor(mail): extract email image policy into testable module Move processHtmlImages, isInlineImageUrl, stripCssRemoteResources, decodeHtmlEntities, isTrackingUrl, and the TRACKER_DOMAINS list out of the heavy EmailThread.tsx component into a new app/lib/email-image-policy.ts so the policy can be unit-tested without pulling in the whole React component tree (which transitively imports opentelemetry and breaks vitest). Replaces the broken EmailThread.spec.ts (which fails at import time) with app/lib/email-image-policy.spec.ts containing the same 4 regression tests plus the @vitest-environment happy-dom pragma so DOMParser is available. --- .../mail/app/components/email/EmailThread.tsx | 188 +----------------- .../email-image-policy.spec.ts} | 4 +- templates/mail/app/lib/email-image-policy.ts | 185 +++++++++++++++++ 3 files changed, 191 insertions(+), 186 deletions(-) rename templates/mail/app/{components/email/EmailThread.spec.ts => lib/email-image-policy.spec.ts} (95%) create mode 100644 templates/mail/app/lib/email-image-policy.ts diff --git a/templates/mail/app/components/email/EmailThread.tsx b/templates/mail/app/components/email/EmailThread.tsx index ee76d3d37..33a61b557 100644 --- a/templates/mail/app/components/email/EmailThread.tsx +++ b/templates/mail/app/components/email/EmailThread.tsx @@ -35,6 +35,10 @@ import { useQueryClient } from "@tanstack/react-query"; import { ensureThread, warmThreads } from "@/lib/thread-cache"; import { getResolvedTheme } from "@/lib/theme"; import { appApiPath } from "@/lib/api-path"; +import { + decodeHtmlEntities, + processHtmlImages, +} from "@/lib/email-image-policy"; import { isMcpEmbedSurface } from "@/lib/mcp-embed"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { setUndoAction } from "@/hooks/use-undo"; @@ -2437,75 +2441,11 @@ function emailHasDesignedBackground(html: string): boolean { return false; } -// Known tracking pixel domains (partial matches against hostname) -const TRACKER_DOMAINS = [ - "open.convertkit-", - "pixel.mailchimp.com", - "list-manage.com/track", - "t.sendinblue.com", - "t.sidekickopen", - "t.semail.", - "tracking.tldrnewsletter.com", - "links.iterable.com", - "email.mg.", - "trk.klclick", - "beacon.krxd.net", - "r.sup.sh", // Superhuman - "t.superhuman.com", - "track.hubspot", - "track.customer.io", - "ct.sendgrid.net", - "sendgrid.net/wf/open", - "mandrillapp.com/track", - "mailgun.org/track", - "go.pardot.com", - "analytics.google.com", - "google-analytics.com", - "bat.bing.com", - "facebook.com/tr", - "connect.facebook.net", - "ad.doubleclick.net", - "demdex.net", - "omtrdc.net", - "ml.klaviyo.com", - "trk.klaviyo.com", -]; - -function isTrackingUrl(src: string): boolean { - try { - const url = new URL(src); - const full = url.hostname + url.pathname; - return TRACKER_DOMAINS.some((d) => full.includes(d)); - } catch { - return false; - } -} - type SanitizedEmailHtml = { headHtml: string; bodyHtml: string; }; -function decodeHtmlEntities(value: string): string { - let decoded = value; - for (let i = 0; i < 3; i++) { - const next = decoded - .replace(/&#x([0-9a-f]+);?/gi, (_, hex: string) => - String.fromCodePoint(Number.parseInt(hex, 16)), - ) - .replace(/&#(\d+);?/g, (_, dec: string) => - String.fromCodePoint(Number.parseInt(dec, 10)), - ) - .replace(/:?/gi, ":") - .replace(/&tab;?/gi, "\t") - .replace(/&newline;?/gi, "\n") - .replace(/&?/gi, "&"); - if (next === decoded) break; - decoded = next; - } - return decoded; -} - function isSafeEmailUrl(value: string, kind: "link" | "image"): boolean { const decoded = decodeHtmlEntities(value).trim(); if (!decoded) return false; @@ -2587,126 +2527,6 @@ function sanitizeEmailHtml(html: string): SanitizedEmailHtml { }; } -function isInlineImageUrl(value: string): boolean { - const lower = decodeHtmlEntities(value) - .trim() - .replace(/[\s\u0000-\u001f\u007f]+/g, "") - .toLowerCase(); - return lower.startsWith("data:image/") || lower.startsWith("cid:"); -} - -function stripCssRemoteResources(css: string): [string, number] { - let blocked = 0; - const withoutImports = css.replace( - /@import\s+(?:url\(\s*)?(['"]?)[\s\S]*?\1\s*\)?[^;]*;?/gi, - () => { - blocked++; - return ""; - }, - ); - const withoutRemoteUrls = withoutImports.replace( - /url\(\s*(['"]?)([\s\S]*?)\1\s*\)/gi, - (match, _quote: string, rawUrl: string) => { - if (isInlineImageUrl(rawUrl)) return match; - blocked++; - return "none"; - }, - ); - - return [withoutRemoteUrls, blocked]; -} - -/** Strip images from HTML based on policy. Returns [processedHtml, imageCount]. */ -export function processHtmlImages( - html: string, - policy: "show" | "block-trackers" | "block-all", -): [string, number] { - if (policy === "show") return [html, 0]; - - const parser = new DOMParser(); - const doc = parser.parseFromString(html, "text/html"); - const template = doc.createElement("template"); - template.innerHTML = html; - const root = template.content; - const images = root.querySelectorAll("img"); - let blocked = 0; - - images.forEach((img) => { - const src = img.getAttribute("src") || ""; - if (!src || isInlineImageUrl(src)) return; - - if (policy === "block-all") { - img.removeAttribute("src"); - img.setAttribute("data-blocked-src", src); - blocked++; - } else if (policy === "block-trackers" && isTrackingUrl(src)) { - img.remove(); - blocked++; - } - }); - - // Also strip tracking pixel style tags (1x1 images via CSS background) - if (policy === "block-trackers" || policy === "block-all") { - root.querySelectorAll('img[width="1"][height="1"]').forEach((img) => { - img.remove(); - blocked++; - }); - root.querySelectorAll('img[width="0"]').forEach((img) => { - img.remove(); - blocked++; - }); - root - .querySelectorAll( - 'img[style*="display:none"], img[style*="display: none"]', - ) - .forEach((img) => { - img.remove(); - blocked++; - }); - } - - if (policy === "block-all") { - root.querySelectorAll("[style]").forEach((el) => { - const [style, count] = stripCssRemoteResources( - el.getAttribute("style") ?? "", - ); - if (count === 0) return; - blocked += count; - if (style.trim()) { - el.setAttribute("style", style); - } else { - el.removeAttribute("style"); - } - }); - - root.querySelectorAll("style").forEach((styleEl) => { - const [css, count] = stripCssRemoteResources(styleEl.textContent ?? ""); - if (count === 0) return; - blocked += count; - if (css.trim()) { - styleEl.textContent = css; - } else { - styleEl.remove(); - } - }); - - root - .querySelectorAll( - "[background], [poster], source[src], video[src], audio[src], track[src]", - ) - .forEach((el) => { - for (const attrName of ["background", "poster", "src"]) { - const value = el.getAttribute(attrName); - if (!value || isInlineImageUrl(value)) continue; - el.removeAttribute(attrName); - blocked++; - } - }); - } - - return [template.innerHTML, blocked]; -} - function HtmlEmailBody({ html, senderEmail, diff --git a/templates/mail/app/components/email/EmailThread.spec.ts b/templates/mail/app/lib/email-image-policy.spec.ts similarity index 95% rename from templates/mail/app/components/email/EmailThread.spec.ts rename to templates/mail/app/lib/email-image-policy.spec.ts index b18bfd1e0..a53d125a8 100644 --- a/templates/mail/app/components/email/EmailThread.spec.ts +++ b/templates/mail/app/lib/email-image-policy.spec.ts @@ -1,7 +1,7 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { describe, expect, it } from "vitest"; -import { processHtmlImages } from "./EmailThread"; +import { processHtmlImages } from "./email-image-policy"; describe("processHtmlImages", () => { it("leaves CSS image URLs untouched when images are shown", () => { diff --git a/templates/mail/app/lib/email-image-policy.ts b/templates/mail/app/lib/email-image-policy.ts new file mode 100644 index 000000000..f44fee26a --- /dev/null +++ b/templates/mail/app/lib/email-image-policy.ts @@ -0,0 +1,185 @@ +export type EmailImagePolicy = "show" | "block-trackers" | "block-all"; + +// Known tracking pixel domains (partial matches against hostname) +const TRACKER_DOMAINS = [ + "open.convertkit-", + "pixel.mailchimp.com", + "list-manage.com/track", + "t.sendinblue.com", + "t.sidekickopen", + "t.semail.", + "tracking.tldrnewsletter.com", + "links.iterable.com", + "email.mg.", + "trk.klclick", + "beacon.krxd.net", + "r.sup.sh", // Superhuman + "t.superhuman.com", + "track.hubspot", + "track.customer.io", + "ct.sendgrid.net", + "sendgrid.net/wf/open", + "mandrillapp.com/track", + "mailgun.org/track", + "go.pardot.com", + "analytics.google.com", + "google-analytics.com", + "bat.bing.com", + "facebook.com/tr", + "connect.facebook.net", + "ad.doubleclick.net", + "demdex.net", + "omtrdc.net", + "ml.klaviyo.com", + "trk.klaviyo.com", +]; + +function isTrackingUrl(src: string): boolean { + try { + const url = new URL(src); + const full = url.hostname + url.pathname; + return TRACKER_DOMAINS.some((d) => full.includes(d)); + } catch { + return false; + } +} + +export function decodeHtmlEntities(value: string): string { + let decoded = value; + for (let i = 0; i < 3; i++) { + const next = decoded + .replace(/&#x([0-9a-f]+);?/gi, (_, hex: string) => + String.fromCodePoint(Number.parseInt(hex, 16)), + ) + .replace(/&#(\d+);?/g, (_, dec: string) => + String.fromCodePoint(Number.parseInt(dec, 10)), + ) + .replace(/:?/gi, ":") + .replace(/&tab;?/gi, "\t") + .replace(/&newline;?/gi, "\n") + .replace(/&?/gi, "&"); + if (next === decoded) break; + decoded = next; + } + return decoded; +} + +function isInlineImageUrl(value: string): boolean { + const lower = decodeHtmlEntities(value) + .trim() + .replace(/[\s\u0000-\u001f\u007f]+/g, "") + .toLowerCase(); + return lower.startsWith("data:image/") || lower.startsWith("cid:"); +} + +function stripCssRemoteResources(css: string): [string, number] { + let blocked = 0; + const withoutImports = css.replace( + /@import\s+(?:url\(\s*)?(['"]?)[\s\S]*?\1\s*\)?[^;]*;?/gi, + () => { + blocked++; + return ""; + }, + ); + const withoutRemoteUrls = withoutImports.replace( + /url\(\s*(['"]?)([\s\S]*?)\1\s*\)/gi, + (match, _quote: string, rawUrl: string) => { + if (isInlineImageUrl(rawUrl)) return match; + blocked++; + return "none"; + }, + ); + + return [withoutRemoteUrls, blocked]; +} + +/** Strip images from HTML based on policy. Returns [processedHtml, imageCount]. */ +export function processHtmlImages( + html: string, + policy: EmailImagePolicy, +): [string, number] { + if (policy === "show") return [html, 0]; + + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + const template = doc.createElement("template"); + template.innerHTML = html; + const root = template.content; + const images = root.querySelectorAll("img"); + let blocked = 0; + + images.forEach((img) => { + const src = img.getAttribute("src") || ""; + if (!src || isInlineImageUrl(src)) return; + + if (policy === "block-all") { + img.removeAttribute("src"); + img.setAttribute("data-blocked-src", src); + blocked++; + } else if (policy === "block-trackers" && isTrackingUrl(src)) { + img.remove(); + blocked++; + } + }); + + // Also strip tracking pixel style tags (1x1 images via CSS background) + if (policy === "block-trackers" || policy === "block-all") { + root.querySelectorAll('img[width="1"][height="1"]').forEach((img) => { + img.remove(); + blocked++; + }); + root.querySelectorAll('img[width="0"]').forEach((img) => { + img.remove(); + blocked++; + }); + root + .querySelectorAll( + 'img[style*="display:none"], img[style*="display: none"]', + ) + .forEach((img) => { + img.remove(); + blocked++; + }); + } + + if (policy === "block-all") { + root.querySelectorAll("[style]").forEach((el) => { + const [style, count] = stripCssRemoteResources( + el.getAttribute("style") ?? "", + ); + if (count === 0) return; + blocked += count; + if (style.trim()) { + el.setAttribute("style", style); + } else { + el.removeAttribute("style"); + } + }); + + root.querySelectorAll("style").forEach((styleEl) => { + const [css, count] = stripCssRemoteResources(styleEl.textContent ?? ""); + if (count === 0) return; + blocked += count; + if (css.trim()) { + styleEl.textContent = css; + } else { + styleEl.remove(); + } + }); + + root + .querySelectorAll( + "[background], [poster], source[src], video[src], audio[src], track[src]", + ) + .forEach((el) => { + for (const attrName of ["background", "poster", "src"]) { + const value = el.getAttribute(attrName); + if (!value || isInlineImageUrl(value)) continue; + el.removeAttribute(attrName); + blocked++; + } + }); + } + + return [template.innerHTML, blocked]; +} From eae0c96539a4441979d5fc93718eff46fe64f9ec Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Sat, 23 May 2026 07:34:04 -0700 Subject: [PATCH 6/8] fix(mail): block link resource loads in embeds --- templates/mail/app/lib/email-image-policy.spec.ts | 12 ++++++++++++ templates/mail/app/lib/email-image-policy.ts | 5 +++++ 2 files changed, 17 insertions(+) diff --git a/templates/mail/app/lib/email-image-policy.spec.ts b/templates/mail/app/lib/email-image-policy.spec.ts index a53d125a8..9b4a3f536 100644 --- a/templates/mail/app/lib/email-image-policy.spec.ts +++ b/templates/mail/app/lib/email-image-policy.spec.ts @@ -42,6 +42,18 @@ describe("processHtmlImages", () => { expect(html).toContain("data:image/png;base64,abcd"); }); + it("removes link elements with remote hrefs", () => { + const input = + '

Hello

'; + + const [html, blockedCount] = processHtmlImages(input, "block-all"); + + expect(blockedCount).toBe(2); + expect(html).not.toContain(" { const input = '
Hi
'; diff --git a/templates/mail/app/lib/email-image-policy.ts b/templates/mail/app/lib/email-image-policy.ts index f44fee26a..358cc1151 100644 --- a/templates/mail/app/lib/email-image-policy.ts +++ b/templates/mail/app/lib/email-image-policy.ts @@ -143,6 +143,11 @@ export function processHtmlImages( } if (policy === "block-all") { + root.querySelectorAll("link[href]").forEach((link) => { + link.remove(); + blocked++; + }); + root.querySelectorAll("[style]").forEach((el) => { const [style, count] = stripCssRemoteResources( el.getAttribute("style") ?? "", From 4f50447f8f1086b4fde0f472cd23b11f0f11c7ab Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Sat, 23 May 2026 07:36:27 -0700 Subject: [PATCH 7/8] chore: rerun ci after checkout failure From b4f9d90cbaed8f4cfbcfe5de986e7a759b888a5f Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Sat, 23 May 2026 07:50:30 -0700 Subject: [PATCH 8/8] fix(mail): block svg remote resources in embeds --- .../mail/app/lib/email-image-policy.spec.ts | 39 +++++++++++++++++++ templates/mail/app/lib/email-image-policy.ts | 26 ++++++++++--- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/templates/mail/app/lib/email-image-policy.spec.ts b/templates/mail/app/lib/email-image-policy.spec.ts index 9b4a3f536..097f5e241 100644 --- a/templates/mail/app/lib/email-image-policy.spec.ts +++ b/templates/mail/app/lib/email-image-policy.spec.ts @@ -42,6 +42,24 @@ describe("processHtmlImages", () => { expect(html).toContain("data:image/png;base64,abcd"); }); + it("preserves same-document CSS fragment URLs while blocking remote URLs", () => { + const input = [ + "", + '
Hello
', + ].join(""); + + const [html, blockedCount] = processHtmlImages(input, "block-all"); + + expect(blockedCount).toBe(2); + expect(html).toContain("url(#clipPath)"); + expect(html).toContain('url("#mask")'); + expect(html).toContain("url(#shadow)"); + expect(html).not.toContain("https://cdn.example.com/hero.png"); + expect(html).not.toContain("https://cdn.example.com/card.png"); + }); + it("removes link elements with remote hrefs", () => { const input = '

Hello

'; @@ -64,4 +82,25 @@ describe("processHtmlImages", () => { expect(html).not.toContain("https://cdn.example.com/bg.png"); expect(html).not.toContain("https://cdn.example.com/poster.png"); }); + + it("removes remote SVG fetch attributes while preserving local references", () => { + const input = [ + "", + '', + '', + '', + '', + "", + ].join(""); + + const [html, blockedCount] = processHtmlImages(input, "block-all"); + + expect(blockedCount).toBe(3); + expect(html).not.toContain("https://cdn.example.com/pixel.png"); + expect(html).not.toContain("https://cdn.example.com/filter.png"); + expect(html).not.toContain("https://cdn.example.com/sprite.svg#icon"); + expect(html).toContain("cid:logo"); + expect(html).toContain("#filterSource"); + expect(html).toContain("#symbol"); + }); }); diff --git a/templates/mail/app/lib/email-image-policy.ts b/templates/mail/app/lib/email-image-policy.ts index 358cc1151..f177dbbef 100644 --- a/templates/mail/app/lib/email-image-policy.ts +++ b/templates/mail/app/lib/email-image-policy.ts @@ -64,12 +64,16 @@ export function decodeHtmlEntities(value: string): string { return decoded; } -function isInlineImageUrl(value: string): boolean { +function isAllowedNonRemoteResourceUrl(value: string): boolean { const lower = decodeHtmlEntities(value) .trim() .replace(/[\s\u0000-\u001f\u007f]+/g, "") .toLowerCase(); - return lower.startsWith("data:image/") || lower.startsWith("cid:"); + return ( + lower.startsWith("data:image/") || + lower.startsWith("cid:") || + lower.startsWith("#") + ); } function stripCssRemoteResources(css: string): [string, number] { @@ -84,7 +88,7 @@ function stripCssRemoteResources(css: string): [string, number] { const withoutRemoteUrls = withoutImports.replace( /url\(\s*(['"]?)([\s\S]*?)\1\s*\)/gi, (match, _quote: string, rawUrl: string) => { - if (isInlineImageUrl(rawUrl)) return match; + if (isAllowedNonRemoteResourceUrl(rawUrl)) return match; blocked++; return "none"; }, @@ -110,7 +114,7 @@ export function processHtmlImages( images.forEach((img) => { const src = img.getAttribute("src") || ""; - if (!src || isInlineImageUrl(src)) return; + if (!src || isAllowedNonRemoteResourceUrl(src)) return; if (policy === "block-all") { img.removeAttribute("src"); @@ -179,11 +183,23 @@ export function processHtmlImages( .forEach((el) => { for (const attrName of ["background", "poster", "src"]) { const value = el.getAttribute(attrName); - if (!value || isInlineImageUrl(value)) continue; + if (!value || isAllowedNonRemoteResourceUrl(value)) continue; el.removeAttribute(attrName); blocked++; } }); + + const svgResourceElements = new Set(["feimage", "image", "use"]); + root.querySelectorAll("*").forEach((el) => { + if (!svgResourceElements.has(el.localName.toLowerCase())) return; + + for (const attrName of ["href", "xlink:href"]) { + const value = el.getAttribute(attrName); + if (!value || isAllowedNonRemoteResourceUrl(value)) continue; + el.removeAttribute(attrName); + blocked++; + } + }); } return [template.innerHTML, blocked];