HR-ATS-Portal/frontend/src/ui/EmailBody.jsx

208 lines
8.1 KiB
JavaScript

/* ============================================================
EmailBody.jsx — render a candidate email as HTML, the way a mail client does.
Two layers of defence, because one is not enough:
1. A sanitiser pass strips scripts, embedded frames, form controls and every
event handler / javascript: URL before the markup is handed over.
2. The result renders inside an iframe whose sandbox never includes
allow-scripts, so even a miss in layer 1 cannot execute. A Content-Security-
Policy meta inside the document blocks every outbound request by default.
allow-scripts is the one token that must never appear here. Paired with
allow-same-origin it lets the frame reach into its own sandbox attribute and
remove it, which hands the attacker the parent origin. allow-same-origin on
its own is safe and is what lets the parent measure scrollHeight to size the
frame — no scripts run either way.
The iframe also isolates CSS. Emails ship <style> blocks written for Outlook;
inlined into the page they would restyle the whole app.
Remote images stay blocked until the user asks for them. A tracking pixel in
an applicant email would otherwise tell the sender exactly when a recruiter
opened it.
That CSS isolation has one cost worth stating plainly: custom properties do
not inherit across an iframe boundary, so this is the only component in the
app that cannot re-theme itself for free. Its palette is snapshotted into the
frame's <style> at build time, and useThemeVersion is what reruns that build.
============================================================ */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useThemeVersion } from '../theme/ThemeProvider'
/** Elements that have no business in a rendered email. */
const STRIP_TAGS = [
'script', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet',
'form', 'input', 'button', 'select', 'textarea', 'base', 'meta', 'link',
]
/** Anything else is a scheme we do not want behind a click. */
const SAFE_URL = /^(https?:|mailto:|tel:|cid:|data:image\/)/i
function sanitize(html) {
const doc = new DOMParser().parseFromString(String(html || ''), 'text/html')
doc.querySelectorAll(STRIP_TAGS.join(',')).forEach((node) => node.remove())
doc.querySelectorAll('*').forEach((node) => {
for (const attr of [...node.attributes]) {
const name = attr.name.toLowerCase()
// onclick, onerror, onload — the classic sanitiser bypass.
if (name.startsWith('on')) {
node.removeAttribute(attr.name)
continue
}
if ((name === 'href' || name === 'src' || name === 'action') && !SAFE_URL.test(attr.value.trim())) {
node.removeAttribute(attr.name)
}
if (name === 'srcdoc' || name === 'srcset') node.removeAttribute(attr.name)
}
})
// Sandbox blocks in-frame navigation, so a link has to open a new tab to work
// at all. noopener keeps the opened tab from reaching back via window.opener.
doc.querySelectorAll('a[href]').forEach((a) => {
a.setAttribute('target', '_blank')
a.setAttribute('rel', 'noopener noreferrer')
})
return doc.body?.innerHTML || ''
}
/**
* Inherit the host theme's colours so the email does not glare in dark mode.
*
* Called fresh on every srcDoc build — see the themeVersion dependency below.
* Read the attribute for color-scheme rather than sniffing whether --bg happens
* to start with an "f": that guess quietly inverts the frame's form controls and
* scrollbars the moment a palette changes shade.
*/
function frameStyles() {
const css = getComputedStyle(document.documentElement)
const pick = (name, fallback) => (css.getPropertyValue(name) || fallback).trim()
const dark = document.documentElement.getAttribute('data-theme') === 'dark'
return `
:root { color-scheme: ${dark ? 'dark' : 'light'}; }
body {
margin: 0;
background: ${pick('--bg-sunken', '#f7f7f8')};
color: ${pick('--text', '#111')};
font-family: ${pick('--sans', 'system-ui, sans-serif')};
font-size: 13px;
line-height: 1.7;
overflow-wrap: break-word;
}
img, table { max-width: 100%; }
img { height: auto; }
table { border-collapse: collapse; }
a { color: ${pick('--primary', '#2563eb')}; }
blockquote {
margin: 8px 0; padding-left: 12px;
border-left: 3px solid ${pick('--border', '#ddd')};
color: ${pick('--text-2', '#555')};
}
`
}
function buildSrcDoc(html, allowRemoteImages) {
const img = allowRemoteImages ? "img-src data: cid: https: http:" : "img-src data: cid:"
// default-src 'none' is the backstop: no fetches, no frames, no scripts, even
// if something slipped past sanitize().
const csp = `default-src 'none'; style-src 'unsafe-inline'; ${img}`
return `<!doctype html><html><head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<style>${frameStyles()}</style>
</head><body>${html}</body></html>`
}
/** True when the string carries real markup rather than incidental angle brackets. */
export function looksLikeHtml(value) {
return /<[a-z!/][\s\S]*>/i.test(String(value || ''))
}
export default function EmailBody({ html, maxHeight }) {
const ref = useRef(null)
const [allowRemoteImages, setAllowRemoteImages] = useState(false)
const [height, setHeight] = useState(320)
const [blockedImages, setBlockedImages] = useState(0)
const themeVersion = useThemeVersion()
// Both memoised because srcDoc IS the frame's source: handing React a new but
// equal string tears the document down and rebuilds it. Unmemoised, every
// unrelated parent render re-parsed the mail and reloaded the frame.
const clean = useMemo(() => sanitize(html), [html])
const srcDoc = useMemo(
// themeVersion is the entire reason this list exists. frameStyles()
// snapshots the CSS variables into the frame's <style>, and custom
// properties do not cross into an iframe, so without a rebuild the email
// keeps the palette it was born with while the rest of the app flips.
() => buildSrcDoc(clean, allowRemoteImages),
// eslint-disable-next-line react-hooks/exhaustive-deps
[clean, allowRemoteImages, themeVersion],
)
const measure = useCallback(() => {
const frame = ref.current
// contentDocument is readable only because the sandbox keeps allow-same-origin.
const body = frame?.contentDocument?.body
if (!body) return
setHeight(body.scrollHeight + 8)
}, [])
const onLoad = useCallback(() => {
measure()
const doc = ref.current?.contentDocument
if (!doc) return
const remote = [...doc.querySelectorAll('img[src]')].filter((i) =>
/^https?:/i.test(i.getAttribute('src') || ''),
)
setBlockedImages(allowRemoteImages ? 0 : remote.length)
// An image that arrives after load changes the height under us.
doc.querySelectorAll('img').forEach((i) => i.addEventListener('load', measure))
}, [measure, allowRemoteImages])
useEffect(() => {
window.addEventListener('resize', measure)
return () => window.removeEventListener('resize', measure)
}, [measure])
return (
<div>
{blockedImages > 0 && (
<div
className="flex items-center gap-8"
style={{
justifyContent: 'space-between',
padding: '8px 12px',
marginBottom: 8,
border: '1px solid var(--border)',
borderRadius: 8,
background: 'var(--bg-elev)',
fontSize: 12,
}}
>
<span className="text-muted">
{blockedImages} remote image{blockedImages === 1 ? '' : 's'} blocked to stop read tracking.
</span>
<button className="btn btn-ghost btn-sm" onClick={() => setAllowRemoteImages(true)}>
Show images
</button>
</div>
)}
<iframe
ref={ref}
className="email-frame"
title="Email body"
onLoad={onLoad}
// No allow-scripts. Ever. See the header comment.
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
srcDoc={srcDoc}
style={{ height: maxHeight ? Math.min(height, maxHeight) : height }}
/>
</div>
)
}