Skip to content
Merged

Hotfix #1390

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
118 changes: 62 additions & 56 deletions apps/mail/components/mail/mail-iframe.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { addStyleTags, doesContainStyleTags, template } from '@/lib/email-utils.client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react';
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 { useTranslations } from 'use-intl';
import { useTheme } from 'next-themes';
import DOMPurify from 'dompurify';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';

Expand All @@ -21,7 +20,8 @@ export function MailIframe({ html, senderEmail }: { html: string; senderEmail: s
);
const [cspViolation, setCspViolation] = useState(false);
const [temporaryImagesEnabled, setTemporaryImagesEnabled] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(0);
const { resolvedTheme } = useTheme();
const trpc = useTRPC();

Expand Down Expand Up @@ -69,71 +69,73 @@ export function MailIframe({ html, senderEmail }: { html: string; senderEmail: s

const t = useTranslations();

const finalHtml = useMemo(() => {
if (!processedHtml) return '';
const calculateAndSetHeight = useCallback(() => {
if (!iframeRef.current?.contentWindow?.document.body) return;

let html = processedHtml;
const containsStyleTags = doesContainStyleTags(processedHtml);
if (!containsStyleTags) {
html = addStyleTags(processedHtml);
}
const body = iframeRef.current.contentWindow.document.body;
const boundingRectHeight = body.getBoundingClientRect().height;
const scrollHeight = body.scrollHeight;

if (!isTrustedSender && !temporaryImagesEnabled) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const images = doc.querySelectorAll('img');
let hasViolations = false;

images.forEach((img) => {
const src = img.getAttribute('src');
if (src && !src.startsWith('data:') && !src.startsWith('blob:')) {
hasViolations = true;
img.removeAttribute('src');
img.setAttribute('data-blocked-src', src);
img.style.display = 'none';
}
});
// Use the larger of the two values to ensure all content is visible
setHeight(Math.max(boundingRectHeight, scrollHeight));
if (body.innerText.trim() === '') {
setHeight(0);
}
}, [iframeRef, setHeight]);

const backgrounds = doc.querySelectorAll('[style*="background"]');
backgrounds.forEach((el) => {
const style = el.getAttribute('style') || '';
if (style.includes('url(') && !style.includes('data:')) {
hasViolations = true;
el.setAttribute('style', style.replace(/background[^;]*url\([^)]*\)[^;]*/gi, ''));
}
});
const dangerousElements = doc.querySelectorAll('script, object, embed, form, input, button');
dangerousElements.forEach((el) => el.remove());
useEffect(() => {
if (!iframeRef.current || !processedHtml) return;

html = DOMPurify.sanitize(doc.documentElement.outerHTML);
setCspViolation(hasViolations);
let finalHtml = processedHtml;
const containsStyleTags = doesContainStyleTags(processedHtml);
if (!containsStyleTags) {
finalHtml = addStyleTags(processedHtml);
}

return html;
}, [processedHtml, isTrustedSender, temporaryImagesEnabled]);
const url = URL.createObjectURL(new Blob([finalHtml], { type: 'text/html' }));
iframeRef.current.src = url;

useEffect(() => {
if (!contentRef.current) return;

requestAnimationFrame(() => {
if (contentRef.current) {
fixNonReadableColors(contentRef.current);
const handler = () => {
if (iframeRef.current?.contentWindow?.document.body) {
calculateAndSetHeight();
fixNonReadableColors(iframeRef.current.contentWindow.document.body);
}
});
}, [finalHtml]);
setTimeout(calculateAndSetHeight, 500);
};

iframeRef.current.onload = handler;

return () => {
URL.revokeObjectURL(url);
};
}, [processedHtml, calculateAndSetHeight]);

useEffect(() => {
if (contentRef.current) {
contentRef.current.style.backgroundColor =
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(() => {
if (contentRef.current) {
fixNonReadableColors(contentRef.current);
}
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();
}, []);

// Show loading fallback while processing HTML (similar to HydrateFallback pattern)
if (isProcessingHtml) {
return (
Expand Down Expand Up @@ -161,10 +163,14 @@ export function MailIframe({ html, senderEmail }: { html: string; senderEmail: s
</button>
</div>
)}
<div
ref={contentRef}
className={cn('w-full flex-1 overflow-hidden px-4 transition-opacity duration-200')}
dangerouslySetInnerHTML={{ __html: finalHtml }}
<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"
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-scripts"
style={{
width: '100%',
overflow: 'hidden',
Expand Down