/** * Détection et correction de contraste dans les iframes d’aperçu mail. */ const REPAIR_LIGHT_TEXT = "#202124" const REPAIR_LIGHT_LINK = "#1a73e8" const REPAIR_DARK_TEXT = "#e8eaed" const REPAIR_DARK_LINK = "#8ab4f8" const CONTRAST_REPAIR_TEXT_TAGS = "div,p,span,td,th,li,h1,h2,h3,h4,h5,h6,font,label,strong,b,em,i,u,center,table" function emailPreviewContrastRepairCss(): string { const lightChildren = CONTRAST_REPAIR_TEXT_TAGS.split(",") .map((t) => `[data-ultimail-light-surface] ${t}`) .join(", ") const darkChildren = CONTRAST_REPAIR_TEXT_TAGS.split(",") .map((t) => `[data-ultimail-dark-surface] ${t}`) .join(", ") return ` ${lightChildren}, [data-ultimail-light-surface] { color: ${REPAIR_LIGHT_TEXT} !important; } [data-ultimail-light-surface] a, [data-ultimail-light-surface] a * { color: ${REPAIR_LIGHT_LINK} !important; } ${darkChildren}, [data-ultimail-dark-surface] { color: ${REPAIR_DARK_TEXT} !important; } [data-ultimail-dark-surface] a, [data-ultimail-dark-surface] a * { color: ${REPAIR_DARK_LINK} !important; } [data-ultimail-light-surface] blockquote { color: #5f6368 !important; border-left-color: #dadce0 !important; } [data-ultimail-dark-surface] blockquote { color: #9aa0a6 !important; border-left-color: #5f6368 !important; } ` } export const EMAIL_PREVIEW_MIN_CONTRAST_RATIO = 3 export type Rgb = readonly [number, number, number] export type EmailPreviewContrastIssue = { tag: string contrastRatio: number color: string backgroundColor: string effectiveBackground: string sample: string selectorHint: string } export type EmailPreviewContrastReport = { issueCount: number issues: EmailPreviewContrastIssue[] hasExternalStyles: boolean scannedElements: number } export type DetectEmailPreviewContrastOptions = { minRatio?: number maxIssues?: number maxScan?: number /** Fallback when la chaîne de fonds est transparente (aperçu dark : blanc newsletter). */ assumedCanvasRgb?: Rgb /** En dark mode, défaut fond clair pour les zones transparentes. */ newsletterLightCanvas?: boolean textSelectors?: string } type BackgroundSample = { rgb: Rgb css: string source: Element /** Fond déduit (transparent) — ne pas en déduire light-surface. */ isAssumedCanvas?: boolean } const DEFAULT_TEXT_SELECTORS = "p,span,td,th,div,li,a,h1,h2,h3,h4,h5,h6,font,label,strong,b,em" const NAMED_RGB: Record = { white: [255, 255, 255], black: [0, 0, 0], transparent: [0, 0, 0], } export function parseCssColorToRgb(color: string): Rgb | null { const trimmed = color.trim().toLowerCase() if (!trimmed || trimmed === "transparent") return null const named = NAMED_RGB[trimmed] if (named) return named const hex = trimmed.match(/^#([0-9a-f]{3,8})$/) if (hex) { let h = hex[1] if (h.length === 3) h = h.split("").map((c) => c + c).join("") if (h.length === 6) { return [ parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), ] } } const rgb = trimmed.match( /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)$/ ) if (rgb) { const alpha = rgb[4] !== undefined ? Number(rgb[4]) : 1 if (alpha < 0.08) return null return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])] } return null } export function relativeLuminance([r, g, b]: Rgb): number { const f = (x: number) => { const s = x / 255 return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 } return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b) } export function contrastRatio(fg: Rgb, bg: Rgb): number { const L1 = relativeLuminance(fg) const L2 = relativeLuminance(bg) const lighter = Math.max(L1, L2) const darker = Math.min(L1, L2) return (lighter + 0.05) / (darker + 0.05) } function selectorHint(el: Element): string { const tag = el.tagName.toLowerCase() const id = el.id ? `#${el.id}` : "" const cls = typeof el.className === "string" && el.className.trim() ? `.${el.className.trim().split(/\s+/).slice(0, 2).join(".")}` : "" return `${tag}${id}${cls}` } function parseHtmlBgcolorAttribute(el: Element): Rgb | null { const raw = el.getAttribute("bgcolor")?.trim() if (!raw) return null const normalized = raw.startsWith("#") ? raw : `#${raw}` return parseCssColorToRgb(normalized) ?? parseCssColorToRgb(raw) } /** Gradients / fonds peints sans background-color opaque (newsletters type bunny.net). */ export function isLikelyLightPaintedBackground(backgroundImage: string): boolean { if (!backgroundImage || backgroundImage === "none") return false const s = backgroundImage.toLowerCase() if (s.includes("url(")) return false if (!s.includes("gradient")) return false if ( s.includes("#000") || s.includes("rgb(0,") || s.includes("rgb(0 ") || s.includes(", 0,") || s.includes("black") ) { return false } return ( s.includes("#fff") || s.includes("white") || s.includes("rgb(255") || /#[ef][0-9a-f]{5}/i.test(s) || s.includes("#f") || s.includes("rgb(24") || s.includes("rgb(23") || s.includes("rgb(22") || s.includes("rgb(236") || s.includes("rgb(239") || s.includes("rgb(245") ) } function backgroundRgbFromElement( el: Element, view: Window ): { rgb: Rgb; css: string } | null { const cs = view.getComputedStyle(el) const solid = parseCssColorToRgb(cs.backgroundColor) if (solid) return { rgb: solid, css: cs.backgroundColor } const bgcolor = parseHtmlBgcolorAttribute(el) if (bgcolor) { return { rgb: bgcolor, css: el.getAttribute("bgcolor") ?? "" } } if (isLikelyLightPaintedBackground(cs.backgroundImage)) { return { rgb: [245, 247, 250], css: cs.backgroundImage } } return null } function clamp(n: number, min: number, max: number): number { return Math.min(Math.max(n, min), max) } /** Fond réellement visible sous le texte (évite de confondre avec un ancêtre bleu foncé). */ function visualBackgroundAtTextElement( el: Element, doc: Document, view: Window ): BackgroundSample | null { const rect = el.getBoundingClientRect() if (rect.width < 1 || rect.height < 1) return null const x = clamp( rect.left + rect.width / 2, 0, Math.max(0, view.innerWidth - 1) ) const y = clamp( rect.top + Math.min(14, rect.height * 0.2), 0, Math.max(0, view.innerHeight - 1) ) let node = doc.elementFromPoint(x, y) if (!node || node === doc.documentElement) return null while (node && node !== doc.body) { const bg = backgroundRgbFromElement(node, view) if (bg) return { ...bg, source: node } node = node.parentElement } return null } function ancestryBackgroundAtElement( el: Element, doc: Document, view: Window ): BackgroundSample | null { let node: Element | null = el while (node && node !== doc.documentElement) { const bg = backgroundRgbFromElement(node, view) if (bg) return { ...bg, source: node } node = node.parentElement } const bodyBg = backgroundRgbFromElement(doc.body, view) if (bodyBg) return { ...bodyBg, source: doc.body } return null } function defaultAssumedCanvasRgb(options: DetectEmailPreviewContrastOptions): Rgb { if (options.newsletterLightCanvas === true) { return [255, 255, 255] } return options.assumedCanvasRgb ?? [32, 33, 36] } function effectiveBackgroundForText( el: Element, doc: Document, view: Window, options: DetectEmailPreviewContrastOptions ): BackgroundSample { const visual = visualBackgroundAtTextElement(el, doc, view) if (visual) return visual const ancestry = ancestryBackgroundAtElement(el, doc, view) if (ancestry) return ancestry const rgb = defaultAssumedCanvasRgb(options) return { rgb, css: "(assumed-canvas)", source: doc.body, isAssumedCanvas: true, } } export type ContrastRepairKind = "light-surface" | "dark-surface" export function classifyContrastRepairKind( fg: Rgb, bg: Rgb, minRatio = EMAIL_PREVIEW_MIN_CONTRAST_RATIO ): ContrastRepairKind | null { const fgL = relativeLuminance(fg) const bgL = relativeLuminance(bg) const ratio = contrastRatio(fg, bg) if (bgL > 0.8 && fgL > 0.55) return "light-surface" if (bgL < 0.22 && fgL < 0.48) return "dark-surface" if (ratio < minRatio) { if (bgL > 0.72 && fgL > 0.5) return "light-surface" if (bgL < 0.28 && fgL < 0.52) return "dark-surface" } return null } const CONTRAST_REPAIR_STYLE_ID = "ultimail-contrast-repair" const MAX_MARKED_SURFACES = 48 function ensureContrastRepairStylesheet(doc: Document): void { if (doc.getElementById(CONTRAST_REPAIR_STYLE_ID)) return const style = doc.createElement("style") style.id = CONTRAST_REPAIR_STYLE_ID style.setAttribute("data-ultimail-contrast-repair", "true") style.textContent = emailPreviewContrastRepairCss() doc.head.appendChild(style) } export type RepairEmailPreviewContrastResult = { lightSurfaces: number darkSurfaces: number } export type EmailPreviewContrastRepairMode = "all" | "dark-only" | "light-only" export type RepairEmailPreviewContrastOptions = DetectEmailPreviewContrastOptions & { isDark?: boolean repairMode?: EmailPreviewContrastRepairMode } export function repairEmailPreviewContrast( doc: Document, options: RepairEmailPreviewContrastOptions = {} ): RepairEmailPreviewContrastResult | null { if (options.isDark === false) return null const view = doc.defaultView const body = doc.body if (!view || !body) return null const minRatio = options.minRatio ?? EMAIL_PREVIEW_MIN_CONTRAST_RATIO const maxScan = options.maxScan ?? 800 const repairMode = options.repairMode ?? "all" const detectOptions: DetectEmailPreviewContrastOptions = { ...options, newsletterLightCanvas: options.newsletterLightCanvas ?? false, assumedCanvasRgb: options.assumedCanvasRgb ?? [32, 33, 36], } const selectors = options.textSelectors ?? DEFAULT_TEXT_SELECTORS const lightSurfaces = new Set() const darkSurfaces = new Set() let scannedElements = 0 for (const el of body.querySelectorAll(selectors)) { if (scannedElements >= maxScan) break scannedElements += 1 const text = (el.textContent ?? "").replace(/\s+/g, " ").trim() if (text.length < 2) continue const cs = view.getComputedStyle(el) const fg = parseCssColorToRgb(cs.color) if (!fg) continue const bg = effectiveBackgroundForText(el, doc, view, detectOptions) const kind = classifyContrastRepairKind(fg, bg.rgb, minRatio) if (!kind) continue if (bg.isAssumedCanvas && kind === "light-surface") continue if (repairMode === "dark-only" && kind === "light-surface") continue if (repairMode === "light-only" && kind === "dark-surface") continue const bucket = kind === "light-surface" ? lightSurfaces : darkSurfaces if (bucket.size >= MAX_MARKED_SURFACES) continue bucket.add(el) } if (lightSurfaces.size === 0 && darkSurfaces.size === 0) { return { lightSurfaces: 0, darkSurfaces: 0 } } for (const el of lightSurfaces) { el.setAttribute("data-ultimail-light-surface", "true") el.removeAttribute("data-ultimail-dark-surface") } for (const el of darkSurfaces) { el.setAttribute("data-ultimail-dark-surface", "true") el.removeAttribute("data-ultimail-light-surface") } ensureContrastRepairStylesheet(doc) return { lightSurfaces: lightSurfaces.size, darkSurfaces: darkSurfaces.size, } } export function emailPreviewHasExternalStyles(doc: Document): boolean { return Boolean( doc.querySelector('link[rel~="stylesheet" i]') || doc.querySelector("style:not([data-ultimail-wrapper])") ) } export function detectEmailPreviewContrastIssues( doc: Document, options: DetectEmailPreviewContrastOptions = {} ): EmailPreviewContrastReport { const view = doc.defaultView const body = doc.body if (!view || !body) { return { issueCount: 0, issues: [], hasExternalStyles: false, scannedElements: 0, } } const minRatio = options.minRatio ?? EMAIL_PREVIEW_MIN_CONTRAST_RATIO const maxIssues = options.maxIssues ?? 20 const maxScan = options.maxScan ?? 800 const detectOptions: DetectEmailPreviewContrastOptions = { ...options, newsletterLightCanvas: options.newsletterLightCanvas ?? false, assumedCanvasRgb: options.assumedCanvasRgb ?? [32, 33, 36], } const selectors = options.textSelectors ?? DEFAULT_TEXT_SELECTORS const issues: EmailPreviewContrastIssue[] = [] let scannedElements = 0 for (const el of body.querySelectorAll(selectors)) { if (scannedElements >= maxScan || issues.length >= maxIssues) break scannedElements += 1 const text = (el.textContent ?? "").replace(/\s+/g, " ").trim() if (text.length < 2) continue const cs = view.getComputedStyle(el) const fg = parseCssColorToRgb(cs.color) if (!fg) continue const bg = effectiveBackgroundForText(el, doc, view, detectOptions) const ratio = contrastRatio(fg, bg.rgb) const kind = classifyContrastRepairKind(fg, bg.rgb, minRatio) if (bg.isAssumedCanvas && kind === "light-surface") continue if (ratio >= minRatio && kind !== "light-surface") continue issues.push({ tag: el.tagName, contrastRatio: Math.round(ratio * 100) / 100, color: cs.color, backgroundColor: cs.backgroundColor, effectiveBackground: bg.css, sample: text.slice(0, 80), selectorHint: selectorHint(el), }) } return { issueCount: issues.length, hasExternalStyles: emailPreviewHasExternalStyles(doc), issues, scannedElements, } } export type EmailPreviewContrastLogContext = { messageId?: string part?: "body" | "quoted" blockRemoteContent: boolean isDark: boolean senderEmail?: string } const DEV_CONTRAST_LOG_PREFIX = "[email-preview:low-contrast]" export function isEmailPreviewContrastDebugEnabled(): boolean { if (process.env.NODE_ENV === "development") return true return process.env.NEXT_PUBLIC_EMAIL_PREVIEW_CONTRAST_DEBUG === "1" } export function logEmailPreviewContrastIssues( context: EmailPreviewContrastLogContext, doc: Document, options?: DetectEmailPreviewContrastOptions ): EmailPreviewContrastReport | null { if (!isEmailPreviewContrastDebugEnabled()) return null const report = detectEmailPreviewContrastIssues(doc, { ...options, newsletterLightCanvas: context.isDark, assumedCanvasRgb: options?.assumedCanvasRgb ?? (context.isDark ? [255, 255, 255] : [255, 255, 255]), }) if (report.issueCount === 0) return report console.warn(DEV_CONTRAST_LOG_PREFIX, { messageId: context.messageId, part: context.part ?? "body", blockRemoteContent: context.blockRemoteContent, isDark: context.isDark, senderEmail: context.senderEmail, hasExternalStyles: report.hasExternalStyles, scannedElements: report.scannedElements, issueCount: report.issueCount, issues: report.issues, hint: report.hasExternalStyles && !context.blockRemoteContent ? "Styles expéditeur après data-ultimail-wrapper — voir fixtures/email-preview/" : "Texte clair Ultimail sur fond clair expéditeur — repair runtime data-ultimail-light-surface", }) return report }