new email renderer (#1584)

# Replace iFrame with Shadow DOM for Email Content Rendering

## Description

Replaced the `MailIframe` component with a new `MailContent` component that uses Shadow DOM instead of iframes for rendering email content. This approach provides better security isolation while maintaining styling control and performance. The implementation uses DOMPurify for sanitization and includes proper handling of external images, trusted senders, and theme adaptation.

## Type of Change

-  New feature (non-breaking change which adds functionality)
- 🔒 Security enhancement
-  Performance improvement

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Introduced a new email content viewer that securely displays HTML emails with improved security, style isolation, and theme support.
  * Added user controls to manage image loading and trust email senders directly from the email view.

* **Refactor**
  * Replaced the previous email iframe viewer with the new secure content viewer, streamlining functionality and enhancing user experience.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ahmet Kilinc
2025-07-04 03:17:25 +01:00
committed by GitHub
parent 142bbaac06
commit f623c912e4
6 changed files with 381 additions and 182 deletions

View File

@@ -0,0 +1,198 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { defaultUserSettings } from '@zero/server/schemas';
import { fixNonReadableColors } from '@/lib/email-utils';
import { useTRPC } from '@/providers/query-provider';
import { getBrowserTimezone } from '@/lib/timezones';
import { useSettings } from '@/hooks/use-settings';
import { m } from '@/paraglide/messages';
import { useTheme } from 'next-themes';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
interface MailContentProps {
html: string;
senderEmail: string;
}
export function MailContent({ html, senderEmail }: MailContentProps) {
const { data, refetch } = useSettings();
const queryClient = useQueryClient();
const isTrustedSender = useMemo(
() => data?.settings?.externalImages || data?.settings?.trustedSenders?.includes(senderEmail),
[data?.settings, senderEmail],
);
const [cspViolation, setCspViolation] = useState(false);
const [temporaryImagesEnabled, setTemporaryImagesEnabled] = useState(false);
const hostRef = useRef<HTMLDivElement>(null);
const shadowRootRef = useRef<ShadowRoot | null>(null);
const { resolvedTheme } = useTheme();
const trpc = useTRPC();
const { mutateAsync: saveUserSettings } = useMutation({
...trpc.settings.save.mutationOptions(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] });
},
});
const { mutateAsync: trustSender } = useMutation({
mutationFn: async () => {
const existingSettings = data?.settings ?? {
...defaultUserSettings,
timezone: getBrowserTimezone(),
};
const { success } = await saveUserSettings({
...existingSettings,
trustedSenders: data?.settings?.trustedSenders
? data.settings.trustedSenders.concat(senderEmail)
: [senderEmail],
});
if (!success) {
throw new Error('Failed to trust sender');
}
},
onSuccess: () => {
refetch();
},
onError: () => {
toast.error('Failed to trust sender');
},
});
const { mutateAsync: processEmailContent } = useMutation(
trpc.mail.processEmailContent.mutationOptions(),
);
const { data: processedData } = useQuery({
queryKey: ['email-content', html, isTrustedSender || temporaryImagesEnabled, resolvedTheme],
queryFn: async () => {
const result = await processEmailContent({
html,
shouldLoadImages: isTrustedSender || temporaryImagesEnabled,
theme: (resolvedTheme as 'light' | 'dark') || 'light',
});
if (result.hasBlockedImages) {
setCspViolation(true);
}
return result.processedHtml;
},
staleTime: 30 * 60 * 1000,
gcTime: 60 * 60 * 1000,
refetchOnWindowFocus: false,
refetchOnMount: false,
});
useEffect(() => {
if (!hostRef.current || shadowRootRef.current) return;
shadowRootRef.current = hostRef.current.attachShadow({ mode: 'open' });
}, []);
useEffect(() => {
if (!shadowRootRef.current || !processedData) return;
shadowRootRef.current.innerHTML = processedData;
}, [processedData]);
useEffect(() => {
if (!shadowRootRef.current) return;
const root = shadowRootRef.current;
const applyFix: () => void = () => {
const topLevelEls = Array.from(root.children) as HTMLElement[];
topLevelEls.forEach((el) => {
try {
fixNonReadableColors(el, {
defaultBackground: resolvedTheme === 'dark' ? 'rgb(10,10,10)' : '#ffffff',
});
} catch (err) {
console.error('Failed to fix colors in email content:', err);
}
});
};
requestAnimationFrame(applyFix);
}, [processedData, resolvedTheme]);
useEffect(() => {
if (isTrustedSender || temporaryImagesEnabled) {
setCspViolation(false);
}
}, [isTrustedSender, temporaryImagesEnabled]);
const handleImageError = useCallback(
(e: Event) => {
const target = e.target as HTMLImageElement;
if (target.tagName === 'IMG') {
if (!(isTrustedSender || temporaryImagesEnabled)) {
setCspViolation(true);
}
target.style.display = 'none';
}
},
[isTrustedSender, temporaryImagesEnabled],
);
useEffect(() => {
if (!shadowRootRef.current) return;
shadowRootRef.current.addEventListener('error', handleImageError, true);
const handleClick = (e: Event) => {
const target = e.target as HTMLElement;
if (target.tagName === 'A') {
e.preventDefault();
const href = target.getAttribute('href');
if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
window.open(href, '_blank', 'noopener,noreferrer');
} else if (href && href.startsWith('mailto:')) {
window.location.href = href;
}
}
};
shadowRootRef.current.addEventListener('click', handleClick);
return () => {
shadowRootRef.current?.removeEventListener('error', handleImageError, true);
shadowRootRef.current?.removeEventListener('click', handleClick);
};
}, [handleImageError, processedData]);
return (
<>
{cspViolation && !isTrustedSender && !data?.settings?.externalImages && (
<div className="flex items-center justify-start bg-amber-600/20 px-2 py-1 text-sm text-amber-600">
<p>{m['common.actions.hiddenImagesWarning']()}</p>
<button
onClick={() => setTemporaryImagesEnabled(!temporaryImagesEnabled)}
className="ml-2 cursor-pointer underline"
>
{temporaryImagesEnabled
? m['common.actions.disableImages']()
: m['common.actions.showImages']()}
</button>
<button
onClick={async () => {
try {
await trustSender();
} catch (error) {
console.error('Error trusting sender:', error);
}
}}
className="ml-2 cursor-pointer underline"
>
{m['common.actions.trustSender']()}
</button>
</div>
)}
<div ref={hostRef} className={cn('mail-content w-full flex-1 overflow-hidden')} />
</>
);
}

View File

@@ -47,7 +47,7 @@ import { Markdown } from '@react-email/components';
import { useSummary } from '@/hooks/use-summary';
import { TextShimmer } from '../ui/text-shimmer';
import { RenderLabels } from './render-labels';
import { MailIframe } from './mail-iframe';
import { MailContent } from './mail-content';
import { m } from '@/paraglide/messages';
import { useParams } from 'react-router';
import { FileText } from 'lucide-react';
@@ -1205,7 +1205,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
<!-- Email Body -->
<div class="email-body">
<div class="email-content">
${escapeHtml(emailData.decodedBody) || '<p><em>No email content available</em></p>'}
${escapeHtml(emailData?.decodedBody || '') || '<p><em>No email content available</em></p>'}
</div>
</div>
@@ -1768,7 +1768,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
<div className="h-fit w-full p-0">
{/* mail main body */}
{emailData?.decodedBody ? (
<MailIframe html={emailData?.decodedBody} senderEmail={emailData.sender.email} />
<MailContent html={emailData?.decodedBody} senderEmail={emailData.sender.email} />
) : null}
{/* mail attachments */}
{emailData?.attachments && emailData?.attachments.length > 0 ? (

View File

@@ -1,174 +0,0 @@
import { addStyleTags, doesContainStyleTags, template } from '@/lib/email-utils.client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { defaultUserSettings } from '@zero/server/schemas';
import { fixNonReadableColors } from '@/lib/email-utils';
import { useTRPC } from '@/providers/query-provider';
import { getBrowserTimezone } from '@/lib/timezones';
import { useSettings } from '@/hooks/use-settings';
import { useTheme } from 'next-themes';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { m } from '@/paraglide/messages';
export function MailIframe({ html, senderEmail }: { html: string; senderEmail: string }) {
const { data, refetch } = useSettings();
const queryClient = useQueryClient();
const isTrustedSender = useMemo(
() => data?.settings?.externalImages || data?.settings?.trustedSenders?.includes(senderEmail),
[data?.settings, senderEmail],
);
const [cspViolation, setCspViolation] = useState(false);
const [temporaryImagesEnabled, setTemporaryImagesEnabled] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(0);
const { resolvedTheme } = useTheme();
const trpc = useTRPC();
const { mutateAsync: saveUserSettings } = useMutation({
...trpc.settings.save.mutationOptions(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] });
},
});
const { mutateAsync: trustSender } = useMutation({
mutationFn: async () => {
const existingSettings = data?.settings ?? {
...defaultUserSettings,
timezone: getBrowserTimezone(),
};
const { success } = await saveUserSettings({
...existingSettings,
trustedSenders: data?.settings.trustedSenders
? data.settings.trustedSenders.concat(senderEmail)
: [senderEmail],
});
if (!success) {
throw new Error('Failed to trust sender');
}
},
onSuccess: () => {
refetch();
},
onError: () => {
toast.error('Failed to trust sender');
},
});
const { data: processedHtml } = useQuery({
queryKey: ['email-template', html, isTrustedSender || temporaryImagesEnabled],
queryFn: () => template(html, isTrustedSender || temporaryImagesEnabled),
staleTime: 30 * 60 * 1000, // Increase cache time to 30 minutes
gcTime: 60 * 60 * 1000, // Keep in cache for 1 hour
refetchOnWindowFocus: false, // Don't refetch on window focus
refetchOnMount: false, // Don't refetch on mount if data exists
});
const calculateAndSetHeight = useCallback(() => {
if (!iframeRef.current?.contentWindow?.document.body) return;
const body = iframeRef.current.contentWindow.document.body;
const boundingRectHeight = body.getBoundingClientRect().height;
const scrollHeight = body.scrollHeight;
if (body.innerText.trim() === '') {
setHeight(0);
return;
}
// Use the larger of the two values to ensure all content is visible
const newHeight = Math.max(boundingRectHeight, scrollHeight);
setHeight(newHeight);
}, [iframeRef, setHeight]);
useEffect(() => {
if (!iframeRef.current || !processedHtml) return;
let finalHtml = processedHtml;
const containsStyleTags = doesContainStyleTags(processedHtml);
if (!containsStyleTags) {
finalHtml = addStyleTags(processedHtml);
}
const url = URL.createObjectURL(new Blob([finalHtml], { type: 'text/html' }));
iframeRef.current.src = url;
const handler = () => {
if (iframeRef.current?.contentWindow?.document.body) {
calculateAndSetHeight();
fixNonReadableColors(iframeRef.current.contentWindow.document.body);
}
setTimeout(calculateAndSetHeight, 500);
};
iframeRef.current.onload = handler;
return () => {
URL.revokeObjectURL(url);
};
}, [processedHtml, calculateAndSetHeight]);
useEffect(() => {
if (iframeRef.current?.contentWindow?.document.body) {
const body = iframeRef.current.contentWindow.document.body;
body.style.backgroundColor =
resolvedTheme === 'dark' ? 'rgb(10, 10, 10)' : 'rgb(245, 245, 245)';
requestAnimationFrame(() => {
fixNonReadableColors(body);
});
}
}, [resolvedTheme]);
useEffect(() => {
const ctrl = new AbortController();
window.addEventListener(
'message',
(event) => {
if (event.data.type === 'csp-violation') {
setCspViolation(true);
}
},
{ signal: ctrl.signal },
);
return () => ctrl.abort();
}, []);
return (
<>
{cspViolation && !isTrustedSender && !data?.settings?.externalImages && (
<div className="flex items-center justify-start bg-amber-600/20 px-2 py-1 text-sm text-amber-600">
<p>{m['common.actions.hiddenImagesWarning']()}</p>
<button
onClick={() => setTemporaryImagesEnabled(!temporaryImagesEnabled)}
className="ml-2 cursor-pointer underline"
>
{temporaryImagesEnabled
? m['common.actions.disableImages']()
: m['common.actions.showImages']()}
</button>
<button onClick={() => void trustSender()} className="ml-2 cursor-pointer underline">
{m['common.actions.trustSender']()}
</button>
</div>
)}
<iframe
height={height}
ref={iframeRef}
className={cn(
'!min-h-0 w-full flex-1 overflow-hidden px-4 transition-opacity duration-200',
)}
title="Email Content"
style={{
width: '100%',
overflow: 'hidden',
}}
/>
</>
);
}

View File

@@ -2,7 +2,11 @@ import * as emailAddresses from 'email-addresses';
import type { Sender } from '@/types';
import Color from 'color';
export const fixNonReadableColors = (rootElement: HTMLElement, minContrast = 3.5) => {
export const fixNonReadableColors = (
rootElement: HTMLElement,
options?: { minContrast?: number; defaultBackground?: string },
) => {
const { minContrast = 3.5, defaultBackground = '#ffffff' } = options || {};
const elements = Array.from<HTMLElement>(rootElement.querySelectorAll('*'));
elements.unshift(rootElement);
@@ -21,7 +25,7 @@ export const fixNonReadableColors = (rootElement: HTMLElement, minContrast = 3.5
try {
const textColor = Color(style.color);
const effectiveBg = getEffectiveBackgroundColor(el);
const effectiveBg = getEffectiveBackgroundColor(el, defaultBackground);
const blendedText =
textColor.alpha() < 1 ? effectiveBg.mix(textColor, effectiveBg.alpha()) : textColor;
@@ -38,14 +42,14 @@ export const fixNonReadableColors = (rootElement: HTMLElement, minContrast = 3.5
}
};
const getEffectiveBackgroundColor = (element: HTMLElement) => {
const getEffectiveBackgroundColor = (element: HTMLElement, defaultBackground: string) => {
let current: HTMLElement | null = element;
while (current) {
const bg = Color(getComputedStyle(current).backgroundColor);
if (bg.alpha() >= 1) return bg.rgb();
current = current.parentElement;
}
return Color('#ffffff');
return Color(defaultBackground);
};
type ListUnsubscribeAction =

View File

@@ -0,0 +1,136 @@
import sanitizeHtml from 'sanitize-html';
import * as cheerio from 'cheerio';
interface ProcessEmailOptions {
html: string;
shouldLoadImages: boolean;
theme: 'light' | 'dark';
}
export function processEmailHtml({ html, shouldLoadImages, theme }: ProcessEmailOptions): {
processedHtml: string;
hasBlockedImages: boolean;
} {
let hasBlockedImages = false;
const sanitizeConfig: sanitizeHtml.IOptions = {
allowedTags: false,
allowedAttributes: false,
allowedSchemes: shouldLoadImages
? ['http', 'https', 'mailto', 'tel', 'data', 'cid', 'blob']
: ['http', 'https', 'mailto', 'tel', 'cid'],
allowedSchemesByTag: {
img: shouldLoadImages ? ['http', 'https', 'data', 'cid', 'blob'] : ['cid'],
},
transformTags: {
img: (tagName, attribs) => {
if (!shouldLoadImages && attribs.src && !attribs.src.startsWith('cid:')) {
hasBlockedImages = true;
return { tagName: 'span', attribs: { style: 'display:none;' } };
}
return { tagName, attribs };
},
a: (tagName, attribs) => {
return {
tagName,
attribs: {
...attribs,
target: attribs.target || '_blank',
rel: 'noopener noreferrer',
},
};
},
},
};
const sanitized = sanitizeHtml(html, sanitizeConfig);
const $ = cheerio.load(sanitized);
$('img[width="1"][height="1"]').remove();
$('img[width="0"][height="0"]').remove();
$('.preheader, .preheaderText, [class*="preheader"]').each((_, el) => {
const $el = $(el);
const style = $el.attr('style') || '';
if (
style.includes('display:none') ||
style.includes('display: none') ||
style.includes('font-size:0') ||
style.includes('font-size: 0') ||
style.includes('line-height:0') ||
style.includes('line-height: 0') ||
style.includes('max-height:0') ||
style.includes('max-height: 0') ||
style.includes('mso-hide:all') ||
style.includes('opacity:0') ||
style.includes('opacity: 0')
) {
$el.remove();
}
});
const minimalStyles = `
<style type="text/css">
:host {
display: block;
width: 100%;
height: 100%;
overflow: auto;
font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
line-height: 1.5;
background-color: ${theme === 'dark' ? '#1A1A1A' : '#ffffff'};
color: ${theme === 'dark' ? '#ffffff' : '#000000'};
}
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
}
a {
cursor: pointer;
color: ${theme === 'dark' ? '#60a5fa' : '#2563eb'};
text-decoration: underline;
}
a:hover {
color: ${theme === 'dark' ? '#93bbfc' : '#1d4ed8'};
}
img {
max-width: 100%;
height: auto;
display: block;
}
table {
border-collapse: collapse;
}
.gmail_quote {
margin: 1em 0;
padding-left: 1em;
border-left: 1px solid ${theme === 'dark' ? '#000' : '#ccc'};
}
::selection {
background: #b3d4fc;
text-shadow: none;
}
</style>
`;
const fullHtml = $.html();
const finalHtml = `${minimalStyles}${fullHtml}`;
return {
processedHtml: finalHtml,
hasBlockedImages,
};
}

View File

@@ -1,11 +1,18 @@
import { activeDriverProcedure, createRateLimiterMiddleware, router } from '../trpc';
import {
activeDriverProcedure,
createRateLimiterMiddleware,
router,
privateProcedure,
} from '../trpc';
import { updateWritingStyleMatrix } from '../../services/writing-style-service';
import { deserializeFiles, serializedFileSchema } from '../../lib/schemas';
import { defaultPageSize, FOLDERS, LABELS } from '../../lib/utils';
import { IGetThreadResponseSchema } from '../../lib/driver/types';
import { processEmailHtml } from '../../lib/email-processor';
import type { DeleteAllSpamResponse } from '../../types';
import { getZeroAgent } from '../../lib/server-utils';
import { env } from 'cloudflare:workers';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
const senderSchema = z.object({
@@ -395,4 +402,32 @@ export const mailRouter = router({
const agent = await getZeroAgent(activeConnection.id);
return agent.getEmailAliases();
}),
processEmailContent: privateProcedure
.input(
z.object({
html: z.string(),
shouldLoadImages: z.boolean(),
theme: z.enum(['light', 'dark']),
}),
)
.mutation(async ({ input }) => {
try {
const { processedHtml, hasBlockedImages } = processEmailHtml({
html: input.html,
shouldLoadImages: input.shouldLoadImages,
theme: input.theme,
});
return {
processedHtml,
hasBlockedImages,
};
} catch (error) {
console.error('Error processing email content:', error);
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to process email content',
});
}
}),
});