Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion templates/mail/app/components/email/ComposeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
24 changes: 15 additions & 9 deletions templates/mail/app/components/email/EmailList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(() => {
Expand All @@ -145,15 +147,19 @@ export function InboxZero() {
return (
<div className="relative flex-1 flex flex-col overflow-hidden">
{/* Background image — fixed so it extends behind header + agent sidebar for blur */}
<img
src={imageUrl}
alt=""
onLoad={() => setLoaded(true)}
className={cn(
"fixed inset-0 h-full w-full object-cover",
loaded ? "opacity-100" : "opacity-0",
)}
/>
{isEmbedded ? (
<div className="fixed inset-0 bg-[linear-gradient(135deg,hsl(220,18%,11%),hsl(203,22%,18%)_55%,hsl(168,24%,16%))]" />
) : (
<img
src={imageUrl}
alt=""
onLoad={() => 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. */}
<div className="fixed inset-0 bg-black/20" />
Expand Down
193 changes: 54 additions & 139 deletions templates/mail/app/components/email/EmailThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(/&colon;?/gi, ":")
.replace(/&tab;?/gi, "\t")
.replace(/&newline;?/gi, "\n")
.replace(/&amp;?/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;
Expand Down Expand Up @@ -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,
Expand All @@ -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 ?? [];
Expand All @@ -2669,17 +2562,29 @@ function HtmlEmailBody({
const [showImagesForThread, setShowImagesForThread] = useState(false);

// Determine effective policy for this email
const effectivePolicy =
isTrusted || showImagesForThread
const effectivePolicy = isEmbedded
Comment thread
builder-io-integration[bot] marked this conversation as resolved.
? "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;
Expand Down Expand Up @@ -2775,10 +2680,10 @@ function HtmlEmailBody({
<html>
<head>
<meta charset="utf-8">
${sanitizedHtml.headHtml}
${processedEmailHtml.headHtml}
<style>${iframeCss} </style>
</head>
<body>${processedHtml}</body>
<body>${processedEmailHtml.bodyHtml}</body>
</html>`);
doc.close();

Expand Down Expand Up @@ -3260,8 +3165,8 @@ function HtmlEmailBody({
images.forEach((img) => img.removeEventListener("load", resize));
};
}, [
processedHtml,
sanitizedHtml.headHtml,
processedEmailHtml.bodyHtml,
processedEmailHtml.headHtml,
isDark,
useDarkIframeCss,
IFRAME_BG,
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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 (
<div>
{showBanner && (
<div className="flex items-center gap-2 px-3 py-1.5 mb-2 rounded-md bg-accent/60 text-[12px] text-muted-foreground">
<IconPhoto className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
<span>Images blocked.</span>
<button
onClick={() => setShowImagesForThread(true)}
className="text-primary hover:text-primary/80 font-medium transition-colors"
>
Show images
</button>
{senderEmail && (
<button
onClick={handleAlwaysTrust}
className="text-muted-foreground/60 hover:text-muted-foreground font-medium transition-colors"
>
Always from {senderEmail.split("@")[1]}
</button>
<span>
{isEmbedded
? "Remote images hidden in this embed."
: "Images blocked."}
</span>
{!isEmbedded && (
<>
<button
onClick={() => setShowImagesForThread(true)}
className="text-primary hover:text-primary/80 font-medium transition-colors"
>
Show images
</button>
{senderEmail && (
<button
onClick={handleAlwaysTrust}
className="text-muted-foreground/60 hover:text-muted-foreground font-medium transition-colors"
>
Always from {senderEmail.split("@")[1]}
</button>
)}
</>
)}
</div>
)}
Expand Down
14 changes: 11 additions & 3 deletions templates/mail/app/components/email/IntegrationsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 */}
<div className="px-4 pt-4 pb-3 flex items-start gap-3">
{person.photo_url && (
{shouldLoadRemotePhoto ? (
<img
src={person.photo_url}
alt=""
className="h-9 w-9 rounded-full object-cover shrink-0 mt-0.5"
referrerPolicy="no-referrer"
/>
)}
) : person.photo_url ? (
<div className="h-9 w-9 rounded-full bg-primary/15 flex items-center justify-center text-[12px] font-semibold text-primary shrink-0 mt-0.5">
{name[0]?.toUpperCase()}
</div>
) : null}
<div className="min-w-0">
<h3 className="text-[14px] font-semibold text-foreground truncate">
{name}
Expand Down Expand Up @@ -756,7 +764,7 @@ function ApolloSection({ email }: { email: string }) {
<div className="h-px bg-border/30 mx-4" />
<div className="px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
{person.organization.logo_url ? (
{shouldLoadRemoteLogo ? (
<img
src={person.organization.logo_url}
alt=""
Expand Down
Loading
Loading