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(); 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..33a61b557 100644 --- a/templates/mail/app/components/email/EmailThread.tsx +++ b/templates/mail/app/components/email/EmailThread.tsx @@ -35,6 +35,11 @@ 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"; import { toast } from "sonner"; @@ -2436,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; @@ -2586,55 +2527,6 @@ function sanitizeEmailHtml(html: string): SanitizedEmailHtml { }; } -/** Strip images from HTML based on policy. Returns [processedHtml, imageCount]. */ -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 images = doc.querySelectorAll("img"); - let blocked = 0; - - images.forEach((img) => { - const src = img.getAttribute("src") || ""; - if (!src || src.startsWith("data:") || src.startsWith("cid:")) 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") { - doc.querySelectorAll('img[width="1"][height="1"]').forEach((img) => { - img.remove(); - blocked++; - }); - doc.querySelectorAll('img[width="0"]').forEach((img) => { - img.remove(); - blocked++; - }); - doc - .querySelectorAll( - 'img[style*="display:none"], img[style*="display: none"]', - ) - .forEach((img) => { - img.remove(); - blocked++; - }); - } - - return [doc.body.innerHTML, blocked]; -} - function HtmlEmailBody({ html, senderEmail, @@ -2657,6 +2549,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,17 +2562,29 @@ 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 : 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; @@ -2775,10 +2680,10 @@ function HtmlEmailBody({ - ${sanitizedHtml.headHtml} + ${processedEmailHtml.headHtml} -${processedHtml} +${processedEmailHtml.bodyHtml} `); doc.close(); @@ -3260,8 +3165,8 @@ function HtmlEmailBody({ images.forEach((img) => img.removeEventListener("load", resize)); }; }, [ - processedHtml, - sanitizedHtml.headHtml, + processedEmailHtml.bodyHtml, + processedEmailHtml.headHtml, isDark, useDarkIframeCss, IFRAME_BG, @@ -3331,7 +3236,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(() => { @@ -3355,27 +3260,37 @@ function HtmlEmailBody({ }, [activeLocalIdx, searchTerm]); const showBanner = - effectivePolicy === "block-all" && blockedCount > 0 && !showImagesForThread; + effectivePolicy === "block-all" && + processedEmailHtml.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 ? ( 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 +1227,12 @@ function AppLayoutInner({ children }: AppLayoutProps) { zIndex: accounts.length - i, }} > - {account.photoUrl ? ( - - ) : ( -
- {account.email[0]?.toUpperCase()} -
- )} +
); })} @@ -1344,18 +1369,12 @@ function AppLayoutInner({ children }: AppLayoutProps) { isActive ? "opacity-100" : "opacity-30", )} > - {account.photoUrl ? ( - - ) : ( -
- {account.email[0]?.toUpperCase()} -
- )} + {account.email} @@ -2148,18 +2167,12 @@ function AccountPopover({ )} - {account.photoUrl ? ( - - ) : ( -
- {account.email[0]?.toUpperCase()} -
- )} + {account.email} diff --git a/templates/mail/app/lib/email-image-policy.spec.ts b/templates/mail/app/lib/email-image-policy.spec.ts new file mode 100644 index 000000000..097f5e241 --- /dev/null +++ b/templates/mail/app/lib/email-image-policy.spec.ts @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from "vitest"; +import { processHtmlImages } from "./email-image-policy"; + +describe("processHtmlImages", () => { + it("leaves CSS image URLs untouched when images are shown", () => { + const input = + '
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("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

'; + + const [html, blockedCount] = processHtmlImages(input, "block-all"); + + expect(blockedCount).toBe(2); + expect(html).not.toContain(" { + 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"); + }); + + 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 new file mode 100644 index 000000000..f177dbbef --- /dev/null +++ b/templates/mail/app/lib/email-image-policy.ts @@ -0,0 +1,206 @@ +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 isAllowedNonRemoteResourceUrl(value: string): boolean { + const lower = decodeHtmlEntities(value) + .trim() + .replace(/[\s\u0000-\u001f\u007f]+/g, "") + .toLowerCase(); + return ( + lower.startsWith("data:image/") || + lower.startsWith("cid:") || + lower.startsWith("#") + ); +} + +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 (isAllowedNonRemoteResourceUrl(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 || isAllowedNonRemoteResourceUrl(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("link[href]").forEach((link) => { + link.remove(); + blocked++; + }); + + 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 || 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]; +} diff --git a/templates/mail/app/lib/mcp-embed.spec.ts b/templates/mail/app/lib/mcp-embed.spec.ts new file mode 100644 index 000000000..59f816267 --- /dev/null +++ b/templates/mail/app/lib/mcp-embed.spec.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isMcpEmbedSurface } from "./mcp-embed"; + +describe("isMcpEmbedSurface", () => { + 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"; +}