ultisuite-client/lib/email-preview-contrast.ts
R3D347HR4Y 8a02c10ba3 Add environment configuration and update email view components
- Created a .cursorignore file to manage local environment files.
- Updated .env.example to reflect changes in the public app URL.
- Modified the gmail workspace configuration to include the drive-suite path.
- Enhanced email view components to support attachment handling and fallback for plain text bodies.
- Improved user experience by updating attachment display logic and integrating inline attachment support.
2026-06-04 00:12:43 +02:00

532 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Détection et correction de contraste dans les iframes daperç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<string, Rgb> = {
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<Element>()
const darkSurfaces = new Set<Element>()
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
}