ultisuite-client/components/gmail/email-view/sandboxed-content.tsx
R3D347HR4Y ad1370ea7e
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat: enhance configuration and add new demo layouts
- Introduced turbopack alias for canvas in next.config.mjs.
- Updated package.json scripts for development and branding tasks.
- Added new dependencies for Tiptap extensions.
- Implemented new demo layouts for agenda, contacts, drive, and mail applications.
- Enhanced globals.css for improved theming and splash screen animations.
- Added OAuth callback handling for drive mounts.
- Updated layout components to integrate new demo shells and improve structure.
2026-06-12 19:10:24 +02:00

319 lines
9.0 KiB
TypeScript

"use client"
import {
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react"
import { useTheme } from "next-themes"
import {
emailPreviewBaseCss,
emailPreviewDarkOverrideCss,
emailPreviewDarkRemoteBodyTailCss,
emailPreviewDarkTailOverrideCss,
emailPreviewLightOverrideCss,
emailPreviewWrapperCss,
} from "@/lib/email-preview-dark-styles"
import {
EMAIL_PREVIEW_MIN_IFRAME_HEIGHT,
measureEmailPreviewIframeHeight,
resolveEmailPreviewMeasureRoot,
} from "@/lib/email-preview-iframe-height"
import {
buildEmailPreviewSrcdoc,
prepareEmailHtmlForIframe,
} from "@/lib/mail-html-iframe"
import { buildEmailPreviewCsp } from "@/lib/mail-remote-content"
import {
isEmailPreviewContrastDebugEnabled,
logEmailPreviewContrastIssues,
repairEmailPreviewContrast,
} from "@/lib/email-preview-contrast"
const EMAIL_PREVIEW_IFRAME_STYLE: CSSProperties = {
display: "block",
background: "transparent",
overflow: "hidden",
}
function documentIsDark(): boolean {
return document.documentElement.classList.contains("dark")
}
function bindEmailPreviewImageLoads(
root: HTMLElement,
onChange: () => void
): () => void {
const images = root.querySelectorAll("img")
if (images.length === 0) return () => {}
let pending = 0
const onDone = () => {
pending -= 1
if (pending <= 0) onChange()
}
for (const img of images) {
if (img.complete) continue
pending += 1
img.addEventListener("load", onDone, { once: true })
img.addEventListener("error", onDone, { once: true })
}
if (pending === 0) return () => {}
return () => {
for (const img of images) {
img.removeEventListener("load", onDone)
img.removeEventListener("error", onDone)
}
}
}
export function SandboxedContent({
html,
blockRemoteContent,
restrictPopups = false,
senderEmail,
cidUrlMap,
plainTextFallback,
messageId,
previewPart = "body",
}: {
html: string
blockRemoteContent: boolean
restrictPopups?: boolean
senderEmail?: string
cidUrlMap?: Record<string, string>
/** Plain body when HTML is image-only or empty with remote content blocked. */
plainTextFallback?: string
/** Pour les logs dev [email-preview:low-contrast]. */
messageId?: string
previewPart?: "body" | "quoted"
}) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const contrastLoggedKeyRef = useRef<string | null>(null)
const contrastDelayTimerRef = useRef<number | null>(null)
const resizeObserverRef = useRef<ResizeObserver | null>(null)
const unbindImageLoadsRef = useRef<(() => void) | null>(null)
const contentGenerationRef = useRef(0)
const appliedHeightRef = useRef(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT)
const syncRafRef = useRef<number | null>(null)
const [height, setHeight] = useState(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT)
const sandboxValue = restrictPopups
? "allow-same-origin"
: "allow-same-origin allow-popups"
const { resolvedTheme } = useTheme()
const isDark =
resolvedTheme === "dark" ||
((resolvedTheme === "system" || resolvedTheme === undefined) &&
typeof document !== "undefined" &&
documentIsDark())
const parsedEmail = useMemo(
() =>
prepareEmailHtmlForIframe(html, {
blockRemoteContent,
isDark,
senderEmail,
cidUrlMap,
plainTextFallback,
}),
[html, blockRemoteContent, isDark, senderEmail, cidUrlMap, plainTextFallback]
)
const themeCss = useMemo(() => {
if (!blockRemoteContent) {
const wrapper = emailPreviewWrapperCss(isDark)
return isDark ? `${wrapper}${emailPreviewDarkOverrideCss()}` : wrapper
}
return `${emailPreviewBaseCss(isDark)}${
isDark ? emailPreviewDarkOverrideCss() : emailPreviewLightOverrideCss()
}`
}, [blockRemoteContent, isDark])
const cspContent = useMemo(
() => buildEmailPreviewCsp(blockRemoteContent),
[blockRemoteContent]
)
const srcdoc = useMemo(
() =>
buildEmailPreviewSrcdoc(parsedEmail, {
csp: cspContent,
wrapperCss: themeCss,
plainTextFallback,
loadAppFont: !blockRemoteContent,
bodyTailCss: isDark
? blockRemoteContent
? emailPreviewDarkTailOverrideCss()
: emailPreviewDarkRemoteBodyTailCss()
: undefined,
}),
[
parsedEmail,
cspContent,
themeCss,
plainTextFallback,
isDark,
blockRemoteContent,
]
)
const iframeKey = `${messageId ?? "no-id"}:${previewPart}:${blockRemoteContent ? "remote-blocked" : "remote-allowed"}:${isDark ? "dark" : "light"}`
const applyMeasuredHeight = useCallback((generation: number) => {
if (generation !== contentGenerationRef.current) return
const doc = iframeRef.current?.contentDocument
if (!doc) return
const next = measureEmailPreviewIframeHeight(doc)
if (appliedHeightRef.current === next) return
appliedHeightRef.current = next
setHeight(next)
}, [])
const scheduleHeightSync = useCallback(
(generation: number) => {
if (syncRafRef.current !== null) {
cancelAnimationFrame(syncRafRef.current)
}
syncRafRef.current = requestAnimationFrame(() => {
syncRafRef.current = requestAnimationFrame(() => {
syncRafRef.current = null
applyMeasuredHeight(generation)
})
})
},
[applyMeasuredHeight]
)
const runContrastPipeline = useCallback(
(doc: Document, pass: "initial" | "delayed", generation: number) => {
if (!isDark || generation !== contentGenerationRef.current) return
const repair = repairEmailPreviewContrast(doc, {
isDark: true,
repairMode: blockRemoteContent ? "dark-only" : "all",
newsletterLightCanvas: false,
assumedCanvasRgb: [32, 33, 36],
})
if (repair && (repair.lightSurfaces > 0 || repair.darkSurfaces > 0)) {
scheduleHeightSync(generation)
if (isEmailPreviewContrastDebugEnabled()) {
console.info("[email-preview:contrast-repaired]", {
messageId,
part: previewPart,
pass,
blockRemoteContent,
...repair,
})
}
}
if (!isEmailPreviewContrastDebugEnabled()) return
const logKey = `${messageId ?? "no-id"}:${previewPart}:${blockRemoteContent}:${isDark}:${srcdoc.length}:${pass}`
if (contrastLoggedKeyRef.current === logKey) return
contrastLoggedKeyRef.current = logKey
logEmailPreviewContrastIssues(
{
messageId,
part: previewPart,
blockRemoteContent,
isDark,
senderEmail,
},
doc,
{ newsletterLightCanvas: false, assumedCanvasRgb: [32, 33, 36] }
)
},
[
messageId,
previewPart,
blockRemoteContent,
isDark,
senderEmail,
srcdoc,
scheduleHeightSync,
]
)
const handleIframeLoad = useCallback(() => {
const generation = contentGenerationRef.current
const doc = iframeRef.current?.contentDocument
const measureRoot = doc ? resolveEmailPreviewMeasureRoot(doc) : null
resizeObserverRef.current?.disconnect()
unbindImageLoadsRef.current?.()
unbindImageLoadsRef.current = null
if (measureRoot) {
resizeObserverRef.current = new ResizeObserver(() => {
scheduleHeightSync(generation)
})
resizeObserverRef.current.observe(measureRoot)
unbindImageLoadsRef.current = bindEmailPreviewImageLoads(measureRoot, () => {
scheduleHeightSync(generation)
})
}
scheduleHeightSync(generation)
if (doc) runContrastPipeline(doc, "initial", generation)
if (contrastDelayTimerRef.current !== null) {
window.clearTimeout(contrastDelayTimerRef.current)
}
contrastDelayTimerRef.current = window.setTimeout(() => {
contrastDelayTimerRef.current = null
if (generation !== contentGenerationRef.current) return
const lateDoc = iframeRef.current?.contentDocument
if (lateDoc) {
scheduleHeightSync(generation)
runContrastPipeline(lateDoc, "delayed", generation)
}
}, 1000)
}, [scheduleHeightSync, runContrastPipeline])
useLayoutEffect(() => {
contentGenerationRef.current += 1
appliedHeightRef.current = EMAIL_PREVIEW_MIN_IFRAME_HEIGHT
setHeight(EMAIL_PREVIEW_MIN_IFRAME_HEIGHT)
contrastLoggedKeyRef.current = null
resizeObserverRef.current?.disconnect()
resizeObserverRef.current = null
unbindImageLoadsRef.current?.()
unbindImageLoadsRef.current = null
if (syncRafRef.current !== null) {
cancelAnimationFrame(syncRafRef.current)
syncRafRef.current = null
}
if (contrastDelayTimerRef.current !== null) {
window.clearTimeout(contrastDelayTimerRef.current)
contrastDelayTimerRef.current = null
}
}, [srcdoc, messageId])
return (
<iframe
key={iframeKey}
ref={iframeRef}
sandbox={sandboxValue}
scrolling="no"
title="Contenu du message"
className="w-full border-0 bg-transparent"
style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: `${height}px` }}
srcDoc={srcdoc}
onLoad={handleIframeLoad}
tabIndex={-1}
/>
)
}