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.
This commit is contained in:
Matthieu MALVACHE
2026-02-16 17:18:38 +01:00
parent 17abe7ff56
commit dddbcf75db
4 changed files with 45 additions and 16 deletions
+2 -12
View File
@@ -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 {
+7 -2
View File
@@ -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);
@@ -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
+32
View File
@@ -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;
}