208 lines
8.1 KiB
JavaScript
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>
|
|
)
|
|
}
|