ultisuite-client/lib/mail-html-iframe.ts
R3D347HR4Y 6ec95262af Add OnlyOffice integration and update project configurations
- Updated .env.example to include configuration for OnlyOffice Document Server.
- Modified the workspace configuration to remove the drive-suite path.
- Adjusted TypeScript environment imports for consistency.
- Enhanced Next.js configuration to disable canvas in Webpack.
- Updated package.json to include new dependencies for OnlyOffice and PDF.js.
- Added global styles for OnlyOffice theme integration in the CSS.
- Created new layout and page components for the Drive feature, including public sharing and editing functionalities.
- Updated metadata handling across various layouts to reflect the new app structure.
2026-06-07 15:49:21 +02:00

678 lines
20 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.

/** Prépare le HTML d'un mail pour affichage dans une iframe sandboxée. */
import { stripHiddenEmailHtml } from "@/lib/strip-hidden-email-html"
import { stripExecutableEmailHtml } from "@/lib/strip-executable-email-html"
import { preprocessEmailHtmlForTheme } from "@/lib/email-preview-dark-styles"
import { resolveCidReference } from "@/lib/mail-cid"
const REMOTE_URL = /^https?:\/\//i
const PROTOCOL_RELATIVE = /^\/\//
export type ParsedEmailHtml = {
headMarkup: string
bodyHtml: string
/** Uniquement si le mail définit explicitement <base href>. */
documentBaseHref?: string
/** Base pour résoudre les URLs relatives dans le HTML. */
resolveBaseHref?: string
}
function escapeHtmlAttr(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
}
export { escapeHtmlAttr }
function resolveUrl(url: string, baseHref?: string): string {
const trimmed = url.trim()
if (!trimmed || trimmed.startsWith("data:") || trimmed.startsWith("cid:")) {
return trimmed
}
if (trimmed.startsWith("mailto:") || trimmed.startsWith("#")) return trimmed
if (REMOTE_URL.test(trimmed)) {
return trimmed.startsWith("http://")
? `https://${trimmed.slice("http://".length)}`
: trimmed
}
if (PROTOCOL_RELATIVE.test(trimmed)) {
return `https:${trimmed}`
}
if (!baseHref) return trimmed
try {
const resolved = new URL(trimmed, baseHref).href
return resolved.startsWith("http://")
? `https://${resolved.slice("http://".length)}`
: resolved
} catch {
return trimmed
}
}
function isStylesheetLink(el: Element): boolean {
if (el.tagName.toLowerCase() !== "link") return false
const rel = (el.getAttribute("rel") ?? "").toLowerCase()
const type = (el.getAttribute("type") ?? "").toLowerCase()
return rel.includes("stylesheet") || type === "text/css"
}
const LAZY_IMG_SRC_ATTRS = [
"data-src",
"data-original",
"data-lazy-src",
"data-url",
"data-href",
"data-image",
"originalsrc",
] as const
function firstLazyImageSrc(img: Element): string | null {
for (const name of LAZY_IMG_SRC_ATTRS) {
const value = img.getAttribute(name)?.trim()
if (value) return value
}
return null
}
/** Known 1×1 / spacer filenames — not arbitrary URL substrings. */
function isPlaceholderImageSrc(src: string): boolean {
const t = src.trim().toLowerCase()
if (!t || t === "about:blank" || t === "#") return true
if (t.startsWith("data:")) return false
try {
const path = new URL(t, "https://example.invalid/").pathname
const file = path.split("/").pop() ?? ""
return /^(?:spacer|blank|pixel|1x1|clear|dot|transparent|spacer\.gif)\.(?:gif|png|jpe?g|svg|webp)$/i.test(
file
)
} catch {
return /(?:^|\/)(?:spacer|blank|pixel|1x1)\.(?:gif|png|jpe?g)/i.test(t)
}
}
function collectHeadResourceMarkup(
doc: Document,
resolveBaseHref?: string
): string {
const parts: string[] = []
const nodes = [
...doc.head.querySelectorAll("style, link[rel]"),
...doc.body.querySelectorAll("style, link[rel]"),
]
for (const node of nodes) {
const tag = node.tagName.toLowerCase()
if (tag === "style") {
parts.push(node.outerHTML)
node.remove()
continue
}
if (isStylesheetLink(node)) {
const href = node.getAttribute("href")
if (href && resolveBaseHref) {
node.setAttribute("href", resolveUrl(href, resolveBaseHref))
}
parts.push(node.outerHTML)
node.remove()
}
}
return parts.join("\n")
}
export function inferEmailResolveBaseHref(html: string): string | undefined {
const baseMatch = html.match(/<base[^>]+href=["']([^"']+)["']/i)
if (baseMatch?.[1]) {
try {
return new URL(baseMatch[1]).href
} catch {
return baseMatch[1]
}
}
const stylesheetMatch = html.match(
/<link[^>]+rel=["'][^"']*stylesheet[^"']*["'][^>]+href=["']([^"']+)["']/i
) ?? html.match(
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["'][^"']*stylesheet/i
)
if (stylesheetMatch?.[1]) {
try {
const u = new URL(stylesheetMatch[1], "https://example.com/")
if (stylesheetMatch[1].startsWith("http") || stylesheetMatch[1].startsWith("//")) {
return `${u.origin}${u.pathname.replace(/\/[^/]*$/, "/")}`
}
} catch {
/* ignore */
}
}
const absoluteMatch = html.match(
/(?:src|href)=["'](https?:\/\/[^/"']+)[^"']*["']/i
)
if (absoluteMatch?.[1]) {
try {
const u = new URL(absoluteMatch[1])
return `${u.origin}/`
} catch {
/* ignore */
}
}
return undefined
}
/** Extrait styles/CSS + body d'un document HTML complet ou fragment. */
export function parseEmailHtmlForIframe(
html: string,
resolveBaseHref?: string
): ParsedEmailHtml {
if (!html.trim()) {
return { headMarkup: "", bodyHtml: "" }
}
if (typeof DOMParser === "undefined") {
return { headMarkup: "", bodyHtml: html }
}
try {
const doc = new DOMParser().parseFromString(html, "text/html")
const explicitBase =
doc.querySelector("base[href]")?.getAttribute("href") ?? undefined
const documentBaseHref = explicitBase
? resolveUrl(explicitBase, undefined)
: undefined
doc.querySelector("base[href]")?.remove()
const headMarkup = collectHeadResourceMarkup(doc, resolveBaseHref)
let bodyHtml = doc.body?.innerHTML?.trim() ?? ""
if (!bodyHtml) bodyHtml = html.trim()
return {
headMarkup,
bodyHtml,
documentBaseHref,
resolveBaseHref,
}
} catch {
return { headMarkup: "", bodyHtml: html }
}
}
function rewriteUrlList(value: string, baseHref?: string): string {
return value
.split(",")
.map((part) => {
const trimmed = part.trim()
const space = trimmed.indexOf(" ")
const url = space === -1 ? trimmed : trimmed.slice(0, space)
const descriptor = space === -1 ? "" : trimmed.slice(space + 1)
const resolved = resolveUrl(url, baseHref)
return descriptor ? `${resolved} ${descriptor}` : resolved
})
.join(", ")
}
function rewriteStyleRemoteUrls(style: string, baseHref?: string): string {
return style.replace(
/url\s*\(\s*['"]?([^'")]+)['"]?\s*\)/gi,
(_match, url: string) => {
const trimmed = url.trim()
if (
trimmed.startsWith("data:") ||
trimmed.startsWith("#") ||
trimmed.startsWith("cid:")
) {
return `url(${trimmed})`
}
return `url(${resolveUrl(trimmed, baseHref)})`
}
)
}
function rewriteHeadMarkupUrls(headMarkup: string, baseHref?: string): string {
if (!headMarkup.trim() || !baseHref) return headMarkup
return headMarkup
.replace(
/(<link[^>]+href=)(["'])([^"']+)\2/gi,
(_m, prefix: string, quote: string, href: string) =>
`${prefix}${quote}${resolveUrl(href, baseHref)}${quote}`
)
.replace(
/(<style[^>]*>)([\s\S]*?)(<\/style>)/gi,
(_m, open: string, css: string, close: string) =>
`${open}${rewriteStyleRemoteUrls(css, baseHref)}${close}`
)
}
const REMOTE_CSS_URL =
/url\s*\(\s*['"]?(?:https?:\/\/|\/\/|[^'"data:#][^'")]*)/i
function neutralizeRemoteUrlsInCss(css: string): string {
return css.replace(
/url\s*\(\s*['"]?([^'")]+)['"]?\s*\)/gi,
(_match, url: string) => {
const trimmed = url.trim()
if (
trimmed.startsWith("data:") ||
trimmed.startsWith("#") ||
trimmed.startsWith("cid:")
) {
return `url(${trimmed})`
}
if (REMOTE_URL.test(trimmed) || PROTOCOL_RELATIVE.test(trimmed)) {
return "url(about:blank)"
}
return `url(${trimmed})`
}
)
}
/** Keep inline <style>; drop external stylesheets when remote content is blocked. */
export function filterHeadMarkupForBlockedRemote(headMarkup: string): string {
if (!headMarkup.trim() || typeof DOMParser === "undefined") {
return headMarkup.replace(/<link\b[^>]*>/gi, "")
}
try {
const doc = new DOMParser().parseFromString(
`<div id="head-root">${headMarkup}</div>`,
"text/html"
)
const root = doc.getElementById("head-root")
if (!root) return headMarkup
for (const link of root.querySelectorAll("link")) {
if (isStylesheetLink(link)) link.remove()
}
for (const style of root.querySelectorAll("style")) {
const css = style.textContent ?? ""
if (css) style.textContent = neutralizeRemoteUrlsInCss(css)
}
return root.innerHTML.trim()
} catch {
return headMarkup.replace(/<link\b[^>]*>/gi, "")
}
}
function clearRemoteImageAttrs(img: Element): void {
for (const name of [
"src",
"srcset",
"sizes",
"background",
...LAZY_IMG_SRC_ATTRS,
] as const) {
img.removeAttribute(name)
}
}
/** Newsletter HTML: keep layout/text; block remote images and external CSS. */
export function stripRemoteResourcesForPreview(html: string): string {
if (!html.trim() || typeof DOMParser === "undefined") return html
try {
const doc = new DOMParser().parseFromString(html, "text/html")
for (const link of doc.querySelectorAll("link")) {
if (isStylesheetLink(link)) link.remove()
}
for (const el of doc.querySelectorAll("[style]")) {
const style = el.getAttribute("style")
if (style && REMOTE_CSS_URL.test(style)) {
el.setAttribute("style", neutralizeRemoteUrlsInCss(style))
}
}
for (const el of doc.querySelectorAll("[background]")) {
const bg = el.getAttribute("background")?.trim() ?? ""
if (bg && isRemoteOrRelativeUrl(bg)) el.removeAttribute("background")
}
for (const img of doc.querySelectorAll("img")) {
const src = img.getAttribute("src") ?? ""
const lazy = firstLazyImageSrc(img)
const remote =
(src.trim() && isRemoteOrRelativeUrl(src) && !src.startsWith("cid:")) ||
Boolean(lazy && isRemoteOrRelativeUrl(lazy))
if (!remote) continue
const alt = img.getAttribute("alt")?.trim()
clearRemoteImageAttrs(img)
img.setAttribute(
"style",
`${img.getAttribute("style") ?? ""};max-width:100%;height:auto;object-fit:contain;background:#e8eaed;border-radius:4px;`.replace(
/^;/,
""
)
)
if (alt) {
const caption = doc.createElement("p")
caption.setAttribute(
"style",
"margin:4px 0 12px;font:13px/1.4 sans-serif;color:#5f6368"
)
caption.textContent = alt
img.insertAdjacentElement("afterend", caption)
}
}
return doc.body.innerHTML
} catch {
return html
}
}
export function htmlHasMeaningfulVisibleText(html: string, minChars = 24): boolean {
if (!html.trim() || typeof DOMParser === "undefined") {
return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().length >= minChars
}
try {
const doc = new DOMParser().parseFromString(html, "text/html")
const text = (doc.body?.textContent ?? "").replace(/\s+/g, " ").trim()
return text.length >= minChars
} catch {
return false
}
}
export function plainTextFallbackHtml(text: string): string {
const trimmed = text.trim()
if (!trimmed) return ""
const escaped = trimmed
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
return (
`<div class="ultimail-plain-fallback" style="margin:0 0 16px;padding:12px 14px;` +
`border-radius:8px;background:var(--muted,#f1f3f4);color:inherit">` +
`<pre style="white-space:pre-wrap;font:14px/1.5 inherit;margin:0">` +
`${escaped}</pre></div>`
)
}
function isRemoteOrRelativeUrl(url: string): boolean {
const t = url.trim()
return (
REMOTE_URL.test(t) ||
PROTOCOL_RELATIVE.test(t) ||
(!t.startsWith("data:") &&
!t.startsWith("cid:") &&
!t.startsWith("mailto:") &&
!t.startsWith("#") &&
!t.startsWith("javascript:"))
)
}
/** Active images lazy-load et résout les URLs relatives / http. */
export function activateRemoteResourcesInHtml(
html: string,
options: { baseHref?: string } = {}
): string {
const { baseHref } = options
if (!html.trim() || typeof DOMParser === "undefined") return html
try {
const doc = new DOMParser().parseFromString(html, "text/html")
for (const img of doc.querySelectorAll("img")) {
const lazySrc = firstLazyImageSrc(img)
const src = img.getAttribute("src") ?? ""
const srcIsUseful =
!!src.trim() &&
!isPlaceholderImageSrc(src) &&
(src.startsWith("data:") ||
src.startsWith("cid:") ||
isRemoteOrRelativeUrl(src))
if (lazySrc && isRemoteOrRelativeUrl(lazySrc) && !srcIsUseful) {
img.setAttribute("src", resolveUrl(lazySrc, baseHref))
} else if (src.trim() && (src.startsWith("data:") || src.startsWith("cid:"))) {
/* keep inline / embedded sources */
} else if (src && isRemoteOrRelativeUrl(src)) {
img.setAttribute("src", resolveUrl(src, baseHref))
} else if (lazySrc && isRemoteOrRelativeUrl(lazySrc)) {
img.setAttribute("src", resolveUrl(lazySrc, baseHref))
}
const srcset = img.getAttribute("srcset")
if (srcset) img.setAttribute("srcset", rewriteUrlList(srcset, baseHref))
const dataSrcset = img.getAttribute("data-srcset")
if (dataSrcset && !img.getAttribute("srcset")) {
img.setAttribute("srcset", rewriteUrlList(dataSrcset, baseHref))
}
img.setAttribute("referrerpolicy", "no-referrer")
}
for (const el of doc.querySelectorAll("[background]")) {
const bg = el.getAttribute("background")
if (!bg || !isRemoteOrRelativeUrl(bg)) continue
const resolved = resolveUrl(bg, baseHref)
el.removeAttribute("background")
const existing = el.getAttribute("style")?.trim() ?? ""
const bgRule = `background-image:url(${resolved})`
const nextStyle = existing
? existing.endsWith(";")
? `${existing}${bgRule}`
: `${existing};${bgRule}`
: bgRule
el.setAttribute("style", nextStyle)
}
for (const el of doc.querySelectorAll("[style]")) {
const style = el.getAttribute("style")
if (style?.includes("url(")) {
el.setAttribute("style", rewriteStyleRemoteUrls(style, baseHref))
}
}
for (const source of doc.querySelectorAll("source[src], source[srcset]")) {
const src = source.getAttribute("src")
if (src && isRemoteOrRelativeUrl(src)) {
source.setAttribute("src", resolveUrl(src, baseHref))
}
const srcset = source.getAttribute("srcset")
if (srcset) source.setAttribute("srcset", rewriteUrlList(srcset, baseHref))
}
return doc.body.innerHTML
} catch {
return html
}
}
const EMAIL_PREVIEW_GEIST_FONT_LINKS =
'<link rel="preconnect" href="https://fonts.googleapis.com">' +
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>' +
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap">'
/** Full HTML document for iframe srcDoc (scripts stripped; no live DOM injection). */
export function buildEmailPreviewSrcdoc(
parsed: ParsedEmailHtml,
options: {
csp: string
wrapperCss: string
plainTextFallback?: string
/** CSS injecté en fin de body (après styles expéditeur inline). */
bodyTailCss?: string
/** Charge Geist (CSP doit autoriser fonts.googleapis.com). */
loadAppFont?: boolean
}
): string {
const baseHref = parsed.documentBaseHref ?? parsed.resolveBaseHref
// headMarkup is CSS (<style> blocks) — only strip script-like tags via regex
const headMarkup = (parsed.headMarkup ?? "")
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
.replace(/<script\b[^>]*\/>/gi, "")
let bodyHtml = stripExecutableEmailHtml(parsed.bodyHtml)
if (!bodyHtml.trim() && options.plainTextFallback?.trim()) {
bodyHtml = plainTextFallbackHtml(options.plainTextFallback)
}
if (!bodyHtml.trim()) {
bodyHtml =
'<p style="color:#3c4043;font:14px ui-sans-serif,system-ui,sans-serif;margin:0">Ce message n\'a pas de contenu affichable.</p>'
}
const baseTag = baseHref
? `<base href="${escapeHtmlAttr(baseHref)}" target="_blank">`
: ""
const bodyTailStyle = options.bodyTailCss
? `<style data-ultimail-tail="true">${options.bodyTailCss}</style>`
: ""
const fontLinks = options.loadAppFont ? EMAIL_PREVIEW_GEIST_FONT_LINKS : ""
return (
"<!DOCTYPE html><html><head>" +
'<meta charset="utf-8">' +
`<meta http-equiv="Content-Security-Policy" content="${escapeHtmlAttr(options.csp)}">` +
baseTag +
fontLinks +
`<style data-ultimail-wrapper="true">${options.wrapperCss}</style>` +
headMarkup +
"</head><body>" +
bodyHtml +
bodyTailStyle +
"</body></html>"
)
}
/** Replace cid: references with blob URLs, or a data placeholder until blobs load. */
export function rewriteCidUrlsInHtml(
html: string,
cidUrlMap: Record<string, string> | undefined
): string {
if (!html.trim() || typeof DOMParser === "undefined") return html
if (!html.includes("cid:")) return html
const resolveCid = (value: string): string => resolveCidReference(cidUrlMap, value)
const CID_ATTRS = [
"src",
"data-src",
"data-original",
"data-lazy-src",
"background",
"poster",
] as const
try {
const doc = new DOMParser().parseFromString(html, "text/html")
for (const img of doc.querySelectorAll("img, video, source, table, td, th, div, span")) {
for (const attr of CID_ATTRS) {
const value = img.getAttribute(attr)
if (value?.toLowerCase().includes("cid:")) {
img.setAttribute(
attr,
value.replace(/cid:[^\s'")]+/gi, (match) => resolveCid(match))
)
}
}
const srcset = img.getAttribute("srcset")
if (srcset?.includes("cid:")) {
img.setAttribute(
"srcset",
srcset.replace(/cid:[^\s,]+/gi, (match) => resolveCid(match))
)
}
}
for (const el of doc.querySelectorAll("[style]")) {
const style = el.getAttribute("style")
if (style?.includes("cid:")) {
el.setAttribute(
"style",
style.replace(/cid:[^\s'")]+/gi, (match) => resolveCid(match))
)
}
}
return doc.body.innerHTML
} catch {
return html
}
}
function maybePrependPlainTextFallback(
bodyHtml: string,
plainTextFallback?: string
): string {
const fallback = plainTextFallback?.trim()
if (!fallback || htmlHasMeaningfulVisibleText(bodyHtml)) {
return bodyHtml
}
return plainTextFallbackHtml(fallback) + (bodyHtml || "")
}
export function prepareEmailHtmlForIframe(
html: string,
options: {
blockRemoteContent: boolean
isDark: boolean
senderEmail?: string
cidUrlMap?: Record<string, string>
plainTextFallback?: string
}
): ParsedEmailHtml {
if (!html.trim()) {
const fallback = options.plainTextFallback?.trim()
if (fallback) {
return { headMarkup: "", bodyHtml: plainTextFallbackHtml(fallback) }
}
return { headMarkup: "", bodyHtml: "" }
}
const resolveBaseHref =
inferEmailResolveBaseHref(html) ??
(options.senderEmail
? `https://${options.senderEmail.split("@")[1]?.trim().toLowerCase()}/`
: undefined)
if (options.blockRemoteContent) {
const parsed = parseEmailHtmlForIframe(html, resolveBaseHref)
let bodyHtml = stripHiddenEmailHtml(parsed.bodyHtml || html)
bodyHtml = stripRemoteResourcesForPreview(bodyHtml)
bodyHtml = rewriteCidUrlsInHtml(
preprocessEmailHtmlForTheme(bodyHtml, options.isDark),
options.cidUrlMap
)
const headMarkup = rewriteCidUrlsInHtml(
filterHeadMarkupForBlockedRemote(parsed.headMarkup),
options.cidUrlMap
)
bodyHtml = maybePrependPlainTextFallback(bodyHtml, options.plainTextFallback)
return {
headMarkup,
bodyHtml,
resolveBaseHref,
}
}
const parsed = parseEmailHtmlForIframe(html, resolveBaseHref)
let bodyHtml = stripHiddenEmailHtml(parsed.bodyHtml)
bodyHtml = activateRemoteResourcesInHtml(bodyHtml, {
baseHref: resolveBaseHref,
})
bodyHtml = rewriteCidUrlsInHtml(bodyHtml, options.cidUrlMap)
bodyHtml = maybePrependPlainTextFallback(bodyHtml, options.plainTextFallback)
const headMarkup = rewriteCidUrlsInHtml(
rewriteHeadMarkupUrls(parsed.headMarkup, resolveBaseHref),
options.cidUrlMap
)
return {
headMarkup,
bodyHtml,
documentBaseHref: parsed.documentBaseHref,
resolveBaseHref,
}
}