- {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 =
+ '
';
+
+ 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(/([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";
+}