From 0f6e4f995f764fcc946bd4b90fdfa88c40579c24 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:29:48 +0200 Subject: [PATCH] fix: prevent iframe flash on Load images or Trust sender --- app/[locale]/page.tsx | 15 +++-- components/email/email-viewer.tsx | 92 ++++++++++++++++++++++++------- lib/email-sanitization.ts | 1 + 3 files changed, 85 insertions(+), 23 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index a8ba1100..dfc37c9b 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -654,8 +654,10 @@ export default function Home() { // Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive) useEffect(() => { if (!selectedEmail || !client) return; - // If the email lacks bodyValues, it was auto-selected from the list and needs full content - if (!selectedEmail.bodyValues) { + // If the email lacks bodyValues, it was auto-selected from the list and needs full content. + // Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before + // calling selectEmail on the stub), to avoid a duplicate request. + if (!selectedEmail.bodyValues && !isLoadingEmail) { const perAccountClient = isUnifiedView && selectedEmail.accountId ? useAuthStore.getState().getClientForAccount(selectedEmail.accountId) : undefined; @@ -1502,7 +1504,13 @@ export default function Home() { setShowComposer(false); } - // Set loading state immediately (keep current email visible) + // Show the list stub immediately so subject/sender render without + // waiting for the body fetch — avoids the loading flicker. + const listEmail = emails.find(e => e.id === email.id); + if (listEmail) { + selectEmail(listEmail); + } + setLoadingEmail(true); // On mobile, switch to viewer @@ -1519,7 +1527,6 @@ export default function Home() { try { // In unified view each email carries its own accountId. Use that // account's client so we fetch from the server that actually owns it. - const listEmail = emails.find(e => e.id === email.id); const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined; const perAccountClient = emailAccountId ? useAuthStore.getState().getClientForAccount(emailAccountId) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 689f405a..89d53511 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -2338,8 +2338,7 @@ export function EmailViewer({ ); if (shouldBlockExternal) { - sanitizeConfig.FORBID_TAGS.push('link'); - sanitizeConfig.FORBID_ATTR.push('background'); + sanitizeConfig.FORBID_TAGS = [...sanitizeConfig.FORBID_TAGS, 'link']; } DOMPurify.addHook('afterSanitizeAttributes', (node) => { @@ -2357,11 +2356,19 @@ export function EmailViewer({ } } + const bgAttr = node.getAttribute?.('background'); + if (bgAttr && (bgAttr.startsWith('http://') || bgAttr.startsWith('https://') || bgAttr.startsWith('//'))) { + node.setAttribute('data-blocked-background', bgAttr); + node.removeAttribute('background'); + blockedExternalContent = true; + } + if (htmlNode.style) { const style = htmlNode.style.cssText; if (style && style.includes('url(')) { const urlMatch = style.match(/url\(['"]?(https?:\/\/[^'")\s]+)['"]?\)/gi); if (urlMatch) { + node.setAttribute('data-blocked-style', style); htmlNode.style.cssText = style.replace(/url\(['"]?https?:\/\/[^'")\s]+['"]?\)/gi, 'url()'); blockedExternalContent = true; } @@ -2427,7 +2434,11 @@ export function EmailViewer({ html: '
No content available
', isHtml: false }; - }, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, isTrustedAddressBookSender, trustedSendersAddressBook, cidBlobUrls]); + // Intentionally omit allowExternalContent and trust state from deps: + // toggling permission imperatively unblocks content via restoreBlockedContent + // in an effect below, so the iframe srcDoc stays stable and doesn't reload/flash. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [email, externalContentPolicy, cidBlobUrls]); // Override email content with S/MIME decrypted content when available const effectiveEmailContent = useMemo(() => { @@ -2613,6 +2624,63 @@ export function EmailViewer({ ${effectiveEmailContent.html}