From dddbcf75db4a014539f353ddfc3c136041cff2df Mon Sep 17 00:00:00 2001 From: Matthieu MALVACHE Date: Mon, 16 Feb 2026 17:18:38 +0100 Subject: [PATCH] fix(email): resolve layout overflow and blocked image empty spaces Remove width: max-content and display: inline-block from email content CSS that caused horizontal scroll and left-side text clipping. Collapse empty table cells/containers when external images are blocked to prevent large blank areas in newsletter emails. --- app/globals.css | 14 ++------ components/email/email-viewer.tsx | 9 ++++-- components/email/thread-conversation-view.tsx | 6 ++-- lib/email-sanitization.ts | 32 +++++++++++++++++++ 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/app/globals.css b/app/globals.css index 6013a22..c8752ea 100644 --- a/app/globals.css +++ b/app/globals.css @@ -88,9 +88,7 @@ body { /* Enhanced Email Content Styling */ -/* Wrapper uses inline-block to size to content, enabling horizontal scroll */ .email-content-wrapper { - display: inline-block; min-width: 100%; } @@ -100,16 +98,8 @@ body { line-height: 1.6; color: var(--color-foreground); max-width: none; - /* Size to content's natural width, don't shrink below it */ - width: max-content; - min-width: 100%; -} - -/* Prevent tables with width="100%" from shrinking below their content */ -.email-content table[width="100%"], -.email-content table[style*="width:100%"], -.email-content table[style*="width: 100%"] { - min-width: max-content; + overflow-wrap: break-word; + word-wrap: break-word; } .email-content p { diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index e0aa875..aebef8c 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useMemo } from "react"; import DOMPurify from "dompurify"; import { Email } from "@/lib/jmap/types"; -import { hasRichFormatting, EMAIL_SANITIZE_CONFIG } from "@/lib/email-sanitization"; +import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { formatFileSize, cn } from "@/lib/utils"; @@ -466,11 +466,16 @@ export function EmailViewer({ } // Sanitize HTML to prevent XSS - const cleanHtml = DOMPurify.sanitize(htmlContent, sanitizeConfig); + let cleanHtml = DOMPurify.sanitize(htmlContent, sanitizeConfig); // Remove the hook after sanitization DOMPurify.removeAllHooks(); + // Collapse empty containers left behind by blocked images + if (shouldBlockExternal && blockedExternalContent) { + cleanHtml = collapseBlockedImageContainers(cleanHtml); + } + // Update blocked content state if (blockedExternalContent && !hasBlockedContent) { setHasBlockedContent(true); diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index d3b5430..92cca11 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useMemo } from "react"; import DOMPurify from "dompurify"; import { Email, ThreadGroup } from "@/lib/jmap/types"; -import { hasRichFormatting, EMAIL_SANITIZE_CONFIG } from "@/lib/email-sanitization"; +import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization"; import { Avatar } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { formatDate, formatFileSize, cn } from "@/lib/utils"; @@ -296,11 +296,13 @@ function EmailCard({ const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig); DOMPurify.removeHook('afterSanitizeAttributes'); + let finalHtml = sanitized; if (blockedExternalContent) { setHasBlockedContent(true); + finalHtml = collapseBlockedImageContainers(sanitized); } - return { html: sanitized, isHtml: true }; + return { html: finalHtml, isHtml: true }; } // Plain text fallback diff --git a/lib/email-sanitization.ts b/lib/email-sanitization.ts index c90dff6..dfd0f86 100644 --- a/lib/email-sanitization.ts +++ b/lib/email-sanitization.ts @@ -75,3 +75,35 @@ export function hasRichFormatting(html: string): boolean { 'h1, h2, h3, h4, h5, h6, ul, ol, blockquote' ); } + +/** + * Collapse empty containers left behind when external images are blocked. + * Walks up from each blocked img to find the nearest table cell or wrapper div + * and hides it if it contains no meaningful visible content. + */ +export function collapseBlockedImageContainers(html: string): string { + const doc = parseHtmlSafely(html); + const blockedImages = doc.querySelectorAll('img[data-blocked-src]'); + + blockedImages.forEach((img) => { + let el: HTMLElement | null = img.parentElement; + while (el && el !== doc.body) { + if (el.tagName === 'TD' || el.tagName === 'TH' || (el.tagName === 'DIV' && el.parentElement?.tagName === 'TD')) { + const hasVisibleText = el.textContent?.replace(/[\s\u00A0]+/g, '').trim(); + const hasVisibleMedia = el.querySelector('img:not([data-blocked-src]), video, canvas'); + const hasLinks = el.querySelector('a[href]'); + if (!hasVisibleText && !hasVisibleMedia && !hasLinks) { + el.style.display = 'none'; + el.style.height = '0'; + el.style.padding = '0'; + el.style.overflow = 'hidden'; + } + break; + } + if (el.tagName === 'TABLE' || el.tagName === 'TR') break; + el = el.parentElement; + } + }); + + return doc.body.innerHTML; +}