- 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.
532 lines
15 KiB
TypeScript
532 lines
15 KiB
TypeScript
/**
|
||
* 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<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
|
||
}
|