From 62f62dd781e348ebb53eec7b35ce03bbe33a08d2 Mon Sep 17 00:00:00 2001 From: "Yu, Wang" <727842003@qq.com> Date: Fri, 18 Sep 2026 17:20:31 +0800 Subject: [PATCH 1/2] feat(email): expand Outlook MSG body and attachment previews Decode Unicode/ANSI and binary HTML bodies, preserve recipient groups and local inline images, add bounded compressed-RTF handling, and reuse the existing nested-renderer contract for rich text and embedded messages. Isolate untrusted email HTML and clean up cancelled body/attachment views. Add deterministic CFB fixtures, parser tests, a Chromium UI gate and docs. Validation: 48 offline unit checks and 34 Chromium API-fixture checks pass. Five installed-MsgReader tests and full workspace verification remain pending. --- packages/renderers/email/README.en.md | 62 +++- packages/renderers/email/README.md | 64 +++- packages/renderers/email/package.json | 5 +- .../email/scripts/verify-msg-browser.mjs | 140 +++++++++ packages/renderers/email/src/email.ts | 291 ++++++++++++------ packages/renderers/email/src/emailHtml.ts | 164 ++++++++++ packages/renderers/email/src/msg.ts | 277 +++++++++++++++++ packages/renderers/email/src/msgCfb.ts | 143 +++++++++ packages/renderers/email/src/msgEncoding.ts | 39 +++ packages/renderers/email/src/msgMessages.ts | 37 +++ packages/renderers/email/src/msgRtf.ts | 194 ++++++++++++ .../email/test/evidence/msg-preview.webp | Bin 0 -> 9378 bytes .../renderers/email/test/fixtures/README.md | 11 + .../email/test/fixtures/msg-fixture.mjs | 198 ++++++++++++ packages/renderers/email/test/msg.test.mjs | 242 +++++++++++++++ 15 files changed, 1738 insertions(+), 129 deletions(-) create mode 100644 packages/renderers/email/scripts/verify-msg-browser.mjs create mode 100644 packages/renderers/email/src/emailHtml.ts create mode 100644 packages/renderers/email/src/msg.ts create mode 100644 packages/renderers/email/src/msgCfb.ts create mode 100644 packages/renderers/email/src/msgEncoding.ts create mode 100644 packages/renderers/email/src/msgMessages.ts create mode 100644 packages/renderers/email/src/msgRtf.ts create mode 100644 packages/renderers/email/test/evidence/msg-preview.webp create mode 100644 packages/renderers/email/test/fixtures/README.md create mode 100644 packages/renderers/email/test/fixtures/msg-fixture.mjs create mode 100644 packages/renderers/email/test/msg.test.mjs diff --git a/packages/renderers/email/README.en.md b/packages/renderers/email/README.en.md index 1fe8efeac..bdd23e6c2 100644 --- a/packages/renderers/email/README.en.md +++ b/packages/renderers/email/README.en.md @@ -1,11 +1,11 @@ # @file-viewer/renderer-email -Standalone email renderer package for Flyfish File Viewer. It handles `.eml`, `.msg`, and `.mbox` previews, body/header switching, attachment download, and nested attachment previews. +Browser-native `.eml`, Outlook `.msg`, and `.mbox` preview with body/header switching, attachment download and nested attachment preview. No server conversion or runtime CDN is used. ## Usage ```ts -import FileViewer from '@file-viewer/vue3' +import { FileViewer } from '@file-viewer/vue3' import { emailRenderer } from '@file-viewer/renderer-email' const options = { @@ -14,27 +14,61 @@ const options = { } ``` -You can also compose it with other renderers: +Pass a real `File` named `message.msg`, or a URL with the original filename. The existing email route selects the MSG parser lazily; EML/MBOX do not import the MSG decoder. + +For attachment previews, compose the corresponding PDF, image, Word and other renderers. The host supplies the standard `renderNestedBuffer` callback. Embedded Outlook messages are extracted as `.msg`, not renamed to text; original attachment bytes are retained for download. + +### Rich-text Outlook messages + +The full preset already includes the Word renderer and RTF capability: + +```ts +import allRenderers from '@file-viewer/preset-all' +const options = { preset: allRenderers } +``` + +For a selective integration, install `@file-viewer/renderer-word` and `@file-viewer/capability-rtf` alongside the email renderer: ```ts import { emailRenderer } from '@file-viewer/renderer-email' -import { pdfRenderer } from '@file-viewer/renderer-pdf' -import { archiveRenderer } from '@file-viewer/renderer-archive' +import { wordRenderer } from '@file-viewer/renderer-word' +import '@file-viewer/capability-rtf' const options = { builtinRenderers: 'none', - renderers: [pdfRenderer, archiveRenderer, emailRenderer], + renderers: [emailRenderer, wordRenderer], } ``` -## Capabilities +Compressed RTF is decompressed locally. HTML-encapsulated RTF can supply an HTML body; ordinary rich RTF uses the existing RTF.js capability through the nested renderer contract. An email-only installation retains readable plain text with a notice when rich rendering is unavailable. Email RTF always blocks external resources and links, even when the host opts into them for other document types. + +## Outlook MSG coverage -- Parses `.eml` and `.mbox` with `postal-mime`. -- Parses Outlook `.msg` with `@kenjiuno/msgreader`. -- Supports HTML body, plain-text body, and raw header switching. HTML email is rendered read-only inside a sandbox iframe. -- Supports attachment download and nested preview through `renderNestedBuffer` when the host viewer provides it. -- Does not depend on any online service or public CDN, making it suitable for intranet attachment centers, ticket email archives, and customer communication review. +- Unicode and ANSI properties; Outlook codepages, Unicode `bodyHtml` and binary `html`, BOM and HTML charset handling. +- Subject, From, on-behalf-of Sender, separate To/Cc/Bcc, submission/delivery time, original transport headers and a clearly labeled metadata fallback for drafts. +- HTML, plain text and RTF body switching; tables, authored styles and local CID/Content-Location raster images. +- MIME-aware attachment names, lazy binary extraction, downloads and nested MSG preview. Unknown sizes are shown as unknown until extraction, not as zero bytes. +- Body uses the available height until an attachment opens. Closing an attachment restores body space and keyboard focus. Pending views and owned object URLs are cleaned up on replacement, cancellation and unmount. +- Simplified Chinese, English, Japanese and German notices follow the viewer locale. Narrow-host layout and light/dark presentation are supported. + +EML/MBOX remain on `postal-mime`; MBOX still previews its first message and reports the count rather than implementing a mailbox browser. + +## Privacy, limits and compatibility + +Email HTML is read-only in an empty-sandbox iframe. Sanitization and a restrictive CSP block scripts, forms, embedded documents, local paths, external stylesheets, fonts, tracking images and remote resource requests. Only owned local image resources and raster image data URLs are allowed. External hyperlinks are not activated. This applies to EML/MBOX as well as MSG: emails that relied on remote images will no longer fetch them automatically. Normal origins use blob URLs; opaque-origin WebViews use bounded local data URLs for inline images. + +Limits: 128 MiB MSG source/individual attachment, 32 MiB HTML or decompressed RTF, 32 MiB eagerly extracted inline images, 1,024 attachments and 4,096 recipients per message, 32,768 CFB directory entries and 24 storage levels. HTML additionally bounds node count and serialized/inline expansion. Malformed, cyclic, truncated or excessive input fails explicitly. These are defensive bounds, not a claim of a complete adversarial parser audit. + +S/MIME/IRM decryption and signature verification are **not** implemented. Protected/signed message classes are identified with a notice; only available content is displayed. MSG contacts, tasks, appointments, OLE activation and every Outlook-specific MAPI property are not complete Outlook replacements. RTF fidelity is limited by the installed RTF capability. No claim of pixel-identical Outlook rendering is made without a matching real-file comparison. + +## Verification + +```sh +pnpm --filter @file-viewer/renderer-email verify:email +pnpm exec playwright install chromium +pnpm --filter @file-viewer/renderer-email verify:msg:browser +``` -## Migration Note +The existing root email regression gate invokes `verify:email`, preserving the EML #232 checks and adding the MSG tests. `test/msg.test.mjs` includes actual installed-MsgReader tests over generated CFB v3/v4, ANSI, Unicode, RTF and nested-message fixtures. Those integration cases are required by default; `MSG_UNIT_ONLY=1` is only an explicitly reported offline subset. -The core package no longer bundles the email renderer and no longer installs `postal-mime` / `@kenjiuno/msgreader` directly. Install this renderer explicitly, or use `@file-viewer/preset-all`, when email preview is required. +The browser gate uses the emitted production renderer and real Chromium with explicit Reader/RTF host API fixtures. It covers layout, sanitization, downloads, races and disposal, **not** installed-parser integration or RTF.js visual parity. It writes screenshots and a JSON report to `output/msg-browser/`. See [fixture provenance](test/fixtures/README.md). diff --git a/packages/renderers/email/README.md b/packages/renderers/email/README.md index 9171ffd04..6258d0c50 100644 --- a/packages/renderers/email/README.md +++ b/packages/renderers/email/README.md @@ -1,11 +1,11 @@ # @file-viewer/renderer-email -Flyfish File Viewer 的独立邮件 renderer 包。它负责 `.eml`、`.msg`、`.mbox` 邮件预览、正文/头信息切换、附件下载和附件嵌套预览。 +浏览器原生预览 `.eml`、Outlook `.msg`、`.mbox`,支持正文/邮件头切换、附件下载和嵌套附件预览,不依赖服务端转换或运行时 CDN。 -## 用法 +## 接入 ```ts -import FileViewer from '@file-viewer/vue3' +import { FileViewer } from '@file-viewer/vue3' import { emailRenderer } from '@file-viewer/renderer-email' const options = { @@ -14,27 +14,61 @@ const options = { } ``` -也可以与其他 renderer 组合: +传入带真实 `.msg` 文件名的 `File`,或保留原文件名的 URL。沿用现有 email 路由,仅在命中 MSG 时加载其解析模块,EML/MBOX 不加载 MSG 解析器。 + +附件预览需要组合相应的 PDF、图片、Word 等 renderer,由宿主提供标准 `renderNestedBuffer`。内嵌 Outlook 邮件按真实 `.msg` 提取和预览,不伪装成文本;下载保留附件原始字节。 + +### Outlook 富文本正文 + +Full preset 已包含 Word renderer 和 RTF 能力: + +```ts +import allRenderers from '@file-viewer/preset-all' +const options = { preset: allRenderers } +``` + +按需接入时,同时安装 `@file-viewer/renderer-word` 和 `@file-viewer/capability-rtf`: ```ts import { emailRenderer } from '@file-viewer/renderer-email' -import { pdfRenderer } from '@file-viewer/renderer-pdf' -import { archiveRenderer } from '@file-viewer/renderer-archive' +import { wordRenderer } from '@file-viewer/renderer-word' +import '@file-viewer/capability-rtf' const options = { builtinRenderers: 'none', - renderers: [pdfRenderer, archiveRenderer, emailRenderer], + renderers: [emailRenderer, wordRenderer], } ``` -## 能力边界 +本地解压压缩 RTF;其中封装的 HTML 可用于正文预览,普通富文本通过嵌套 renderer 复用现有 RTF.js 能力。仅安装 email renderer 时仍可阅读提取的纯文本,并提示富文本能力未安装。邮件 RTF 始终禁止外部资源和链接,不继承宿主针对其他文档开启的外链权限。 + +## MSG 能力 -- `.eml` 和 `.mbox` 使用 `postal-mime` 解析邮件头、HTML 正文、纯文本正文和附件。 -- `.msg` 使用 `@kenjiuno/msgreader` 解析 Outlook MSG 文件。 -- 支持 HTML / 正文 / 头信息切换,HTML 邮件在 sandbox iframe 中只读展示。 -- 支持附件下载;宿主 viewer 提供 `renderNestedBuffer` 时,附件会继续复用 PDF、Office、图片、代码等现有 renderer。 -- 不绑定任何在线服务或公共 CDN,适合内网附件中心、工单邮件归档和客户来信查看。 +- Unicode/ANSI 属性、Outlook 代码页、Unicode `bodyHtml` 与二进制 `html`、BOM 和 HTML charset 解码。 +- 主题、发件人、代发 Sender、独立的 To/Cc/Bcc、提交/投递时间、原始邮件头;草稿缺少邮件头时显示明确标识的元数据摘要。 +- HTML、纯文本、RTF 正文切换,保留表格、作者样式和本地 CID/Content-Location 栅格图片。 +- MIME、附件安全文件名、惰性二进制提取、原字节下载和内嵌 MSG 预览;未知大小在提取前显示未知,不误报为零字节。 +- 未打开附件时正文占满可用高度;关闭附件恢复正文空间和键盘焦点;切换、取消、卸载会清理过期预览和资源 URL。 +- 提示支持简体中文、英语、日语、德语,跟随 viewer locale;适配窄容器和明暗主题。 + +EML/MBOX 继续使用 `postal-mime`。MBOX 仍预览第一封邮件并提示邮件数量,本次不新增邮箱列表浏览器。 + +## 隐私、限制与兼容性 + +HTML 正文位于空 sandbox iframe 内,通过净化和严格 CSP 禁止脚本、表单操作、嵌入文档、本地路径、外部样式、字体、追踪图片及远程资源请求。只允许本地持有的图片资源和栅格图片 data URL,外部超链接不激活。此规则同时作用于 EML/MBOX,因此历史上依赖远程图片的邮件不再自动联网加载。普通来源使用 Blob URL;不透明来源的 WebView 使用有界的本地 data URL 展示内嵌图片。 + +安全上限:MSG 原文件/单个附件 128 MiB,HTML 或解压后的 RTF 32 MiB,提前解码的内嵌图片合计 32 MiB,每封邮件最多 1,024 个附件、4,096 个收件人,CFB 最多 32,768 个目录项、24 层存储。HTML 另有节点数量和序列化/内嵌资源膨胀上限。损坏、循环、截断或超限输入明确失败;这些防御性限制不代表完成了全面对抗性解析器审计。 + +**不实现 S/MIME/IRM 解密或签名验证。** 对受保护/签名邮件类型显示提示,仅展示可获得的内容。联系人、任务、日程、OLE 激活和全部 Outlook 专有 MAPI 属性不属于完整 Outlook 替代范围。富文本还原度受已安装 RTF 能力限制,未经真实文件对照不承诺与 Outlook 像素一致。 + +## 验证 + +```sh +pnpm --filter @file-viewer/renderer-email verify:email +pnpm exec playwright install chromium +pnpm --filter @file-viewer/renderer-email verify:msg:browser +``` -## 迁移说明 +根目录原有邮件回归入口会调用 `verify:email`,保留 EML #232 检查并加入 MSG 测试。`test/msg.test.mjs` 默认使用真实安装的 MsgReader,覆盖生成的 CFB v3/v4、ANSI、Unicode、RTF 和嵌套邮件。`MSG_UNIT_ONLY=1` 仅用于明确标注的离线子集,不能替代集成验收。 -`@file-viewer/core` 已不再内置 email renderer,也不再直接安装 `postal-mime` / `@kenjiuno/msgreader`。需要邮件预览时,请显式安装本包,或直接使用 `@file-viewer/preset-all` 聚合能力。 +浏览器测试使用生产 renderer 编译结果、真实 Chromium,以及明确声明的 Reader/RTF 宿主 API 测试替身,验证布局、安全隔离、下载、竞态和清理;不代表真实解析器集成或 RTF.js 视觉还原验收。截图和 JSON 结果输出到 `output/msg-browser/`。参见[样本来源说明](test/fixtures/README.md)。 diff --git a/packages/renderers/email/package.json b/packages/renderers/email/package.json index d802132da..57f950bb8 100644 --- a/packages/renderers/email/package.json +++ b/packages/renderers/email/package.json @@ -58,7 +58,10 @@ "scripts": { "build": "tsc -b tsconfig.json", "type-check": "tsc -b tsconfig.json", - "verify:github-232": "pnpm --filter @file-viewer/core build && pnpm build && node scripts/verify-github-232.mjs" + "verify:email": "pnpm --filter @file-viewer/core build && pnpm build && node scripts/verify-github-232.mjs && node --test test/msg.test.mjs", + "verify:github-232": "pnpm verify:email", + "verify:msg": "pnpm --filter @file-viewer/core build && pnpm build && node --test test/msg.test.mjs", + "verify:msg:browser": "pnpm --filter @file-viewer/core build && pnpm build && node scripts/verify-msg-browser.mjs" }, "dependencies": { "@file-viewer/core": "workspace:3.1.1", diff --git a/packages/renderers/email/scripts/verify-msg-browser.mjs b/packages/renderers/email/scripts/verify-msg-browser.mjs new file mode 100644 index 000000000..116390c4b --- /dev/null +++ b/packages/renderers/email/scripts/verify-msg-browser.mjs @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { resolve, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createMsgFixture, PNG, rtfEnvelope } from '../test/fixtures/msg-fixture.mjs'; + +// Real Chromium + actual emitted renderer modules. Reader and nested RTF APIs +// are explicit fixtures here; the separate msg.test.mjs gate tests the installed +// binary parser. These assertions do NOT establish Outlook/RTF.js visual parity. +const packageRoot = fileURLToPath(new URL('../', import.meta.url)); +const dist = resolve(process.env.MSG_TEST_DIST || join(packageRoot, 'dist')); +const output = resolve(process.env.MSG_BROWSER_OUTPUT || join(packageRoot, 'output/msg-browser')); +const playwright = await import(process.env.FILE_VIEWER_PLAYWRIGHT_MODULE + ? pathToFileURL(resolve(process.env.FILE_VIEWER_PLAYWRIGHT_MODULE)).href : 'playwright'); +await mkdir(output, { recursive: true }); +const dataModule = source => 'data:text/javascript;base64,' + Buffer.from(source).toString('base64'); +const imports = {}; +for (const file of (await readdir(dist)).filter(file => file.endsWith('.js'))) { + const source = (await readFile(join(dist, file), 'utf8')).replace(/(['"])\.\/([^'"]+\.js)\1/g, (_all, _quote, path) => JSON.stringify('fv/' + path)); + imports['fv/' + file] = dataModule(source); +} +const labels = { 'email.meta.from': 'From', 'email.meta.to': 'To', 'email.meta.cc': 'Cc', 'email.meta.date': 'Date', 'email.tabs.text': 'Text', 'email.tabs.headers': 'Headers', 'email.attachments.download': 'Download', 'email.attachments.title': 'Attachments', 'email.attachments.empty': 'No attachments', 'email.loading.parsing': 'Reading email', 'email.error.title': 'Email preview notice', 'email.attachments.opening': 'Opening attachment', 'email.attachments.nestedUnavailable': 'No renderer installed' }; +imports['@file-viewer/core'] = dataModule(`const labels=${JSON.stringify(labels)}; +export const createFileViewerTranslator=()=>key=>labels[key]||key; +export const resolveFileViewerColorScheme=(theme,dark)=>theme==='dark'||theme==='system'&&dark?'dark':'light'; +export const disposeFileViewerRendered=async instance=>{await instance?.unmount?.()}; +export const resolveFileViewerLocale=input=>input?.i18n?.locale||input?.locale||'en-US';`); +imports['@kenjiuno/msgreader'] = dataModule(`export default class Reader { + constructor(){this.data=globalThis.__fields;} + getFileData(){return this.data;} + getAttachment(a){return {fileName:a.fileName||a.name,content:new Uint8Array(a.bytes||[])};} +}`); +imports['postal-mime'] = dataModule('export default {async parse(){return globalThis.__postal}}'); +const runtime = `import renderEmail from 'fv/email.js'; +import {createEmailHtmlDocument} from 'fv/emailHtml.js'; +window.sanitize=createEmailHtmlDocument;window.urls=new Set();window.revoked=[]; +const create=URL.createObjectURL.bind(URL),revoke=URL.revokeObjectURL.bind(URL); +URL.createObjectURL=blob=>{const url=create(blob);window.urls.add(url);return url;}; +URL.revokeObjectURL=url=>{window.urls.delete(url);window.revoked.push(url);revoke(url);}; +const buffer=new Uint8Array(${JSON.stringify([...new Uint8Array(createMsgFixture())])}).buffer; +window.nestedCalls=[];window.disposedChildren=[];window.pending=[]; +window.nested=async(buffer,type,target,context)=>{ + const name=context.filename;window.nestedCalls.push({type,name,options:context.options}); + if(window.deferNested)await new Promise(resolve=>window.pending.push({name,resolve})); + const child=document.createElement('pre');child.textContent=type==='rtf'?'RTF capability host fixture':new TextDecoder().decode(buffer);target.replaceChildren(child); + return {$el:child,unmount(){window.disposedChildren.push(name);target.replaceChildren();}}; +}; +window.mount=async(fields,options={})=>{ + fields.html=Array.isArray(fields.html)?new Uint8Array(fields.html):fields.html; + fields.compressedRtf=fields.compressedRtf?new Uint8Array(fields.compressedRtf):undefined; + window.__fields=fields; + window.current=await renderEmail(buffer,document.querySelector('#host'),'msg',{filename:'outlook-preview.msg',options:{locale:'en-US',theme:options.theme||'light'},signal:window.parentController?.signal,renderNestedBuffer:options.noNested?undefined:window.nested}); +};window.ready=true;`; +const document = `
`; +const html = '

Outlook MSG · 中文 日本語

Binary HTML, table layout and inline images are preserved.

ItemStatus
Quarterly reportReady
BudgetApproved

Embedded image Embedded image is loaded locally.

'; +const fields = { + dataType: 'msg', subject: 'Outlook MSG · Quarterly review', senderName: 'Sender', senderEmail: '/O=EXCHANGE/CN=SENDER', senderSmtpAddress: 'sender@example.test', + body: 'Plain text body — 中文 日本語', html: [...Buffer.from(html)], clientSubmitTime: 'Fri, 18 Sep 2026 00:00:00 GMT', + headers: 'From: sender@example.test\r\nTo: alice@example.test\r\nSubject: Quarterly review\r\n', + recipients: [{ name: 'Alice', smtpAddress: 'alice@example.test', recipType: 'to' }, { name: 'Carol', email: 'carol@example.test', recipType: 'cc' }, { name: 'Bob', email: 'bob@example.test', recipType: 'bcc' }], + attachments: [{ fileName: 'inline.png', attachMimeTag: 'image/png', pidContentId: 'logo@example.test', contentLength: PNG.length, bytes: [...PNG] }, { fileName: 'report.txt', attachMimeTag: 'text/plain', contentLength: 17, bytes: [...Buffer.from('Attachment bytes\n')] }, { name: 'Forwarded message', innerMsgContent: true, bytes: [...Buffer.from('nested placeholder')] }], +}; +const browser = await playwright.chromium.launch({ headless: true, ...(process.env.CHROMIUM_EXECUTABLE_PATH ? { executablePath: process.env.CHROMIUM_EXECUTABLE_PATH } : {}) }); +const checks = [], external = [], errors = []; +try { + const page = await browser.newPage({ viewport: { width: 1180, height: 760 }, deviceScaleFactor: 1 }); + page.on('request', request => { if (/^https?:/.test(request.url())) external.push(request.url()); }); + page.on('pageerror', error => errors.push(String(error))); + await page.setContent(document); await page.waitForFunction(() => window.ready); + const mount = async (value = fields, options = {}) => page.evaluate(({ value, options }) => window.mount(value, options), { value, options }); + const check = (name, value) => { assert.ok(value, name); checks.push(name); }; + await mount(); + const frame = page.frameLocator('iframe.email-html'); + await frame.locator('img').first().evaluate(img => img.decode()); + check('binary HTML body', await frame.locator('h1').innerText() === 'Outlook MSG · 中文 日本語'); + check('HTML table cells', await frame.locator('table td').count() === 6); + const meta = await page.locator('.email-meta').innerText(); + check('To/Cc/Bcc separation', ['alice@example.test', 'carol@example.test', 'bob@example.test', 'Bcc'].every(s => meta.includes(s))); + check('SMTP preferred to Exchange DN', !meta.includes('/O=EXCHANGE')); + check('empty iframe sandbox', await page.locator('iframe').getAttribute('sandbox') === ''); + check('local CID resource', /^(blob:|data:image\/)/.test(await frame.locator('img').first().getAttribute('src'))); + check('CID raster decoded', await frame.locator('img').first().evaluate(img => img.complete && img.naturalWidth === 24)); + check('no tracking requests', external.length === 0); + check('scripts removed', await frame.locator('script').count() === 0 && !await page.evaluate(() => window.injected === true)); + check('body fills height', (await page.locator('.email-message-content').boundingBox()).height >= (await page.locator('.email-body').boundingBox()).height - 2); + await page.screenshot({ path: join(output, 'msg-after-desktop.png') }); + await page.getByRole('button', { name: 'Text', exact: true }).click(); + check('plain-text tab', await page.locator('.email-text').innerText() === fields.body); + await page.getByRole('button', { name: 'Headers', exact: true }).click(); + check('raw headers', (await page.locator('.email-text').innerText()).replace(/\r\n/g, '\n') === fields.headers.replace(/\r\n/g, '\n')); + await page.getByRole('button', { name: 'HTML', exact: true }).click(); + await page.locator('.attachment-item').nth(1).click(); await page.waitForFunction(() => window.nestedCalls.length === 1); + check('attachment preview bytes', await page.locator('.attachment-target').innerText() === 'Attachment bytes\n'); + const downloading = page.waitForEvent('download'); await page.getByRole('button', { name: 'Download', exact: true }).click(); const download = await downloading; + check('download basename', download.suggestedFilename() === 'report.txt'); + check('download bytes', await readFile(await download.path(), 'utf8') === 'Attachment bytes\n'); + await page.getByRole('button', { name: 'Close attachment preview' }).click(); + check('close restores space', !await page.locator('.attachment-preview').isVisible()); + check('close restores focus', await page.locator('.attachment-item').nth(1).evaluate(e => e === document.activeElement)); + await page.evaluate(() => { window.deferNested = true; }); + // Programmatic clicks deliberately stress a race normally covered by the overlay. + await page.locator('.attachment-item').nth(1).evaluate(e => e.click()); await page.waitForFunction(() => window.pending.length === 1); + await page.locator('.attachment-item').nth(2).evaluate(e => e.click()); await page.waitForFunction(() => window.pending.length === 2); + await page.evaluate(() => window.pending[1].resolve()); await page.waitForFunction(() => document.querySelector('.attachment-target').textContent.includes('nested placeholder')); + await page.evaluate(() => window.pending[0].resolve()); await page.waitForFunction(() => window.disposedChildren.filter(n => n === 'report.txt').length >= 2); + check('stale nested view disposed', await page.evaluate(() => window.disposedChildren.filter(n => n === 'report.txt').length >= 2)); + check('selected attachment not overwritten', await page.locator('.attachment-preview-head strong').innerText() === 'Forwarded message.msg' && (await page.locator('.attachment-target').innerText()).includes('nested placeholder')); + await page.evaluate(async () => { window.deferNested = false; await window.current.unmount(); }); + check('unmount revokes URLs', await page.evaluate(() => window.urls.size) === 0); + check('unmount removes UI', await page.locator('#host').innerHTML() === ''); + await page.setViewportSize({ width: 390, height: 844 }); await mount(); + await page.screenshot({ path: join(output, 'msg-after-mobile.png') }); + check('mobile horizontal bounds', await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + check('mobile readable body height', (await page.locator('.email-message-content').boundingBox()).height > 180); + await page.evaluate(() => window.current.unmount()); await page.setViewportSize({ width: 1180, height: 760 }); await mount(fields, { theme: 'dark' }); + await page.locator('#host').evaluate(e => { e.dataset.viewerTheme = 'dark'; }); + await page.screenshot({ path: join(output, 'msg-after-dark.png') }); + check('dark body scheme', await page.frameLocator('iframe').locator('body').evaluate(e => getComputedStyle(e).colorScheme) === 'dark'); + const sanitized = await page.evaluate(() => window.sanitize('bad', new Map([['a', 'blob:a'], ['a@x', 'blob:b']]))); + check('exact CID, no prefix replacement', !sanitized.includes('cid:ab') && sanitized.split('src="blob:a"').length === 3 && sanitized.includes('src="blob:b"')); + check('active HTML and local paths removed', ![' sanitized.includes(s))); + const preserved = await page.evaluate(() => window.sanitize('

Visible text

', new Map([['a%40x', 'blob:literal']]))); + check('authored body and styles preserved', ['lang="ja"', 'dir="rtl"', '#123456', '#abcdef', 'p{color:green}', 'Visible text'].every(s => preserved.includes(s))); + check('form contents retained without controls', !preserved.includes('Visible text

')); + check('percent in raw Content-ID stays literal', preserved.includes('src="blob:literal"')); + await page.evaluate(async () => { await window.current.unmount(); window.parentController = new AbortController(); window.parentController.abort(); }); + const aborted = await page.evaluate(async value => { try { await window.mount(value); return false; } catch (error) { return error.name === 'AbortError'; } }, fields); + check('pre-aborted render cleanup', aborted && await page.evaluate(() => window.urls.size === 0 && !document.querySelector('#host').childNodes.length)); + await page.evaluate(() => { window.parentController = undefined; }); + await mount({ ...fields, html: undefined, body: '', attachments: [], compressedRtf: [...rtfEnvelope(String.raw`{\rtf1\ansi Rich \b body\b0}`, true)] }); + check('RTF delegated to host capability', await page.locator('.email-rtf').innerText() === 'RTF capability host fixture'); + check('RTF external resources blocked', await page.evaluate(() => window.nestedCalls.at(-1).options.docx.externalResourcePolicy) === 'block'); + await page.getByRole('button', { name: 'Text', exact: true }).click(); await page.waitForFunction(() => window.disposedChildren.includes('outlook-preview.msg.rtf')); + check('RTF disposed on body switch', await page.evaluate(() => window.disposedChildren.includes('outlook-preview.msg.rtf'))); + await page.evaluate(() => window.current.unmount()); + check('no unhandled browser errors', errors.length === 0); + const report = { environment: 'Chromium; emitted renderer; explicit Reader/RTF API fixtures, not installed parser/engine integration', passed: checks.length, checks, externalRequests: external, pageErrors: errors }; + await writeFile(join(output, 'results.json'), JSON.stringify(report, null, 2) + '\n'); + console.log(JSON.stringify(report, null, 2)); +} finally { await browser.close(); } diff --git a/packages/renderers/email/src/email.ts b/packages/renderers/email/src/email.ts index 72d2efac1..174e2ccf2 100644 --- a/packages/renderers/email/src/email.ts +++ b/packages/renderers/email/src/email.ts @@ -8,8 +8,11 @@ import type { FileViewerRenderedInstance, } from '@file-viewer/core'; +import { createEmailHtmlDocument, createEmailImageResource } from './emailHtml.js'; +import { getMsgLabels, type MsgNotice } from './msgMessages.js'; + type EmailKind = 'eml' | 'msg' | 'mbox'; -type EmailBodyMode = 'html' | 'text' | 'headers'; +type EmailBodyMode = 'html' | 'rtf' | 'text' | 'headers'; interface EmailAddress { name?: string; @@ -22,6 +25,8 @@ interface EmailAttachmentView { mimeType?: string; size: number; contentId?: string; + contentLocation?: string; + inline?: boolean; load(): Promise; } @@ -29,8 +34,12 @@ export interface ParsedEmailView { kind: EmailKind; subject: string; from: EmailAddress[]; + sender?: EmailAddress[]; to: EmailAddress[]; cc: EmailAddress[]; + bcc?: EmailAddress[]; + rtf?: ArrayBuffer; + warnings?: MsgNotice[]; date?: string; text?: string; html?: string; @@ -41,7 +50,7 @@ export interface ParsedEmailView { const emailStyle = ` .email-viewer{position:relative;height:100%;min-height:0;display:flex;flex-direction:column;background:#f3f6f8;color:#172033;box-sizing:border-box} .email-viewer *{box-sizing:border-box} -.email-header{padding:18px 22px;border-bottom:1px solid rgba(23,32,51,.08);background:#fff} +.email-header{max-height:35%;overflow:auto;flex:0 0 auto;padding:18px 22px;border-bottom:1px solid rgba(23,32,51,.08);background:#fff} .email-header>span{color:#1f7a58;font-size:12px;font-weight:900} .email-header h2{margin:4px 0 12px;font-size:22px;line-height:1.25} .email-meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 18px} @@ -49,7 +58,7 @@ const emailStyle = ` .email-meta strong{margin-right:8px;color:#172033} .email-body{flex:1;min-height:0;display:grid;grid-template-columns:minmax(240px,300px) minmax(0,1fr)} .email-sidebar{min-height:0;display:flex;flex-direction:column;gap:14px;padding:14px;border-right:1px solid rgba(23,32,51,.08);background:rgba(255,255,255,.7)} -.body-tabs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;padding:4px;border-radius:12px;background:rgba(23,32,51,.06)} +.body-tabs{display:grid;grid-template-columns:repeat(auto-fit,minmax(52px,1fr));gap:6px;padding:4px;border-radius:12px;background:rgba(23,32,51,.06)} .body-tabs button,.attachment-item,.attachment-preview-head button{font:inherit;cursor:pointer} .body-tabs button{height:34px;border:0;border-radius:9px;background:transparent;color:#64748b;font-size:12px;font-weight:800} .body-tabs button.active{background:#fff;color:#172033} @@ -63,7 +72,13 @@ const emailStyle = ` .attachment-item span{grid-row:span 2;height:38px;display:inline-flex;align-items:center;justify-content:center;border-radius:10px;background:rgba(31,122,88,.12);color:#1f7a58;font-size:11px;font-weight:900} .attachment-item strong,.attachment-item em{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .attachment-item em{color:#64748b;font-size:12px;font-style:normal} -.message-panel{min-width:0;min-height:0;display:grid;grid-template-rows:minmax(240px,46%) minmax(0,1fr)} +.message-panel{min-width:0;min-height:0;display:grid;grid-template-rows:minmax(0,1fr)} +.message-panel.has-attachment-preview{grid-template-rows:minmax(160px,46%) minmax(0,1fr)} +.email-rtf{height:100%;overflow:auto} +.email-notices{max-height:25%;overflow:auto;margin:0;padding:10px 22px;background:#fff7e8;color:#714700;font-size:13px;line-height:1.5} +.email-notices p{margin:4px 0} +.attachment-preview-actions{display:flex;gap:8px;align-items:center} +[data-viewer-theme='dark'] .email-notices{background:#332711;color:#f8daa0} .email-message-content{min-height:0} .email-html,.email-text{width:100%;height:100%;border:0;background:#fff} .email-text{margin:0;overflow:auto;padding:20px;white-space:pre-wrap;word-break:break-word;line-height:1.65} @@ -86,7 +101,8 @@ const emailStyle = ` [data-viewer-theme='dark'] .email-html{background:#111827;color-scheme:dark} [data-viewer-theme='dark'] .email-state{background:rgba(13,17,23,.9);color:#cbd5e1} @media (prefers-color-scheme:dark){[data-viewer-theme='system'] .email-viewer{background:#172033;color:#e5eef8}[data-viewer-theme='system'] .email-header,[data-viewer-theme='system'] .email-sidebar,[data-viewer-theme='system'] .attachment-item,[data-viewer-theme='system'] .email-text,[data-viewer-theme='system'] .attachment-preview-head{border-color:rgba(139,148,158,.2);background:#111827;color:#e5eef8}[data-viewer-theme='system'] .email-meta p,[data-viewer-theme='system'] .attachment-item em,[data-viewer-theme='system'] .attachment-title span,[data-viewer-theme='system'] .attachment-empty{color:#94a3b8}[data-viewer-theme='system'] .email-meta strong,[data-viewer-theme='system'] .attachment-title,[data-viewer-theme='system'] .attachment-item strong{color:#f8fafc}[data-viewer-theme='system'] .body-tabs{background:rgba(139,148,158,.12)}[data-viewer-theme='system'] .body-tabs button{color:#94a3b8}[data-viewer-theme='system'] .body-tabs button.active{background:#1f2937;color:#f8fafc}[data-viewer-theme='system'] .email-html{background:#111827;color-scheme:dark}[data-viewer-theme='system'] .email-state{background:rgba(13,17,23,.9);color:#cbd5e1}} -@media (max-width:860px){.email-meta,.email-body{grid-template-columns:1fr}.email-body{grid-template-rows:auto minmax(0,1fr)}.email-sidebar{border-right:0;border-bottom:1px solid rgba(23,32,51,.08)}} +@media (prefers-reduced-motion:reduce){.email-state span{animation:none}} +@media (max-width:860px){.email-sidebar{max-height:220px;overflow:auto}.email-meta,.email-body{grid-template-columns:1fr}.email-body{grid-template-rows:auto minmax(0,1fr)}.email-sidebar{border-right:0;border-bottom:1px solid rgba(23,32,51,.08)}} `; const formatBytes = (value: number) => { @@ -166,8 +182,7 @@ const createPostalAttachments = ( return; } const buffer = await attachment.load(); - const url = URL.createObjectURL(new Blob([buffer], { type: attachment.mimeType })); - objectUrls.push(url); + const url = createEmailImageResource(buffer, attachment.mimeType!, objectUrls); cidUrls.set(normalizeContentId(attachment.contentId), url); })).then(() => attachments); }; @@ -239,50 +254,18 @@ const parseMbox = async ( }; }; -const parseMsg = async (buffer: ArrayBuffer, filename: string): Promise => { - const msgReaderModule = await import('@kenjiuno/msgreader'); - const MsgReader = ((msgReaderModule.default as any)?.default || msgReaderModule.default) as any; - const reader = new MsgReader(buffer); - const fileData = reader.getFileData(); - const attachments: EmailAttachmentView[] = (fileData.attachments || []).map((attachment: any, index: number) => { - const name = attachment.fileName || attachment.fileNameShort || attachment.name || `attachment-${index + 1}${attachment.extension || ''}`; - return { - id: `${index}-${name}`, - name, - mimeType: 'application/octet-stream', - size: attachment.contentLength || attachment.size || 0, - contentId: attachment.pidContentId, - async load() { - const file = reader.getAttachment(attachment); - return toArrayBuffer(file.content); - }, - }; - }); - - return { - kind: 'msg', - subject: fileData.subject || filename, - from: normalizeAddress({ name: fileData.senderName, address: fileData.senderEmail }), - to: normalizeAddress(fileData.recipients || []).filter(item => item.name || item.address), - cc: [], - date: fileData.messageDeliveryTime || fileData.clientSubmitTime || fileData.creationTime, - text: fileData.body, - html: fileData.html || '', - headers: fileData.headers, - attachments, - }; -}; - const parseEmail = ( buffer: ArrayBuffer, type: EmailKind, filename: string, objectUrls: string[], cidUrls: Map, - t: ReturnType + t: ReturnType, + signal?: AbortSignal ) => { if (type === 'msg') { - return parseMsg(buffer, filename); + return import('./msg.js').then(({ parseMsg }) => + parseMsg(buffer, filename, objectUrls, cidUrls, signal)); } if (type === 'mbox') { return parseMbox(buffer, filename, objectUrls, cidUrls, t); @@ -313,25 +296,14 @@ const createElement = ( const getAttachmentExtension = (name: string) => { const index = name.lastIndexOf('.'); - return index >= 0 ? name.slice(index + 1).toLowerCase() : 'txt'; -}; - -const createHtmlSrcdoc = (html: string, cidUrls: Map, darkMode = false) => { - let next = html; - cidUrls.forEach((url, cid) => { - const escaped = cid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - next = next.replace(new RegExp(`cid:${escaped}`, 'gi'), url); - }); - const pageStyle = darkMode - ? ':root{color-scheme:dark}body{background:#111827;color:#e5e7eb}' - : ':root{color-scheme:light}body{background:#fff;color:#172033}'; - return `${next}`; + return index >= 0 ? name.slice(index + 1).toLowerCase() : 'bin'; }; const appendMeta = (meta: HTMLElement, label: string, value: string) => { const row = document.createElement('p'); const strong = document.createElement('strong'); strong.textContent = label; + row.title = value || '-'; row.append(strong, document.createTextNode(value || '-')); meta.append(row); }; @@ -342,15 +314,25 @@ export default async function renderEmail( type = 'eml', context?: FileRenderContext ): Promise { + type = type.toLowerCase().replace(/^\./, ''); const normalizedType: EmailKind = type === 'msg' ? 'msg' : type === 'mbox' ? 'mbox' : 'eml'; const filename = context?.filename || `message.${normalizedType}`; const objectUrls: string[] = []; const cidUrls = new Map(); const t = createFileViewerTranslator(context?.options); + const msgLabels = getMsgLabels(context?.options); + const controller = new AbortController(); + const abortFromParent = () => controller.abort(context?.signal?.reason); + if (context?.signal?.aborted) abortFromParent(); + else context?.signal?.addEventListener('abort', abortFromParent, { once: true }); + const signal = controller.signal; const systemDark = target.ownerDocument.defaultView?.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; const darkMode = resolveFileViewerColorScheme(context?.options?.theme, systemDark) === 'dark'; const cleanups: Array<() => void> = []; let nestedRendered: FileViewerRenderedInstance | undefined; + let bodyRendered: FileViewerRenderedInstance | undefined; + let bodyVersion = 0; + let bodyController: AbortController | undefined; let attachmentPreviewVersion = 0; let attachmentAbortController: AbortController | undefined; let disposed = false; @@ -397,12 +379,39 @@ export default async function renderEmail( }; const clearAttachmentPreview = async () => { - await disposeFileViewerRendered(nestedRendered); + const previous = nestedRendered; nestedRendered = undefined; + await disposeFileViewerRendered(previous); + }; + const clearBodyPreview = async () => { + const previous = bodyRendered; + bodyRendered = undefined; + await disposeFileViewerRendered(previous); + }; + const cleanup = async () => { + if (disposed) return; + disposed = true; + controller.abort(); + bodyVersion += 1; + attachmentPreviewVersion += 1; + bodyController?.abort(); + attachmentAbortController?.abort(); + context?.signal?.removeEventListener('abort', abortFromParent); + cleanups.splice(0).forEach(cleanup => cleanup()); + try { + await Promise.allSettled([clearAttachmentPreview(), clearBodyPreview()]); + } finally { + objectUrls.splice(0).forEach(url => URL.revokeObjectURL(url)); + cidUrls.clear(); + if (root.parentNode === target) target.replaceChildren(); + } }; const downloadAttachment = async (attachment: EmailAttachmentView) => { + signal.throwIfAborted(); const attachmentBuffer = await attachment.load(); + if (disposed) return; + signal.throwIfAborted(); const url = URL.createObjectURL(new Blob([attachmentBuffer], { type: attachment.mimeType || 'application/octet-stream' })); objectUrls.push(url); const link = document.createElement('a'); @@ -413,8 +422,8 @@ export default async function renderEmail( link.remove(); }; - const renderParsedEmail = (parsed: ParsedEmailView) => { - let activeBody: EmailBodyMode = parsed.html ? 'html' : parsed.text ? 'text' : 'headers'; + const renderParsedEmail = async (parsed: ParsedEmailView) => { + let activeBody: EmailBodyMode = parsed.html ? 'html' : parsed.rtf && context?.renderNestedBuffer ? 'rtf' : parsed.text ? 'text' : 'headers'; let activeAttachment: EmailAttachmentView | null = null; const tabButtons: Array<{ mode: EmailBodyMode; button: HTMLButtonElement }> = []; const attachmentButtons: Array<{ id: string; button: HTMLButtonElement }> = []; @@ -426,10 +435,12 @@ export default async function renderEmail( header.append(createElement('h2', undefined, parsed.subject || filename)); const meta = createElement('div', 'email-meta'); appendMeta(meta, t('email.meta.from'), addressText(parsed.from)); + if (parsed.sender?.length) appendMeta(meta, msgLabels.sender, addressText(parsed.sender)); appendMeta(meta, t('email.meta.to'), addressText(parsed.to)); if (parsed.cc.length) { appendMeta(meta, t('email.meta.cc'), addressText(parsed.cc)); } + if (parsed.bcc?.length) appendMeta(meta, msgLabels.bcc, addressText(parsed.bcc)); appendMeta(meta, t('email.meta.date'), parsed.date || '-'); header.append(meta); @@ -445,31 +456,95 @@ export default async function renderEmail( const attachmentDownload = createElement('button', undefined, t('email.attachments.download')); attachmentDownload.type = 'button'; const attachmentTarget = createElement('div', 'attachment-target') as HTMLDivElement; - attachmentPreviewHead.append(attachmentPreviewTitle, attachmentDownload); + const attachmentClose = createElement('button', undefined, '×'); + attachmentClose.type = 'button'; + attachmentClose.setAttribute('aria-label', msgLabels.close); + attachmentClose.title = msgLabels.close; + const attachmentActions = createElement('div', 'attachment-preview-actions'); + attachmentActions.append(attachmentDownload, attachmentClose); + attachmentPreviewHead.append(attachmentPreviewTitle, attachmentActions); attachmentPreview.append(attachmentPreviewHead, attachmentTarget); - const renderMessageContent = () => { - messageContent.replaceChildren(); - if (activeBody === 'html' && parsed.html) { - const iframe = createElement('iframe', 'email-html') as HTMLIFrameElement; - iframe.setAttribute('sandbox', ''); - iframe.srcdoc = createHtmlSrcdoc(parsed.html, cidUrls, darkMode); - messageContent.append(iframe); - return; - } + const notices = createElement('div', 'email-notices'); + notices.setAttribute('role', 'status'); + const noticeKeys = new Set(parsed.warnings || []); + const renderNotices = () => { + notices.replaceChildren(); + noticeKeys.forEach(key => notices.append(createElement('p', undefined, msgLabels[key]))); + notices.hidden = !noticeKeys.size; + }; + if (parsed.rtf && !parsed.html && !context?.renderNestedBuffer) noticeKeys.add('email.msg.rtfUnavailable'); + renderNotices(); + + const renderPlainBody = (mode: 'text' | 'headers') => { const pre = createElement('pre', 'email-text'); - pre.textContent = activeBody === 'text' ? parsed.text || '' : parsed.headers || ''; - messageContent.append(pre); + pre.textContent = mode === 'text' ? parsed.text || '' : parsed.headers || ''; + messageContent.replaceChildren(pre); + }; + const renderMessageContent = async () => { + const version = ++bodyVersion; + bodyController?.abort(); + const current = new AbortController(); + bodyController = current; + const abort = () => current.abort(signal.reason); + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + const stale = () => disposed || version !== bodyVersion || current.signal.aborted; + try { + await clearBodyPreview(); + if (stale()) return; + messageContent.replaceChildren(); + if (activeBody === 'html' && parsed.html) { + const iframe = createElement('iframe', 'email-html'); + iframe.title = parsed.subject || filename; + iframe.setAttribute('sandbox', ''); + iframe.referrerPolicy = 'no-referrer'; + iframe.srcdoc = createEmailHtmlDocument(parsed.html, cidUrls, darkMode, target.ownerDocument); + messageContent.append(iframe); + } else if (activeBody === 'rtf' && parsed.rtf && context?.renderNestedBuffer) { + const child = createElement('div', 'email-rtf'); + messageContent.append(child); + // Reuse the host's opt-in RTF capability (RTF.js), like attachments. + // A mail body must not inherit the host's external-resource opt-in. + const rendered = await context.renderNestedBuffer(parsed.rtf, 'rtf', child, { + ...context, + filename: `${filename}.rtf`, + sourceUrl: undefined, + signal: current.signal, + options: { + ...context.options, + docx: { ...context.options?.docx, externalResourcePolicy: 'block', externalLinkPolicy: 'block' }, + }, + }); + if (stale()) { await disposeFileViewerRendered(rendered); return; } + bodyRendered = rendered; + } else { + renderPlainBody(activeBody === 'headers' ? 'headers' : 'text'); + } + } catch (error) { + if (stale()) return; + if (activeBody === 'rtf') { + noticeKeys.add('email.msg.rtfUnavailable'); + renderNotices(); + renderPlainBody(parsed.text ? 'text' : 'headers'); + } else { + throw error; + } + } finally { + signal.removeEventListener('abort', abort); + } }; const syncTabState = () => { tabButtons.forEach(({ mode, button }) => { button.classList.toggle('active', mode === activeBody); + button.setAttribute('aria-pressed', String(mode === activeBody)); }); }; const bodyModes: Array<{ key: EmailBodyMode; label: string; disabled: boolean }> = [ { key: 'html', label: 'HTML', disabled: !parsed.html }, + ...(parsed.rtf ? [{ key: 'rtf' as const, label: 'RTF', disabled: !context?.renderNestedBuffer }] : []), { key: 'text', label: t('email.tabs.text'), disabled: !parsed.text }, { key: 'headers', label: t('email.tabs.headers'), disabled: !parsed.headers }, ]; @@ -483,7 +558,9 @@ export default async function renderEmail( } activeBody = mode.key; syncTabState(); - renderMessageContent(); + void renderMessageContent().catch(error => { + if (!disposed && !signal.aborted) showError(error instanceof Error ? error.message : String(error)); + }); }); tabButtons.push({ mode: mode.key, button }); tabs.append(button); @@ -509,21 +586,26 @@ export default async function renderEmail( attachmentAbortController?.abort(); const previewAbortController = new AbortController(); attachmentAbortController = previewAbortController; - const abortFromParent = () => previewAbortController.abort(context?.signal?.reason); - if (context?.signal?.aborted) { - abortFromParent(); - } else { - context?.signal?.addEventListener('abort', abortFromParent, { once: true }); - } + const abort = () => previewAbortController.abort(signal.reason); + if (signal.aborted) abort(); + else signal.addEventListener('abort', abort, { once: true }); + const stale = () => disposed || previewVersion !== attachmentPreviewVersion || previewAbortController.signal.aborted; activeAttachment = attachment; syncAttachmentState(); attachmentPreview.hidden = false; + messagePanel.classList.add('has-attachment-preview'); + errorElement?.remove(); + errorElement = null; attachmentPreviewTitle.textContent = attachment.name; showLoading(t('email.attachments.opening', { name: attachment.name })); try { await clearAttachmentPreview(); + if (stale()) return; attachmentTarget.replaceChildren(); const attachmentBuffer = await attachment.load(); + if (stale()) return; + const sizeLabel = attachmentButtons.find(item => item.id === attachment.id)?.button.querySelector('em'); + if (sizeLabel) sizeLabel.textContent = formatBytes(attachmentBuffer.byteLength); const child = createElement('div', 'email-attachment-render') as HTMLDivElement; attachmentTarget.append(child); const extension = getAttachmentExtension(attachment.name); @@ -531,10 +613,11 @@ export default async function renderEmail( const nextRendered = await context.renderNestedBuffer(attachmentBuffer, extension, child, { ...context, filename: attachment.name, + sourceUrl: undefined, options: context.options, signal: previewAbortController.signal, }); - if (disposed || previewVersion !== attachmentPreviewVersion || previewAbortController.signal.aborted) { + if (stale()) { await disposeFileViewerRendered(nextRendered); return; } @@ -543,13 +626,13 @@ export default async function renderEmail( child.append(createElement('div', undefined, t('email.attachments.nestedUnavailable', { name: attachment.name }))); } } catch (nextError) { - if (disposed || previewVersion !== attachmentPreviewVersion || previewAbortController.signal.aborted) { + if (stale()) { return; } console.error(nextError); showError(nextError instanceof Error ? nextError.message : String(nextError)); } finally { - context?.signal?.removeEventListener('abort', abortFromParent); + signal.removeEventListener('abort', abort); if (attachmentAbortController === previewAbortController) { attachmentAbortController = undefined; } @@ -573,42 +656,52 @@ export default async function renderEmail( attachmentPanel.append(button); }); + listen(attachmentClose, 'click', () => { + attachmentPreviewVersion += 1; + attachmentAbortController?.abort(); + attachmentAbortController = undefined; + const previous = activeAttachment; + activeAttachment = null; + syncAttachmentState(); + attachmentPreview.hidden = true; + messagePanel.classList.remove('has-attachment-preview'); + hideLoading(); + void clearAttachmentPreview().catch(() => {}); + attachmentTarget.replaceChildren(); + attachmentButtons.find(item => item.id === previous?.id)?.button.focus(); + }); + listen(attachmentDownload, 'click', () => { if (activeAttachment) { - void downloadAttachment(activeAttachment); + void downloadAttachment(activeAttachment).catch(error => { + if (!disposed && !signal.aborted) showError(error instanceof Error ? error.message : String(error)); + }); } }); - renderMessageContent(); sidebar.append(tabs, attachmentPanel); messagePanel.append(messageContent, attachmentPreview); body.append(sidebar, messagePanel); - root.append(header, body); + root.append(header, notices, body); + await renderMessageContent(); }; showLoading(t('email.loading.parsing')); try { - const parsed = await parseEmail(buffer, normalizedType, filename, objectUrls, cidUrls, t); - renderParsedEmail(parsed); + signal.throwIfAborted(); + const parsed = await parseEmail(buffer, normalizedType, filename, objectUrls, cidUrls, t, signal); + signal.throwIfAborted(); + await renderParsedEmail(parsed); + signal.throwIfAborted(); } catch (nextError) { - console.error(nextError); - root.replaceChildren(); - showError(nextError instanceof Error ? nextError.message : String(nextError)); + await cleanup(); + throw nextError; } finally { hideLoading(); } return { $el: root, - async unmount() { - disposed = true; - attachmentPreviewVersion += 1; - attachmentAbortController?.abort(); - attachmentAbortController = undefined; - await clearAttachmentPreview(); - cleanups.splice(0).forEach(cleanup => cleanup()); - objectUrls.forEach(url => URL.revokeObjectURL(url)); - target.replaceChildren(); - }, + unmount: cleanup, }; } diff --git a/packages/renderers/email/src/emailHtml.ts b/packages/renderers/email/src/emailHtml.ts new file mode 100644 index 000000000..ae6ed714b --- /dev/null +++ b/packages/renderers/email/src/emailHtml.ts @@ -0,0 +1,164 @@ +export const normalizeEmailResourceId = (value: string): string => { + const isUri = /^cid:/i.test(value.trim()); + let result = value.trim().replace(/^cid:/i, '').replace(/^<|>$/g, ''); + if (isUri) { + try { result = decodeURIComponent(result); } catch { /* Keep malformed escapes literal. */ } + } + return result; +}; + +/** Sandboxed srcdoc cannot read blob:null URLs from a different opaque + * origin (file:// WebViews, data: hosts). Keep ordinary hosts zero-copy and use + * a bounded data URL only for those hosts or browsers without object URLs. + */ +export function createEmailImageResource(buffer: ArrayBuffer, mimeType: string, objectUrls: string[]): string { + if (typeof URL.createObjectURL === 'function') { + const url = URL.createObjectURL(new Blob([buffer], { type: mimeType })); + if (!url.startsWith('blob:null/')) { objectUrls.push(url); return url; } + URL.revokeObjectURL(url); + } + const bytes = new Uint8Array(buffer); + const parts: string[] = []; + // Divisible by three: only the final base64 chunk can contain padding. + for (let offset = 0; offset < bytes.length; offset += 24576) { + parts.push(btoa(String.fromCharCode(...bytes.subarray(offset, offset + 24576)))); + } + return `data:${mimeType};base64,${parts.join('')}`; +} + + +const ALLOWED_TAGS = new Set('a abbr address article aside b bdi bdo big blockquote br caption center cite code col colgroup dd del details div dl dt em figcaption figure font footer h1 h2 h3 h4 h5 h6 header hr i img ins kbd label li main mark ol p pre q rp rt ruby s samp section small span strike strong style sub summary sup table tbody td tfoot th thead time title tr tt u ul var wbr'.split(' ')); +const DROP_CONTENT = new Set('script iframe frame frameset object embed applet svg math template noscript audio video source track input link meta base'.split(' ')); +const ALLOWED_ATTRS = new Set('alt title class id style width height align valign bgcolor color face size border cellpadding cellspacing colspan rowspan dir lang role aria-label scope start type value open name text'.split(' ')); +const SAFE_IMAGE = /^data:image\/(?:png|jpeg|gif|webp|avif|bmp);base64,[a-z0-9+/=\r\n]+$/i; + +export function createEmailHtmlDocument(html: string, resources: Map, darkMode = false, documentRef: Document = document): string { + if (html.length > 32 * 1024 * 1024) throw new Error('Email HTML exceeds the safety limit.'); + // Template contents are inert, unlike parsing a live document with loadable images. + const template = documentRef.createElement('template'); + const inertDocument = template.content.ownerDocument; + if (inertDocument.defaultView) throw new Error('Email parsing requires an inert template document.'); + const parsed = inertDocument.createElement('html'); + parsed.innerHTML = html; + // Fragment parsing drops the outer html token. Recover only its language and + // direction through a second inert element; never copy executable attributes. + const rootAttributes = /^\s*(?:]*>\s*)?"']|"[^"]*"|'[^']*')*)>/i.exec(html.slice(0, 16384))?.[1]; + if (rootAttributes) { + const probe = inertDocument.createElement('div'); + probe.innerHTML = ``; + for (const name of ['lang', 'dir']) { + const value = probe.firstElementChild?.getAttribute(name); + if (value) parsed.setAttribute(name, value); + } + } + const head = parsed.querySelector('head') || inertDocument.createElement('head'); + const body = parsed.querySelector('body') || inertDocument.createElement('body'); + const ownedUrls = new Set(resources.values()); + const lookup = (value: string): string | undefined => { + const trimmed = value.trim(); + if (ownedUrls.has(trimmed) && /^blob:/.test(trimmed)) return trimmed; + if (SAFE_IMAGE.test(trimmed)) return trimmed; + const key = normalizeEmailResourceId(trimmed); + let exact = resources.get(key); + if (!exact && !/^cid:/i.test(trimmed)) { + try { exact = resources.get(decodeURIComponent(key)); } catch { /* Literal Content-Location. */ } + } + if (exact && (/^blob:/.test(exact) || SAFE_IMAGE.test(exact))) return exact; + if (/^cid:/i.test(trimmed)) { + // Legacy writers vary CID casing. Only use a case-insensitive fallback + // when it identifies exactly one attachment; never replace a prefix. + const matches = [...resources].filter(([id]) => id.toLowerCase() === key.toLowerCase()); + if (matches.length === 1 && (/^blob:/.test(matches[0][1]) || SAFE_IMAGE.test(matches[0][1]))) return matches[0][1]; + } + return undefined; + }; + let expandedResourceCharacters = 0; + const resource = (value: string) => { + const resolved = lookup(value); + if (resolved) { + expandedResourceCharacters += resolved.length; + if (expandedResourceCharacters > 48 * 1024 * 1024) throw new Error('Email inline resource expansion limit exceeded.'); + } + return resolved; + }; + const css = (value: string) => value + .replace(/@import\s+(?:url\([^)]*\)|[^;]*);?/gi, '') + .replace(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*))\s*\)/gi, (_match, double, single, bare) => { + const resolved = resource(String(double ?? single ?? bare ?? '')); + return resolved ? `url("${resolved.replace(/["\\\r\n]/g, '')}")` : 'none'; + }); + const attributes = (element: Element) => { + const tag = element.localName.toLowerCase(); + for (const attribute of Array.from(element.attributes)) { + const name = attribute.name.toLowerCase(); + if (tag === 'img' && name === 'src') { + const resolved = resource(attribute.value); + if (resolved) element.setAttribute('src', resolved); else element.removeAttribute(attribute.name); + } else if (name === 'background') { + const resolved = resource(attribute.value); + if (resolved) element.setAttribute('background', resolved); else element.removeAttribute(attribute.name); + } else if (tag === 'a' && name === 'href') { + if (!/^#[a-z0-9_:.\-]+$/i.test(attribute.value)) element.removeAttribute(attribute.name); + } else if (name === 'style') element.setAttribute('style', css(attribute.value)); + else if (!ALLOWED_ATTRS.has(name)) element.removeAttribute(attribute.name); + } + }; + let count = 0; + const clean = (parent: ParentNode) => { + const pending = [...parent.children]; + while (pending.length) { + const element = pending.pop()!; + if (++count > 100000) throw new Error('Email HTML node limit exceeded.'); + const tag = element.localName.toLowerCase(); + if (DROP_CONTENT.has(tag)) { element.remove(); continue; } + if (!ALLOWED_TAGS.has(tag)) { + const children = [...element.children]; + element.replaceWith(...Array.from(element.childNodes)); + pending.push(...children); + continue; + } + attributes(element); + if (tag === 'style') element.textContent = css(element.textContent || ''); + pending.push(...element.children); + } + }; + clean(head); + clean(body); + attributes(parsed); + attributes(body); + // Preserve legacy Outlook body colors as well as modern inline styles. + if (body.getAttribute('bgcolor') && !body.style.backgroundColor) body.style.backgroundColor = body.getAttribute('bgcolor')!; + if (body.getAttribute('text') && !body.style.color) body.style.color = body.getAttribute('text')!; + if (body.getAttribute('background') && !body.style.backgroundImage) body.style.backgroundImage = `url("${body.getAttribute('background')!.replace(/["\\\r\n]/g, '')}")`; + // Check escaped serialization growth before building a potentially huge srcdoc. + const escapedLength = (value: string, attribute = false) => { + let size = value.length; + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code === 38) size += 4; + else if (code === 60 || code === 62) size += 3; + else if (attribute && code === 34) size += 5; + } + return size; + }; + const nodes: Node[] = [parsed]; + let estimated = 0; + while (nodes.length) { + const node = nodes.pop()!; + if (node.nodeType === 3) estimated += escapedLength(node.nodeValue || ''); + else if (node.nodeType === 1) { + estimated += 32; + for (const attr of Array.from((node as Element).attributes)) estimated += attr.name.length + escapedLength(attr.value, true) + 8; + } + if (estimated > 64 * 1024 * 1024) throw new Error('Email HTML serialization limit exceeded.'); + nodes.push(...Array.from(node.childNodes)); + } + const escape = (value: string) => value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + const serializeAttributes = (element: Element) => Array.from(element.attributes).map(attr => ` ${attr.name}="${escape(attr.value)}"`).join(''); + const background = darkMode ? '#111827' : '#fff'; + const foreground = darkMode ? '#e5e7eb' : '#172033'; + // CSP is required in addition to sanitization: CSS escapes and future HTML + // features must not introduce external requests or executable content. + const csp = "default-src 'none'; script-src 'none'; connect-src 'none'; img-src blob: data:; style-src 'unsafe-inline'; font-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'"; + return `${head.innerHTML}${body.innerHTML}`; +} diff --git a/packages/renderers/email/src/msg.ts b/packages/renderers/email/src/msg.ts new file mode 100644 index 000000000..8ae2b8810 --- /dev/null +++ b/packages/renderers/email/src/msg.ts @@ -0,0 +1,277 @@ +import type { ParsedEmailView } from './email.js'; +import type { MsgNotice } from './msgMessages.js'; +import { inspectMsgContainer, MAX_MSG_BYTES } from './msgCfb.js'; +import { createEmailImageResource, normalizeEmailResourceId } from './emailHtml.js'; +import { decompressMsgRtf, extractMsgRtf } from './msgRtf.js'; +import { decodeEmailHtmlBytes, msgCodepageLabel } from './msgEncoding.js'; + +/** Structural view of @kenjiuno/msgreader 1.28's documented API. Keep it local: + * consumers continue to use the shared File Viewer contract, not MAPI types. + */ +export interface MsgFields { + dataType?: string; + error?: unknown; + subject?: string; + senderName?: string; + senderEmail?: string; + senderSmtpAddress?: string; + sentRepresentingSmtpAddress?: string; + sentRepresentingName?: string; + sentRepresentingEmail?: string; + displayTo?: string; + displayCc?: string; + displayBcc?: string; + body?: string; + bodyHtml?: string; + html?: Uint8Array | ArrayBuffer | string; + headers?: string; + compressedRtf?: Uint8Array; + internetCodepage?: number; + messageCodepage?: number; + clientSubmitTime?: string; + messageDeliveryTime?: string; + creationTime?: string; + recipients?: MsgFields[]; + attachments?: MsgFields[]; + name?: string; + email?: string; + smtpAddress?: string; + recipType?: string | number; + fileName?: string; + fileNameShort?: string; + extension?: string; + contentLength?: number; + pidContentId?: string; + contentLocation?: string; + attachMimeTag?: string; + attachmentHidden?: boolean; + innerMsgContent?: boolean; + innerMsgContentFields?: MsgFields; + messageClass?: string; + messageId?: string; + [key: string]: unknown; +} +export interface MsgReaderLike { + parserConfig?: { ansiEncoding?: string; propertyObserver?: (fields: MsgFields, tag: number, raw: Uint8Array | null) => void }; + getFileData(): MsgFields; + getAttachment(attachment: MsgFields): { fileName: string; content: Uint8Array }; +} +export type MsgReaderConstructor = new (buffer: ArrayBuffer) => MsgReaderLike; +const MAX_ATTACHMENTS = 1024; +const MAX_RECIPIENTS = 4096; +const MAX_BODY_CHARS = 32 * 1024 * 1024; +const MAX_INLINE_BYTES = 32 * 1024 * 1024; +const MAX_NESTED_MESSAGES = 24; +const MIME: Readonly> = { + png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', + webp: 'image/webp', avif: 'image/avif', bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff', + svg: 'image/svg+xml', pdf: 'application/pdf', txt: 'text/plain', csv: 'text/csv', + html: 'text/html', htm: 'text/html', rtf: 'application/rtf', msg: 'application/vnd.ms-outlook', + eml: 'message/rfc822', zip: 'application/zip', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', +}; +const RASTER_MIME = /^image\/(?:png|jpeg|gif|webp|avif|bmp|x-ms-bmp)$/i; + +const string = (value: unknown, max = MAX_BODY_CHARS): string => { + if (typeof value !== 'string') return ''; + if (value.length > max) throw new Error('Outlook MSG text exceeds the safety limit.'); + return value.replace(/\0+$/, ''); +}; +export function safeMsgAttachmentName(value: unknown, fallback: string): string { + const name = string(value, 32768).split(/[\\/]/).pop()!.replace(/[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]/g, '').replace(/[<>:"|?*]/g, '_').replace(/[. ]+$/, '').trim(); + return !name || name === '.' || name === '..' ? fallback : name; +} +const recipient = (item: MsgFields) => ({ name: string(item.name, 32768), address: string(item.smtpAddress || item.email, 32768) }); +const addressText = (items: ParsedEmailView['to']) => items.map(item => item.name && item.address ? `${item.name} <${item.address}>` : item.address || item.name || '').join(', '); +const copy = (value: Uint8Array | ArrayBuffer): ArrayBuffer => { + const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : value; + if (!(bytes instanceof Uint8Array) || bytes.length > MAX_MSG_BYTES) throw new Error('Invalid or oversized MSG attachment.'); + return new Uint8Array(bytes).buffer; +}; + +function assertFieldTree(root: MsgFields) { + const seen = new Set(); + const pending = [{ item: root, depth: 0 }]; + let count = 0; + while (pending.length) { + const { item, depth } = pending.pop()!; + if (depth > MAX_NESTED_MESSAGES || seen.has(item) || ++count > 32768) throw new Error('Outlook MSG nesting limit exceeded.'); + seen.add(item); + if ((item.attachments?.length || 0) > MAX_ATTACHMENTS || (item.recipients?.length || 0) > MAX_RECIPIENTS) throw new Error('Outlook MSG recipient/attachment limit exceeded.'); + for (const attachment of item.attachments || []) { + if (attachment.innerMsgContentFields) pending.push({ item: attachment.innerMsgContentFields, depth: depth + 1 }); + } + } +} + +/** Exported only from the internal module for deterministic normalization tests. */ +export async function normalizeMsg( + reader: MsgReaderLike, + fields: MsgFields, + filename: string, + objectUrls: string[], + cidUrls: Map, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted(); + if (fields.error || fields.dataType !== 'msg') throw new Error(string(fields.error) || 'The file does not contain a readable Outlook message.'); + assertFieldTree(fields); + const warnings: MsgNotice[] = []; + const actualSender = { name: string(fields.senderName, 32768), address: string(fields.senderSmtpAddress || fields.senderEmail, 32768) }; + const represented = { name: string(fields.sentRepresentingName, 32768), address: string(fields.sentRepresentingSmtpAddress || fields.sentRepresentingEmail, 32768) }; + const hasRepresentation = !!(represented.name || represented.address); + const from = [hasRepresentation ? represented : actualSender].filter(item => item.name || item.address); + const sender = hasRepresentation && (actualSender.address || actualSender.name) && + (actualSender.address !== represented.address || actualSender.name !== represented.name) ? [actualSender] : []; + const to: ParsedEmailView['to'] = []; + const cc: ParsedEmailView['cc'] = []; + const bcc: ParsedEmailView['to'] = []; + for (const item of fields.recipients || []) { + const address = recipient(item); + if (!address.name && !address.address) continue; + const kind = String(item.recipType ?? 'to').toLowerCase(); + (kind === 'cc' || kind === '2' ? cc : kind === 'bcc' || kind === '3' ? bcc : to).push(address); + } + // Display lists are useful in drafts that omit recipient rows. They are + // display text, not an address parser: do not invent SMTP addresses. + if (!to.length && fields.displayTo) to.push({ name: string(fields.displayTo, 32768) }); + if (!cc.length && fields.displayCc) cc.push({ name: string(fields.displayCc, 32768) }); + if (!bcc.length && fields.displayBcc) bcc.push({ name: string(fields.displayBcc, 32768) }); + let html = string(fields.bodyHtml); + if (!html && fields.html) html = decodeEmailHtmlBytes(fields.html, fields.internetCodepage, fields.messageCodepage); + let text = string(fields.body); + let rtf: ArrayBuffer | undefined; + if (fields.compressedRtf?.length) { + try { + const uncompressed = decompressMsgRtf(fields.compressedRtf); + const extracted = extractMsgRtf(uncompressed, fields.messageCodepage); + text ||= extracted.text; + html ||= extracted.html || ''; + rtf = copy(uncompressed); + } catch (error) { + if (!html && !text) throw error; + warnings.push('email.msg.rtfUnavailable'); + } + } + signal?.throwIfAborted(); + let inlineBytes = 0; + const attachments: ParsedEmailView['attachments'] = (fields.attachments || []).map((attachment, index) => { + const fallback = `attachment-${index + 1}`; + let name = safeMsgAttachmentName(attachment.fileName || attachment.fileNameShort || attachment.name, fallback); + const extension = string(attachment.extension, 128).replace(/^\./, ''); + if (attachment.innerMsgContent && !/\.msg$/i.test(name)) name += '.msg'; + else if (!name.includes('.') && /^[a-z0-9]{1,16}$/i.test(extension)) name += `.${extension}`; + const hint = string(attachment.attachMimeTag, 256).split(';')[0].trim().toLowerCase(); + const mimeType = attachment.innerMsgContent ? MIME.msg : (/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i.test(hint) ? hint : MIME[name.split('.').pop()!.toLowerCase()] || 'application/octet-stream'); + const size = Number.isSafeInteger(attachment.contentLength) && attachment.contentLength! >= 0 ? attachment.contentLength! : -1; + if (size > MAX_MSG_BYTES) throw new Error('Outlook MSG attachment exceeds the size limit.'); + const contentId = string(attachment.pidContentId, 32768); + const contentLocation = string(attachment.contentLocation, 32768); + const result: ParsedEmailView['attachments'][number] = { + id: `msg-${index}`, + name, size, mimeType, contentId, contentLocation, + inline: !!attachment.attachmentHidden || !!contentId, + async load() { + signal?.throwIfAborted(); + // Do not cache every extracted buffer: the reader already owns the CFB. + const extracted = reader.getAttachment(attachment); + if (!extracted || !(extracted.content instanceof Uint8Array)) throw new Error(`Attachment data is not embedded: ${name}`); + const buffer = copy(extracted.content); + result.size = buffer.byteLength; + signal?.throwIfAborted(); + return buffer; + }, + }; + return result; + }); + // Only raster CID images are decoded eagerly. PDF/Office/nested MSG remain lazy. + for (const attachment of attachments) { + const referenced = !!(attachment.contentId || attachment.contentLocation || attachment.inline || html.includes(attachment.name)); + if (!referenced || !RASTER_MIME.test(attachment.mimeType || '')) continue; + signal?.throwIfAborted(); + try { + if (inlineBytes + Math.max(0, attachment.size) > MAX_INLINE_BYTES) throw new Error('Inline image size limit exceeded.'); + const bytes = await attachment.load(); + inlineBytes += bytes.byteLength; + if (inlineBytes > MAX_INLINE_BYTES) throw new Error('Inline image size limit exceeded.'); + signal?.throwIfAborted(); + const cid = normalizeEmailResourceId(attachment.contentId || ''); + const keys = [cid, attachment.contentLocation].filter((key): key is string => !!key); + // A unique filename is also a valid local Content-Location fallback. + if (attachments.filter(item => item.name === attachment.name).length === 1) keys.push(attachment.name); + const available = keys.map(normalizeEmailResourceId).filter(key => !cidUrls.has(key)); + if (available.length) { + const url = createEmailImageResource(bytes, attachment.mimeType!, objectUrls); + available.forEach(key => cidUrls.set(key, url)); + } + } catch (error) { + if (signal?.aborted) throw error; + if (!warnings.includes('email.msg.inlineUnavailable')) warnings.push('email.msg.inlineUnavailable'); + } + } + const subject = string(fields.subject, 32768) || filename; + const date = string(fields.clientSubmitTime || fields.messageDeliveryTime || fields.creationTime, 4096); + const rawHeaders = string(fields.headers, 2 * 1024 * 1024); + const headerLine = (key: string, value: string) => value ? `${key}: ${value.replace(/[\r\n\0]/g, ' ')}` : ''; + // Outlook drafts may have no transport headers. Label the generated metadata + // explicitly instead of misrepresenting it as the original transport envelope. + const headers = rawHeaders || [ + 'X-File-Viewer-Source: Outlook MAPI properties (not transport headers)', + headerLine('Subject', subject), headerLine('From', addressText(from)), headerLine('Sender', addressText(sender)), + headerLine('To', addressText(to)), headerLine('Cc', addressText(cc)), + headerLine('Bcc', addressText(bcc)), headerLine('Date', date), + headerLine('Message-ID', string(fields.messageId, 32768)), + headerLine('Message-Class', string(fields.messageClass, 32768)), + ].filter(Boolean).join('\r\n'); + if (!html && !text && !rtf) warnings.push('email.msg.noBody'); + if (/\.smime(?:\.|$)|rpmsg|protected/i.test(string(fields.messageClass, 32768))) warnings.push('email.msg.protected'); + return { kind: 'msg', subject, from, sender, to, cc, bcc, date, text, html, rtf, headers, attachments, warnings }; +} + +/** Capture standard MAPI strings not named by the reader's default map. */ +export function captureMsgProperty(fields: MsgFields, tag: number, raw: Uint8Array | null, codepage?: number): void { + const name = ({ 0x0042: 'sentRepresentingName', 0x0065: 'sentRepresentingEmail', + 0x0e04: 'displayTo', 0x0e03: 'displayCc', 0x0e02: 'displayBcc', + 0x3713: 'contentLocation' } as Record)[tag >>> 16]; + const type = tag & 0xffff; + if (!name || !raw || (type !== 0x001f && type !== 0x001e)) return; + if (raw.length > 65536) throw new Error('Outlook MSG metadata exceeds the safety limit.'); + fields[name] = decodeEmailHtmlBytes(raw, type === 0x001f ? 1200 : codepage || 1252); +} + +export async function parseMsg( + buffer: ArrayBuffer, + filename: string, + objectUrls: string[], + cidUrls: Map, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted(); + const { messageCodepage } = inspectMsgContainer(buffer); + const module: unknown = await import('@kenjiuno/msgreader'); + signal?.throwIfAborted(); + // CJS interop varies between native ESM, Vite and Webpack. + const exported = (module as { default?: unknown }).default; + const constructor = typeof exported === 'function' ? exported : (exported as { default?: unknown } | undefined)?.default; + if (typeof constructor !== 'function') throw new Error('Outlook MSG parser failed to load.'); + const localBuffer = buffer instanceof ArrayBuffer ? buffer : new Uint8Array(buffer).slice().buffer; + const reader = new (constructor as MsgReaderConstructor)(localBuffer); + const label = msgCodepageLabel(messageCodepage); + reader.parserConfig = { + // iconv-lite's names differ from WHATWG for Korean/Chinese codepages. + ...(messageCodepage ? { ansiEncoding: ({ 949: 'cp949', 936: 'cp936', 932: 'cp932', 950: 'cp950' } as Record)[messageCodepage] || label || `cp${messageCodepage}` } : {}), + propertyObserver: (fields, tag, raw) => captureMsgProperty(fields, tag, raw, messageCodepage), + }; + const initialUrls = objectUrls.length; + const initialCids = new Map(cidUrls); + try { + return await normalizeMsg(reader, reader.getFileData(), filename, objectUrls, cidUrls, signal); + } catch (error) { + objectUrls.splice(initialUrls).forEach(url => URL.revokeObjectURL(url)); + cidUrls.clear(); + initialCids.forEach((url, cid) => cidUrls.set(cid, url)); + throw error; + } +} diff --git a/packages/renderers/email/src/msgCfb.ts b/packages/renderers/email/src/msgCfb.ts new file mode 100644 index 000000000..7dbc3192d --- /dev/null +++ b/packages/renderers/email/src/msgCfb.ts @@ -0,0 +1,143 @@ +/** Bounded structural validation before handing an untrusted CFB to MsgReader. + * MS-CFB: header, FAT/DIFAT, directory and MiniFAT. This is not a second MSG parser. + */ +export const MAX_MSG_BYTES = 128 * 1024 * 1024; +const END = 0xfffffffe; +const FREE = 0xffffffff; +const MAX_DIRECTORY_ENTRIES = 32768; +const MAX_STORAGE_DEPTH = 24; +const MAGIC = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]; + +export function inspectMsgContainer(buffer: ArrayBuffer): { messageCodepage?: number } { + const fail = (reason: string): never => { throw new Error(`Invalid Outlook MSG: ${reason}`); }; + if (buffer.byteLength < 512 || buffer.byteLength > MAX_MSG_BYTES) fail('source size limit'); + const bytes = new Uint8Array(buffer); + const view = new DataView(buffer); + if (!MAGIC.every((value, index) => bytes[index] === value)) fail('missing CFB signature'); + const version = view.getUint16(26, true); + const shift = view.getUint16(30, true); + if (view.getUint16(28, true) !== 0xfffe || !((version === 3 && shift === 9) || (version === 4 && shift === 12)) || view.getUint16(32, true) !== 6) fail('unsupported CFB header'); + const sectorSize = 2 ** shift; + if (bytes.length % sectorSize || bytes.length < sectorSize * 2) fail('truncated sector'); + const sectorCount = bytes.length / sectorSize - 1; + const sectorOffset = (sector: number) => { + if (!Number.isInteger(sector) || sector < 0 || sector >= sectorCount) fail('sector out of bounds'); + return (sector + 1) * sectorSize; + }; + const fatCount = view.getUint32(44, true); + const difatCount = view.getUint32(72, true); + if (!fatCount || fatCount > sectorCount || difatCount > sectorCount) fail('invalid allocation table count'); + const reserved = new Set(); + const fatSectors: number[] = []; + const addFat = (sector: number) => { + if (sector === FREE) return; + sectorOffset(sector); + if (reserved.has(sector)) fail('duplicate FAT/DIFAT sector'); + reserved.add(sector); + fatSectors.push(sector); + }; + for (let index = 0; index < 109; index++) addFat(view.getUint32(76 + index * 4, true)); + let difat = view.getUint32(68, true); + for (let index = 0; index < difatCount; index++) { + const start = sectorOffset(difat); + if (reserved.has(difat)) fail('cyclic DIFAT'); + reserved.add(difat); + for (let offset = 0; offset < sectorSize - 4; offset += 4) addFat(view.getUint32(start + offset, true)); + difat = view.getUint32(start + sectorSize - 4, true); + } + if (difatCount && difat !== END) fail('unterminated DIFAT'); + if (fatSectors.length !== fatCount || fatCount * (sectorSize / 4) < sectorCount) fail('incomplete FAT'); + const fat = new Uint32Array(fatCount * (sectorSize / 4)); + fatSectors.forEach((sector, index) => { + const start = sectorOffset(sector); + for (let offset = 0; offset < sectorSize / 4; offset++) fat[index * (sectorSize / 4) + offset] = view.getUint32(start + offset * 4, true); + }); + const claimed = new Set(reserved); + const chain = (start: number, table: Uint32Array, limit: number, owners: Set, maxLength = limit) => { + const result: number[] = []; + for (let sector = start; sector !== END;) { + if (sector >= limit || sector >= table.length || owners.has(sector) || result.length >= maxLength) fail('cyclic, shared or out-of-range allocation chain'); + owners.add(sector); + result.push(sector); + sector = table[sector]; + } + return result; + }; + const directory = chain(view.getUint32(48, true), fat, sectorCount, claimed, Math.ceil(MAX_DIRECTORY_ENTRIES * 128 / sectorSize)); + if (!directory.length) fail('missing directory'); + const dirBytes = new Uint8Array(directory.length * sectorSize); + directory.forEach((sector, index) => dirBytes.set(bytes.subarray(sectorOffset(sector), sectorOffset(sector) + sectorSize), index * sectorSize)); + const dir = new DataView(dirBytes.buffer); + const count = dirBytes.length / 128; + const decoder = new TextDecoder('utf-16le'); + const entries = Array.from({ length: count }, (_, id) => { + const offset = id * 128; + const type = dir.getUint8(offset + 66); + const length = dir.getUint16(offset + 64, true); + if (type && (!([1, 2, 5].includes(type)) || length < 2 || length > 64 || length % 2)) fail('invalid directory entry'); + const low = dir.getUint32(offset + 120, true); + const high = version === 4 ? dir.getUint32(offset + 124, true) : 0; + const size = low + high * 2 ** 32; + if ((type === 2 || type === 5) && size > MAX_MSG_BYTES) fail('stream size limit'); + return { + id, type, size, + name: type ? decoder.decode(dirBytes.subarray(offset, offset + length - 2)) : '', + left: dir.getUint32(offset + 68, true), + right: dir.getUint32(offset + 72, true), + child: dir.getUint32(offset + 76, true), + start: dir.getUint32(offset + 116, true), + }; + }); + const root = entries[0]; + if (root.type !== 5) fail('missing root storage'); + const miniCount = view.getUint32(64, true); + if (miniCount > sectorCount || view.getUint32(56, true) !== 4096) fail('invalid MiniFAT header'); + const miniFatChain = miniCount ? chain(view.getUint32(60, true), fat, sectorCount, claimed) : []; + if (miniFatChain.length !== miniCount) fail('incomplete MiniFAT'); + const miniFat = new Uint32Array(miniCount * (sectorSize / 4)); + miniFatChain.forEach((sector, index) => { + const start = sectorOffset(sector); + for (let offset = 0; offset < sectorSize / 4; offset++) miniFat[index * (sectorSize / 4) + offset] = view.getUint32(start + offset * 4, true); + }); + const miniStream = root.size ? chain(root.start, fat, sectorCount, claimed) : []; + if (miniStream.length * sectorSize < root.size) fail('truncated mini stream'); + const miniClaimed = new Set(); + const visited = new Set([0]); + const stack = [{ id: root.child, depth: 1, parent: 0 }]; + let properties: Uint8Array | undefined; + while (stack.length) { + const next = stack.pop()!; + if (next.id === FREE) continue; + if (next.id >= count || visited.has(next.id) || next.depth > MAX_STORAGE_DEPTH) fail('cyclic directory or nested storage limit'); + visited.add(next.id); + const item = entries[next.id]; + if (item.type !== 1 && item.type !== 2) fail('invalid child entry'); + stack.push({ ...next, id: item.left }, { ...next, id: item.right }); + if (item.type === 1) { + stack.push({ id: item.child, depth: next.depth + 1, parent: item.id }); + continue; + } + const small = item.size < 4096; + const sectors = item.size ? chain(item.start, small ? miniFat : fat, small ? Math.ceil(root.size / 64) : sectorCount, small ? miniClaimed : claimed) : []; + const width = small ? 64 : sectorSize; + if (sectors.length * width < item.size) fail('truncated property or attachment stream'); + if (small && sectors.some((sector, index) => sector * 64 + Math.min(64, Math.max(0, item.size - index * 64)) > root.size)) fail('property exceeds mini stream'); + if (next.parent === 0 && item.name === '__properties_version1.0') { + if (item.size < 32 || item.size > 2 * 1024 * 1024 || (item.size - 32) % 16) fail('invalid message property stream'); + properties = new Uint8Array(item.size); + sectors.forEach((sector, index) => { + const offset = small + ? sectorOffset(miniStream[Math.floor(sector * 64 / sectorSize)]) + (sector * 64 % sectorSize) + : sectorOffset(sector); + const length = Math.min(width, item.size - index * width); + if (length > 0) properties!.set(bytes.subarray(offset, offset + length), index * width); + }); + } + } + if (!properties) fail('CFB is not an Outlook message'); + const props = new DataView(properties!.buffer); + for (let offset = 32; offset + 16 <= props.byteLength; offset += 16) { + if (props.getUint32(offset, true) === 0x3ffd0003) return { messageCodepage: props.getUint32(offset + 8, true) }; + } + return {}; +} diff --git a/packages/renderers/email/src/msgEncoding.ts b/packages/renderers/email/src/msgEncoding.ts new file mode 100644 index 000000000..a3d4834f3 --- /dev/null +++ b/packages/renderers/email/src/msgEncoding.ts @@ -0,0 +1,39 @@ +export const msgCodepageLabel = (codepage?: number): string | undefined => { + if (codepage && codepage >= 28591 && codepage <= 28606) return `iso-8859-${codepage - 28590}`; + if (codepage && codepage >= 1250 && codepage <= 1258) return `windows-${codepage}`; + return ({ + 866: 'ibm866', 10000: 'macintosh', 10007: 'x-mac-cyrillic', 38598: 'iso-8859-8-i', + 50221: 'iso-2022-jp', 50222: 'iso-2022-jp', + 65001: 'utf-8', 1200: 'utf-16le', 1201: 'utf-16be', 20127: 'windows-1252', + 932: 'shift_jis', 936: 'gbk', 949: 'euc-kr', 950: 'big5', 54936: 'gb18030', + 28591: 'windows-1252', 28592: 'iso-8859-2', 28605: 'iso-8859-15', + 20866: 'koi8-r', 21866: 'koi8-u', 874: 'windows-874', 50220: 'iso-2022-jp', + 51932: 'euc-jp', 51949: 'euc-kr', + } as Record)[codepage || 0]; +}; + + +/** PR_HTML is PtypBinary, whereas 1013001F (bodyHtml) is already Unicode. */ +export function decodeEmailHtmlBytes(value: string | Uint8Array | ArrayBuffer, internetCodepage?: number, messageCodepage?: number): string { + if (typeof value === 'string') { + if (value.length > 32 * 1024 * 1024) throw new Error('MSG HTML size limit exceeded.'); + return value.replace(/\0+$/, ''); + } + const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : value; + if (!(bytes instanceof Uint8Array) || bytes.length > 32 * 1024 * 1024) throw new Error('Invalid or oversized MSG HTML body.'); + let label: string | undefined; + if (bytes[0] === 0xff && bytes[1] === 0xfe) label = 'utf-16le'; + else if (bytes[0] === 0xfe && bytes[1] === 0xff) label = 'utf-16be'; + else if (bytes[0] === 0x3c && bytes[1] === 0 && bytes[3] === 0) label = 'utf-16le'; + else if (bytes[0] === 0 && bytes[1] === 0x3c && bytes[2] === 0) label = 'utf-16be'; + else if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) label = 'utf-8'; + const prefix = new TextDecoder('windows-1252').decode(bytes.subarray(0, 4096)); + const declared = /]*\bcharset\s*=\s*["']?\s*([a-z0-9._-]+)/i.exec(prefix)?.[1]; + const candidates = [label, msgCodepageLabel(internetCodepage), declared, msgCodepageLabel(messageCodepage)].filter((item): item is string => !!item); + for (const candidate of candidates) { + try { return new TextDecoder(candidate).decode(bytes).replace(/\0+$/, ''); } catch { /* Unsupported charset label: try the next source. */ } + } + try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes).replace(/\0+$/, ''); } + catch { return new TextDecoder('windows-1252').decode(bytes).replace(/\0+$/, ''); } +} + diff --git a/packages/renderers/email/src/msgMessages.ts b/packages/renderers/email/src/msgMessages.ts new file mode 100644 index 000000000..043188332 --- /dev/null +++ b/packages/renderers/email/src/msgMessages.ts @@ -0,0 +1,37 @@ +import { resolveFileViewerLocale, type FileViewerI18nInput } from '@file-viewer/core'; + +// Renderer-owned MAPI notices do not add format-specific options to core. +export type MsgNotice = 'email.msg.rtfUnavailable' | 'email.msg.inlineUnavailable' + | 'email.msg.noBody' | 'email.msg.protected'; +type Labels = Record; +const messages: Record<'en-US' | 'zh-CN' | 'ja-JP' | 'de-DE', Labels> = { + 'en-US': { + bcc: 'Bcc', sender: 'Sender', close: 'Close attachment preview', + 'email.msg.rtfUnavailable': 'Rich-text rendering is unavailable. The readable plain-text body is shown when available. Install the RTF capability to render RTF-only messages.', + 'email.msg.inlineUnavailable': 'An inline image could not be displayed. The original attachment remains available.', + 'email.msg.noBody': 'This Outlook item has no readable email body. Its metadata and embedded attachments are shown.', + 'email.msg.protected': 'This message is protected or signed. Decryption and signature verification are not performed by this viewer.', + }, + 'zh-CN': { + bcc: '密送', sender: '代发人', close: '关闭附件预览', + 'email.msg.rtfUnavailable': '富文本正文暂不可渲染,已尽可能显示纯文本正文。仅含 RTF 的邮件需要安装 RTF 预览能力。', + 'email.msg.inlineUnavailable': '部分内嵌图片无法显示,原始附件仍保留。', + 'email.msg.noBody': '此 Outlook 项目不含可读邮件正文,已展示元数据及内嵌附件。', + 'email.msg.protected': '此邮件包含加密或签名内容,本预览器不进行解密或签名验证。', + }, + 'ja-JP': { + bcc: 'Bcc', sender: '送信者', close: '添付ファイルのプレビューを閉じる', + 'email.msg.rtfUnavailable': 'リッチテキストを表示できません。可能な場合はプレーンテキストを表示します。RTF 形式の本文には RTF プレビュー機能が必要です。', + 'email.msg.inlineUnavailable': '一部の埋め込み画像を表示できません。元の添付ファイルは保持されています。', + 'email.msg.noBody': 'この Outlook アイテムには表示可能なメール本文がありません。メタデータと添付ファイルを表示します。', + 'email.msg.protected': 'このメールには保護または署名が含まれます。復号と署名検証は行いません。', + }, + 'de-DE': { + bcc: 'Bcc', sender: 'Absender', close: 'Anhangvorschau schließen', + 'email.msg.rtfUnavailable': 'Die Rich-Text-Darstellung ist nicht verfügbar. Wenn möglich, wird Klartext angezeigt. Für RTF-Nachrichten ist die RTF-Vorschaufunktion erforderlich.', + 'email.msg.inlineUnavailable': 'Ein eingebettetes Bild konnte nicht angezeigt werden. Der Originalanhang bleibt verfügbar.', + 'email.msg.noBody': 'Dieses Outlook-Element enthält keinen lesbaren Nachrichtentext. Metadaten und eingebettete Anhänge werden angezeigt.', + 'email.msg.protected': 'Diese Nachricht ist geschützt oder signiert. Entschlüsselung und Signaturprüfung werden nicht durchgeführt.', + }, +}; +export const getMsgLabels = (options?: FileViewerI18nInput): Labels => messages[resolveFileViewerLocale(options)]; diff --git a/packages/renderers/email/src/msgRtf.ts b/packages/renderers/email/src/msgRtf.ts new file mode 100644 index 000000000..2ec4a65c7 --- /dev/null +++ b/packages/renderers/email/src/msgRtf.ts @@ -0,0 +1,194 @@ +import { msgCodepageLabel } from './msgEncoding.js'; + +/** MS-OXRTFCP decompression and MS-OXRTFEX body extraction. + * Rich RTF layout is delegated to the installed RTF renderer; this tokenizer + * provides readable text and extracts HTML encapsulated by Outlook. + */ +export const MAX_MSG_RTF_BYTES = 32 * 1024 * 1024; +const SEED = '{\\rtf1\\ansi\\mac\\deff0\\deftab720{\\fonttbl;}{\\f0\\fnil \\froman \\fswiss \\fmodern \\fscript \\fdecor MS Sans SerifSymbolArialTimes New RomanCourier{\\colortbl\\red0\\green0\\blue0\r\n\\par \\pard\\plain\\f0\\fs20\\b\\i\\u\\tab\\tx'; + +export function rtfCrc32(bytes: Uint8Array): number { + let crc = 0; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + return crc >>> 0; +} + +export function decompressMsgRtf(input: Uint8Array): Uint8Array { + const fail = (reason: string): never => { throw new Error(`Invalid Outlook RTF: ${reason}`); }; + if (input.byteLength < 16 || input.byteLength > MAX_MSG_RTF_BYTES + 16) fail('compressed size limit'); + const view = new DataView(input.buffer, input.byteOffset, input.byteLength); + const compressedSize = view.getUint32(0, true); + const rawSize = view.getUint32(4, true); + const magic = view.getUint32(8, true); + if (compressedSize + 4 !== input.length || rawSize > MAX_MSG_RTF_BYTES) fail('size mismatch or expansion limit'); + const payload = input.subarray(16); + if (magic === 0x414c454d) { + if (payload.length !== rawSize) fail('truncated uncompressed body'); + return payload.slice(); + } + if (magic !== 0x75465a4c) fail('unknown compression type'); + if (rtfCrc32(payload) !== view.getUint32(12, true)) fail('CRC mismatch'); + const dictionary = new Uint8Array(4096); + const initialized = new Uint8Array(4096); + dictionary.set(new TextEncoder().encode(SEED)); + initialized.fill(1, 0, SEED.length); + const output = new Uint8Array(rawSize); + let write = SEED.length; + let position = 0; + let cursor = 0; + let terminated = false; + const put = (value: number) => { + if (position >= rawSize) fail('decompressed size mismatch'); + output[position++] = value; + dictionary[write] = value; + initialized[write] = 1; + write = (write + 1) & 4095; + }; + while (cursor < payload.length && !terminated) { + const flags = payload[cursor++]; + for (let bit = 0; bit < 8 && !terminated; bit++) { + if (flags & (1 << bit)) { + if (cursor + 2 > payload.length) fail('truncated reference'); + const first = payload[cursor++]; + const second = payload[cursor++]; + let reference = (first << 4) | (second >>> 4); + if (reference === write) { terminated = true; break; } + const length = (second & 15) + 2; + for (let index = 0; index < length; index++) { + if (!initialized[reference]) fail('uninitialized dictionary reference'); + put(dictionary[reference]); + reference = (reference + 1) & 4095; + } + } else { + if (cursor >= payload.length) fail('truncated literal'); + put(payload[cursor++]); + } + } + } + if (!terminated || position !== rawSize) fail('missing terminator or incomplete body'); + return output; +} + + +const SKIPPED = new Set([ + 'fonttbl', 'colortbl', 'stylesheet', 'info', 'pict', 'object', 'objdata', + 'header', 'headerl', 'headerr', 'footer', 'footerl', 'footerr', 'filetbl', + 'listtable', 'listoverridetable', 'listtext', 'pntext', 'fldinst', + 'datastore', 'themedata', 'colorschememapping', 'xmlnstbl', 'generator', +]); +interface GroupState { + skip: boolean; + htmlTag: boolean; + htmlRtf: boolean; + uc: number; + encoding: string; + starred: boolean; +} + +export function extractMsgRtf(bytes: Uint8Array, codepage?: number): { text: string; html?: string } { + if (bytes.length > MAX_MSG_RTF_BYTES) throw new Error('Outlook RTF exceeds the body limit.'); + // Latin-1 byte identity, NOT TextDecoder("latin1") (which maps to Windows-1252). + let source = ''; + for (let offset = 0; offset < bytes.length; offset += 8192) source += String.fromCharCode(...bytes.subarray(offset, offset + 8192)); + if (!/^\{\\rtf[1-9]/.test(source)) throw new Error('Invalid Outlook RTF document.'); + let state: GroupState = { skip: false, htmlTag: false, htmlRtf: false, uc: 1, encoding: msgCodepageLabel(codepage) || 'windows-1252', starred: false }; + const stack: GroupState[] = []; + const text: string[] = []; + const html: string[] = []; + const pending: number[] = []; + let fallback = 0; + let fromHtml = false; + const emit = (value: string) => { + if (state.skip) return; + if (!state.htmlTag) text.push(value); + if (!state.htmlRtf) html.push(value); + }; + const flush = () => { + if (!pending.length) return; + emit(new TextDecoder(state.encoding).decode(new Uint8Array(pending))); + pending.length = 0; + }; + const byte = (value: number) => { + if (fallback > 0) { fallback--; return; } + pending.push(value); + + }; + let rootClosed = false; + for (let cursor = 0; cursor < source.length;) { + const char = source[cursor++]; + if (rootClosed) { + if (!/[\s\0]/.test(char)) throw new Error('Unexpected content after Outlook RTF document.'); + continue; + } + if (char === '{') { + flush(); + if (stack.length >= 128) throw new Error('Outlook RTF nesting limit exceeded.'); + stack.push({ ...state }); + state = { ...state, starred: false }; + fallback = 0; + continue; + } + if (char === '}') { + flush(); + if (!stack.length) throw new Error('Unbalanced Outlook RTF document.'); + state = stack.pop()!; + fallback = 0; + rootClosed = stack.length === 0; + continue; + } + if (char === '\r' || char === '\n') continue; + if (char !== '\\') { byte(char.charCodeAt(0)); continue; } + const control = source[cursor++]; + if (control === '\\' || control === '{' || control === '}') { byte(control.charCodeAt(0)); continue; } + if (control === "'") { + const hex = source.slice(cursor, cursor + 2); + if (!/^[0-9a-f]{2}$/i.test(hex)) throw new Error('Invalid Outlook RTF hex escape.'); + byte(Number.parseInt(hex, 16)); cursor += 2; continue; + } + flush(); + if (control === '*') { state.starred = true; continue; } + if (control === '~' || control === '_' || control === '-') { + if (fallback) fallback--; else emit(control === '~' ? '\u00a0' : control === '_' ? '\u2011' : '\u00ad'); + continue; + } + if (!control || !/[a-z]/i.test(control)) continue; + let word = control; + while (cursor < source.length && /[a-z]/i.test(source[cursor])) word += source[cursor++]; + const start = cursor; + if (source[cursor] === '-') cursor++; + while (cursor < source.length && /[0-9]/.test(source[cursor])) cursor++; + const raw = source.slice(start, cursor); + const value = raw && raw !== '-' ? Number(raw) : undefined; + if (source[cursor] === ' ') cursor++; + if (state.starred) { + if (word !== 'htmltag' && word !== 'mhtmltag') state.skip = true; + state.starred = false; + } + if (SKIPPED.has(word)) state.skip = true; + if (word === 'htmltag' || word === 'mhtmltag') state.htmlTag = true; + if (word === 'htmlrtf') state.htmlRtf = value !== 0; + if (word === 'fromhtml' && value === 1) fromHtml = true; + if (word === 'ansicpg' && value !== undefined) { + state.encoding = msgCodepageLabel(value) || state.encoding; + } + if (word === 'uc' && value !== undefined) state.uc = Math.max(0, Math.min(16, value)); + if (word === 'u' && value !== undefined) { + emit(String.fromCharCode(value & 0xffff)); + fallback = state.uc; + } + if (word === 'bin') { + if (value === undefined || !Number.isSafeInteger(value) || value < 0 || cursor + value > source.length) throw new Error('Invalid Outlook RTF binary block.'); + cursor += value; + } + const special = ({ par: '\n', line: '\n', tab: '\t', cell: '\t', row: '\n', emdash: '\u2014', endash: '\u2013', bullet: '\u2022', lquote: '\u2018', rquote: '\u2019', ldblquote: '\u201c', rdblquote: '\u201d' } as Record)[word]; + if (special) { + if (fallback) fallback--; else emit(special); + } + } + flush(); + if (stack.length || !rootClosed) throw new Error('Truncated Outlook RTF document.'); + return { text: text.join('').trim(), html: fromHtml ? html.join('').trim() : undefined }; +} diff --git a/packages/renderers/email/test/evidence/msg-preview.webp b/packages/renderers/email/test/evidence/msg-preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..bf3e5df59b4d5be742cc69739669a1d2a6853b90 GIT binary patch literal 9378 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 R`%W+md;mBxGT{H``Coyl8zcY# literal 0 HcmV?d00001 diff --git a/packages/renderers/email/test/fixtures/README.md b/packages/renderers/email/test/fixtures/README.md new file mode 100644 index 000000000..49f7e2d53 --- /dev/null +++ b/packages/renderers/email/test/fixtures/README.md @@ -0,0 +1,11 @@ +# MSG regression fixture provenance + +`msg-fixture.mjs` creates original, deterministic compound files in memory. It is not a renamed EML or a copy of a customer message. All identities use `example.test`; the 24 x 24 PNG is a generated solid-color image. The code and generated samples use this package's Apache-2.0 license. + +The writer models the Microsoft MS-CFB and MS-OXMSG structures used by these regressions: v3/v4 sectors, FAT/DIFAT/MiniFAT, directory red-black trees, property streams, Unicode/ANSI strings, recipients, binary attachments and embedded message storages. Its RTF envelope helper models MS-OXRTFCP MELA/LZFu with CRC and end-of-stream reference. It is test-only, not a supported production MSG writer. + +`msg.test.mjs` tests structural corruption, codepages, binary HTML, RTF decompression, metadata, exact attachment bytes and resource cleanup. Five default-on cases run through the real installed `@kenjiuno/msgreader`, including extraction and reparsing of an embedded MSG. A separate Reader-shaped normalization fixture makes boundary behavior deterministic. + +`verify-msg-browser.mjs` deliberately uses Reader/RTF API fixtures with real Chromium and the emitted email renderer. Screenshots show a synthetic UI regression, not an Outlook screenshot or a real-parser end-to-end capture. It verifies binary HTML presentation, CID raster decode, table layout, recipient groups, downloads, narrow layout, sandbox/CSP, body switching, races and disposal. No runtime network server or CDN is needed by this gate. + +Required before declaring full integration verified: run `pnpm --filter @file-viewer/renderer-email verify:email` without `MSG_UNIT_ONLY`, build/type-check the actual package against workspace dependencies, and compare representative Outlook-produced messages and real RTF output where fidelity is claimed. Public or privately supplied real messages must retain their actual privacy restrictions; none are included here. diff --git a/packages/renderers/email/test/fixtures/msg-fixture.mjs b/packages/renderers/email/test/fixtures/msg-fixture.mjs new file mode 100644 index 000000000..ceb4724eb --- /dev/null +++ b/packages/renderers/email/test/fixtures/msg-fixture.mjs @@ -0,0 +1,198 @@ +// License-safe fixtures authored for File Viewer. All identities use example.test. +// A real CFB v3/v4 writer, including MiniFAT, DIFAT and red-black directory trees. +// No Outlook installation, personal email or renamed text file is involved. +const FREE = 0xffffffff, END = 0xfffffffe, FAT = 0xfffffffd, DIFAT = 0xfffffffc; +const u16 = value => new Uint8Array(Buffer.from(value + '\0', 'utf16le')); +const utf8 = value => new TextEncoder().encode(value); +const concat = arrays => { const out = new Uint8Array(arrays.reduce((n, a) => n + a.length, 0)); let p = 0; for (const a of arrays) { out.set(a, p); p += a.length; } return out; }; +const dataView = bytes => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); +export const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAIAAABvFaqvAAAAIklEQVR4nGOUaQhnoAZgooopowaNGjRq0KhBowaNGkQRAAAoaAEj5Rf7BgAAAABJRU5ErkJggg==', 'base64')); + +export function writeCompoundFile(streams, version = 3) { + const sectorSize = version === 4 ? 4096 : 512, words = sectorSize / 4; + const nodes = [{ name: 'Root Entry', type: 5, children: [], left: FREE, right: FREE, child: FREE, color: 1, parent: FREE }]; + const paths = new Map([['', 0]]); + const directory = path => { + if (paths.has(path)) return paths.get(path); + const slash = path.lastIndexOf('/'), parent = directory(path.slice(0, Math.max(0, slash))); + const name = path.slice(slash + 1), id = nodes.length; + nodes.push({ name, type: 1, children: [], left: FREE, right: FREE, child: FREE, color: 1, parent: FREE }); + nodes[parent].children.push(id); paths.set(path, id); return id; + }; + for (const [path, data] of Object.entries(streams)) { + const slash = path.lastIndexOf('/'), parent = directory(slash < 0 ? '' : path.slice(0, slash)); + const id = nodes.length; + nodes.push({ name: path.slice(slash + 1), type: 2, data, left: FREE, right: FREE, child: FREE, color: 0, parent: FREE }); + nodes[parent].children.push(id); + } + const less = (a, b) => a.name.length !== b.name.length ? a.name.length < b.name.length : a.name.toUpperCase() < b.name.toUpperCase(); + // Standard red-black insertion, with a separate sibling tree for each storage. + for (const owner of nodes.filter(n => n.type !== 2)) { + let root = FREE; + const rotate = (x, left) => { + const a = left ? 'right' : 'left', b = left ? 'left' : 'right', y = nodes[x][a]; + nodes[x][a] = nodes[y][b]; + if (nodes[y][b] !== FREE) nodes[nodes[y][b]].parent = x; + nodes[y].parent = nodes[x].parent; + if (nodes[x].parent === FREE) root = y; + else nodes[nodes[x].parent][nodes[nodes[x].parent].left === x ? 'left' : 'right'] = y; + nodes[y][b] = x; nodes[x].parent = y; + }; + for (const id of owner.children) { + let parent = FREE, next = root; + while (next !== FREE) { parent = next; next = nodes[next][less(nodes[id], nodes[next]) ? 'left' : 'right']; } + nodes[id].parent = parent; nodes[id].color = 0; + if (parent === FREE) root = id; else nodes[parent][less(nodes[id], nodes[parent]) ? 'left' : 'right'] = id; + let z = id; + while (nodes[z].parent !== FREE && nodes[nodes[z].parent].color === 0) { + let p = nodes[z].parent, g = nodes[p].parent; + const left = nodes[g].left === p, uncle = nodes[g][left ? 'right' : 'left']; + if (uncle !== FREE && nodes[uncle].color === 0) { nodes[p].color = nodes[uncle].color = 1; nodes[g].color = 0; z = g; } + else { + if (nodes[p][left ? 'right' : 'left'] === z) { z = p; rotate(z, left); p = nodes[z].parent; g = nodes[p].parent; } + nodes[p].color = 1; nodes[g].color = 0; rotate(g, !left); + } + } + nodes[root].color = 1; + } + owner.child = root; + } + const mini = [], miniFat = []; + for (const node of nodes) { + if (node.type !== 2) continue; + node.size = node.data.length; node.start = END; + if (!node.size || node.size >= 4096) continue; + node.start = mini.length; + for (let p = 0; p < node.size; p += 64) { + const block = new Uint8Array(64); block.set(node.data.subarray(p, p + 64)); + miniFat.push(p + 64 < node.size ? mini.length + 1 : END); mini.push(block); + } + } + const sectors = [], chains = []; + const allocate = data => { + if (!data.length) return END; + const first = sectors.length, ids = []; + for (let p = 0; p < data.length; p += sectorSize) { + const block = new Uint8Array(sectorSize); block.set(data.subarray(p, p + sectorSize)); + ids.push(sectors.length); sectors.push(block); + } + chains.push(ids); return first; + }; + for (const node of nodes) if (node.type === 2 && node.size >= 4096) node.start = allocate(node.data); + const miniBytes = concat(mini); + nodes[0].size = miniBytes.length; nodes[0].start = allocate(miniBytes); + const miniTable = new Uint8Array(Math.ceil(miniFat.length / words) * sectorSize).fill(255); + miniFat.forEach((value, index) => dataView(miniTable).setUint32(index * 4, value, true)); + const firstMiniFat = allocate(miniTable); + const dirBytes = new Uint8Array(Math.ceil(nodes.length * 128 / sectorSize) * sectorSize), dv = dataView(dirBytes); + nodes.forEach((node, id) => { + const start = id * 128, name = u16(node.name); + if (name.length > 64) throw new Error('CFB fixture name too long'); + dirBytes.set(name, start); dv.setUint16(start + 64, name.length, true); dv.setUint8(start + 66, node.type); dv.setUint8(start + 67, node.color); + for (const [offset, value] of [[68, node.left], [72, node.right], [76, node.child], [116, node.start ?? END]]) dv.setUint32(start + offset, value, true); + dv.setBigUint64(start + 120, BigInt(node.size || 0), true); + if (!id) dirBytes.set([0x0b, 0x0d, 0x02, 0, 0, 0, 0, 0, 0xc0, 0, 0, 0, 0, 0, 0, 0x46], start + 80); + }); + const firstDirectory = allocate(dirBytes), dataCount = sectors.length; + let fatCount = 1, difatCount = 0; + for (;;) { + const f = Math.ceil((dataCount + fatCount + difatCount) / words), d = Math.ceil(Math.max(0, f - 109) / (words - 1)); + if (f === fatCount && d === difatCount) break; + fatCount = f; difatCount = d; + } + const fatIds = Array.from({ length: fatCount }, (_, i) => dataCount + i); + const difatIds = Array.from({ length: difatCount }, (_, i) => dataCount + fatCount + i); + const fatBytes = new Uint8Array(fatCount * sectorSize).fill(255), fv = dataView(fatBytes); + for (const ids of chains) ids.forEach((id, i) => fv.setUint32(id * 4, ids[i + 1] ?? END, true)); + fatIds.forEach(id => fv.setUint32(id * 4, FAT, true)); difatIds.forEach(id => fv.setUint32(id * 4, DIFAT, true)); + for (let i = 0; i < fatCount; i++) sectors.push(fatBytes.slice(i * sectorSize, (i + 1) * sectorSize)); + for (let i = 0; i < difatCount; i++) { + const block = new Uint8Array(sectorSize).fill(255), v = dataView(block); + for (let j = 0; j < words - 1; j++) v.setUint32(j * 4, fatIds[109 + i * (words - 1) + j] ?? FREE, true); + v.setUint32(sectorSize - 4, difatIds[i + 1] ?? END, true); sectors.push(block); + } + const header = new Uint8Array(sectorSize), hv = dataView(header); + header.set([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]); + for (const [offset, value] of [[24, 0x3e], [26, version], [28, 0xfffe], [30, version === 4 ? 12 : 9], [32, 6]]) hv.setUint16(offset, value, true); + for (const [offset, value] of [[40, version === 4 ? dirBytes.length / sectorSize : 0], [44, fatCount], [48, firstDirectory], [56, 4096], [60, firstMiniFat], [64, miniTable.length / sectorSize], [68, difatIds[0] ?? END], [72, difatCount]]) hv.setUint32(offset, value, true); + for (let i = 0; i < 109; i++) hv.setUint32(76 + i * 4, fatIds[i] ?? FREE, true); + return concat([header, ...sectors]).buffer; +} + +export function rtfEnvelope(rtf, compressed = false) { + const raw = typeof rtf === 'string' ? Uint8Array.from(Buffer.from(rtf, 'latin1')) : rtf; + let payload = raw; + if (compressed) { + const groups = []; + for (let p = 0; p < raw.length; p += 8) { + const literals = raw.slice(p, p + 8); + if (literals.length === 8) groups.push(Uint8Array.of(0), literals); + else { + const ref = (207 + raw.length) & 4095; + groups.push(Uint8Array.of(1 << literals.length), literals, Uint8Array.of(ref >>> 4, (ref & 15) << 4)); + } + } + if (raw.length % 8 === 0) { const ref = (207 + raw.length) & 4095; groups.push(Uint8Array.of(1, ref >>> 4, (ref & 15) << 4)); } + payload = concat(groups); + } + const bytes = new Uint8Array(16 + payload.length), v = dataView(bytes); + v.setUint32(0, bytes.length - 4, true); v.setUint32(4, raw.length, true); v.setUint32(8, compressed ? 0x75465a4c : 0x414c454d, true); + let crc = 0; + if (compressed) for (const byte of payload) { crc ^= byte; for (let n = 0; n < 8; n++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); } + v.setUint32(12, crc >>> 0, true); bytes.set(payload, 16); return bytes; +} + +export function createMsgFixture(options = {}) { + const streams = {}; + const properties = (path, headerSize, entries, recipientCount = 0, attachmentCount = 0) => { + const data = new Uint8Array(headerSize + entries.length * 16), v = dataView(data); + if (headerSize >= 24) for (const [offset, value] of [[8, recipientCount], [12, attachmentCount], [16, recipientCount], [20, attachmentCount]]) v.setUint32(offset, value, true); + entries.forEach(([tag, value], index) => { + const p = headerSize + index * 16; v.setUint32(p, tag, true); v.setUint32(p + 4, 6, true); + const type = tag & 0xffff; + if (type === 3) v.setUint32(p + 8, value, true); + else if (type === 0xb) v.setUint16(p + 8, Number(value), true); + else if (type === 0x40) v.setBigUint64(p + 8, BigInt(value), true); + else if (type !== 0xd) { + const bytes = type === 0x1f ? u16(value) : typeof value === 'string' ? utf8(value) : value; + streams[path + '__substg1.0_' + tag.toString(16).padStart(8, '0').toUpperCase()] = bytes; + v.setUint32(p + 8, bytes.length, true); + } + }); + streams[path + '__properties_version1.0'] = data; + }; + const message = (path, inner = false) => { + const ansi = !inner && options.ansi; + const subject = inner ? 'Nested message' : options.subject || 'MSG preview — 中文 日本語'; + const body = inner ? 'Nested Outlook body' : options.text ?? 'Plain text body — 中文'; + const recipients = inner ? [] : [['Alice', 'alice@example.test', 1], ['Carol', 'carol@example.test', 2], ['Bob', 'bob@example.test', 3]]; + const attachments = inner || options.attachments === false ? [] : [ + { name: 'inline.png', mime: 'image/png', cid: 'logo@example.test', content: PNG }, + { name: 'report.txt', mime: 'text/plain', content: utf8('Attachment bytes\n') }, + { name: 'Nested message', inner: true }, + ]; + if (!inner && options.largeAttachment) attachments.push({ name: 'large.bin', mime: 'application/octet-stream', content: new Uint8Array(options.largeAttachment) }); + const entries = [[0x001a001f, 'IPM.Note'], [0x0c1a001f, 'Sender'], [0x0c1f001f, '/O=TEST/OU=EXCHANGE/CN=SENDER'], [0x5d01001f, 'sender@example.test'], [0x3ffd0003, ansi ? 936 : 65001], [0x3fde0003, options.internetCodepage || 65001], [0x00390040, BigInt(Date.UTC(2026, 8, 18) + 11644473600000) * 10000n]]; + if (ansi) { + entries.push([0x0037001e, Uint8Array.of(0xd6, 0xd0, 0xce, 0xc4, 0)], [0x1000001e, Uint8Array.of(0xd6, 0xd0, 0xce, 0xc4, 0)]); + } else { entries.push([0x0037001f, subject]); if (body) entries.push([0x1000001f, body]); } + if (!inner && options.html !== false) { + const html = options.html || '

MSG preview — 中文 日本語

HTML body, not comma-separated bytes.

HTML tablePreserved
Embedded image'; + entries.push([options.unicodeHtml ? 0x1013001f : 0x10130102, options.unicodeHtml ? html : typeof html === 'string' ? utf8(html) : html]); + } + if (!inner && options.rtf) entries.push([0x10090102, rtfEnvelope(options.rtf, options.compressedRtf)]); + if (!inner && options.headers !== false) entries.push([0x007d001f, 'From: sender@example.test\r\nTo: alice@example.test\r\nSubject: Outlook fixture\r\n']); + properties(path, inner ? 24 : 32, entries, recipients.length, attachments.length); + recipients.forEach(([name, email, type], index) => properties(`${path}__recip_version1.0_#${index.toString(16).padStart(8, '0')}/`, 8, [[0x3001001f, name], [0x3003001f, '/O=TEST/OU=EXCHANGE/CN=' + name], [0x39fe001f, email], [0x0c150003, type]])); + attachments.forEach((a, index) => { + const folder = `${path}__attach_version1.0_#${index.toString(16).padStart(8, '0')}/`; + const props = [[0x37050003, a.inner ? 5 : 1], [0x3001001f, a.name], [0x3707001f, a.name]]; + if (a.inner) { props.push([0x3701000d, null]); message(folder + '__substg1.0_3701000D/', true); } + else { props.push([0x37010102, a.content], [0x370e001f, a.mime], [0x0e200003, a.content.length]); if (a.cid) props.push([0x3712001f, a.cid], [0x7ffe000b, true]); } + properties(folder, 8, props); + }); + }; + message(''); + for (const tag of ['00020102', '00030102', '00040102']) streams['__nameid_version1.0/__substg1.0_' + tag] = new Uint8Array(); + return writeCompoundFile(streams, options.version || 3); +} diff --git a/packages/renderers/email/test/msg.test.mjs b/packages/renderers/email/test/msg.test.mjs new file mode 100644 index 000000000..f0782ad9e --- /dev/null +++ b/packages/renderers/email/test/msg.test.mjs @@ -0,0 +1,242 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; +import { createMsgFixture, writeCompoundFile, rtfEnvelope, PNG } from './fixtures/msg-fixture.mjs'; + +// The default command exercises the real installed MsgReader. UNIT_ONLY is an +// explicitly reported offline subset, never a substitute for the integration gate. +const dist = process.env.MSG_TEST_DIST ? pathToFileURL(resolve(process.env.MSG_TEST_DIST) + '/') : new URL('../dist/', import.meta.url); +const { inspectMsgContainer } = await import(new URL('msgCfb.js', dist)); +const { decompressMsgRtf, extractMsgRtf, rtfCrc32 } = await import(new URL('msgRtf.js', dist)); +const { decodeEmailHtmlBytes } = await import(new URL('msgEncoding.js', dist)); +const { createEmailImageResource } = await import(new URL('emailHtml.js', dist)); +const { normalizeMsg, captureMsgProperty, parseMsg, safeMsgAttachmentName } = await import(new URL('msg.js', dist)); +const text = new TextEncoder(), decode = bytes => new TextDecoder().decode(bytes); +const unitOnly = process.env.MSG_UNIT_ONLY === '1'; +const view = buffer => new DataView(buffer); +const fields = extra => ({ dataType: 'msg', subject: 'Subject', body: 'Body', recipients: [], attachments: [], ...extra }); +const resources = t => { const urls = []; t.after(() => urls.forEach(url => URL.revokeObjectURL(url))); return [urls, new Map()]; }; +const reader = load => ({ getFileData: () => fields(), getAttachment: load || (() => { throw new Error('unexpected extraction'); }) }); + +for (const version of [3, 4]) { + test(`CFB v${version}: directory, normal streams and MiniFAT are accepted`, () => { + assert.deepEqual(inspectMsgContainer(createMsgFixture({ version, largeAttachment: 8193 })), { messageCodepage: 65001 }); + }); +} +test('CFB: ANSI codepage is available before MsgReader decodes strings', () => assert.equal(inspectMsgContainer(createMsgFixture({ ansi: true })).messageCodepage, 936)); +test('CFB: DIFAT continuation sectors are validated', () => { + const fixture = createMsgFixture({ largeAttachment: 8 * 1024 * 1024 }); + assert.ok(view(fixture).getUint32(72, true) > 0); + assert.equal(inspectMsgContainer(fixture).messageCodepage, 65001); +}); +test('CFB: arbitrary OLE/Excel data is not treated as MSG', () => assert.throws(() => inspectMsgContainer(writeCompoundFile({ Workbook: text.encode('not a message') })), /not an Outlook/)); +for (const [name, corrupt] of [ + ['signature', buffer => new Uint8Array(buffer)[0] = 0], + ['FAT count', buffer => view(buffer).setUint32(44, 0, true)], + ['directory cycle', buffer => { const v = view(buffer), fat = (v.getUint32(76, true) + 1) * 512, dir = v.getUint32(48, true); v.setUint32(fat + dir * 4, dir, true); }], + ['directory child cycle', buffer => { const v = view(buffer), dir = (v.getUint32(48, true) + 1) * 512; v.setUint32(dir + 76, 0, true); }], + ['MiniFAT cycle', buffer => { const v = view(buffer), mini = (v.getUint32(60, true) + 1) * 512; v.setUint32(mini, 0, true); }], + ['DIFAT count', buffer => view(buffer).setUint32(72, 0xffffffff, true)], +]) test(`CFB rejects ${name}`, () => { const buffer = createMsgFixture(); corrupt(buffer); assert.throws(() => inspectMsgContainer(buffer), /Invalid Outlook MSG/); }); +test('CFB rejects truncated sectors and empty input', () => { + assert.throws(() => inspectMsgContainer(createMsgFixture().slice(0, -1)), /truncated sector/); + assert.throws(() => inspectMsgContainer(new ArrayBuffer(0)), /size limit/); +}); +test('invalid MSG fails before loading the parser dependency', async () => assert.rejects(parseMsg(new ArrayBuffer(12), 'invalid.msg', [], new Map()), /Invalid Outlook MSG/)); + +test('HTML: Unicode string and UTF-8 bytes have identical bodies', () => { + const html = '

中文 日本語 😀

'; + assert.equal(decodeEmailHtmlBytes(text.encode(html)), html); + assert.equal(decodeEmailHtmlBytes(html + '\0'), html); +}); +test('HTML: typed-array byte offsets are honored', () => { + const data = text.encode('xx

Body

yy'); + assert.equal(decodeEmailHtmlBytes(data.subarray(2, -2)), '

Body

'); +}); +test('HTML: BOM takes precedence over conflicting codepage metadata', () => { + const data = Uint8Array.from(Buffer.from('\ufeff

中文

', 'utf16le')); + assert.equal(decodeEmailHtmlBytes(data, 1252), '

中文

'); +}); +test('HTML: Outlook codepage, HTML charset and legacy fallback decode', () => { + assert.equal(decodeEmailHtmlBytes(Uint8Array.of(0xd6, 0xd0, 0xce, 0xc4), 936), '中文'); + assert.equal(decodeEmailHtmlBytes(Uint8Array.from(Buffer.from('caf\xe9', 'latin1'))), 'café'); + assert.equal(decodeEmailHtmlBytes(Uint8Array.of(0xe9)), 'é'); +}); +test('HTML: oversized and invalid values are rejected', () => { + assert.throws(() => decodeEmailHtmlBytes(new Uint8Array(32 * 1024 * 1024 + 1)), /oversized/); + assert.throws(() => decodeEmailHtmlBytes({}), /Invalid/); +}); + +test('RTF CRC uses the MS-OXRTFCP initialization and finalization', () => assert.equal(rtfCrc32(text.encode('123456789')), 0x2dfd2d88)); +for (const compressed of [false, true]) test(`RTF: ${compressed ? 'LZFu' : 'MELA'} body decodes exactly`, () => { + const input = '{\\rtf1\\ansi Test \\b bold\\b0\\par Next line}'; + assert.equal(decode(decompressMsgRtf(rtfEnvelope(input, compressed))), input); +}); +test('RTF: LZFu ring wraps beyond 4096 bytes', () => { + const input = '{\\rtf1\\ansi ' + 'abcdefgh'.repeat(1500) + '}'; + assert.equal(decode(decompressMsgRtf(rtfEnvelope(input, true))), input); +}); +test('RTF: references can use the prescribed initial dictionary', () => { + const payload = Uint8Array.of(3, 0, 15, 14, 0), compressed = new Uint8Array(16 + payload.length), v = view(compressed.buffer); + v.setUint32(0, compressed.length - 4, true); v.setUint32(4, 17, true); v.setUint32(8, 0x75465a4c, true); v.setUint32(12, rtfCrc32(payload), true); compressed.set(payload, 16); + assert.equal(decode(decompressMsgRtf(compressed)), '{\\rtf1\\ansi\\mac\\deff0'.slice(0, 17)); +}); +test('RTF: CRC mismatch, expansion limit and truncation are rejected', () => { + const a = rtfEnvelope('{\\rtf1 test}', true); a[12] ^= 1; + assert.throws(() => decompressMsgRtf(a), /CRC/); + const b = rtfEnvelope('{\\rtf1 test}', true); view(b.buffer).setUint32(4, 0xffffffff, true); + assert.throws(() => decompressMsgRtf(b), /limit/); + assert.throws(() => decompressMsgRtf(rtfEnvelope('{\\rtf1 test}', true).subarray(0, -1)), /mismatch/); +}); +test('RTF: text extraction respects Unicode fallback, destinations and paragraphs', () => { + const rtf = String.raw`{\rtf1\ansi\uc1{\fonttbl{\f0 Hidden Font;}}\u20013?\u25991?\par Visible {\*\unknown hidden}next\tab end}`; + assert.equal(extractMsgRtf(text.encode(rtf)).text, '中文\nVisible next\tend'); +}); +test('RTF: CJK hex byte runs are not split at 8192 bytes', () => { + const raw = String.raw`{\rtf1\ansi\ansicpg65001 ` + 'a'.repeat(8191) + String.raw`\'e4\'b8\'ad}`; + assert.equal(extractMsgRtf(text.encode(raw)).text, 'a'.repeat(8191) + '中'); +}); +test('RTF: encapsulated HTML excludes htmlrtf fallback content', () => { + const rtf = String.raw`{\rtf1\ansi\fromhtml1{\*\htmltag1

}Body{\*\htmltag1

}\htmlrtf1 ignored\htmlrtf0}`; + assert.equal(extractMsgRtf(text.encode(rtf)).html, '

Body

'); +}); +test('RTF: truncated groups and binary blocks are rejected', () => { + assert.throws(() => extractMsgRtf(text.encode('{\\rtf1')), /Truncated/); + assert.throws(() => extractMsgRtf(text.encode('{\\rtf1\\bin999 x}')), /binary/); + assert.throws(() => extractMsgRtf(text.encode('{\\rtf1' + '{'.repeat(129) + '}'.repeat(130))), /nesting/); +}); + +test('MSG: To, Cc and Bcc stay separate; SMTP is preferred over Exchange DN', async t => { + const parsed = await normalizeMsg(reader(), fields({ senderName: 'Sender', senderEmail: '/O=EX', senderSmtpAddress: 'sender@example.test', recipients: [ + { name: 'To', smtpAddress: 'to@example.test', email: '/O=EX', recipType: 'to' }, + { email: 'cc@example.test', recipType: 2 }, { email: 'bcc@example.test', recipType: 'bcc' }, + ] }), 'email.msg', ...resources(t)); + assert.equal(parsed.from[0].address, 'sender@example.test'); + assert.deepEqual(parsed.to.map(a => a.address), ['to@example.test']); + assert.deepEqual(parsed.cc.map(a => a.address), ['cc@example.test']); + assert.deepEqual(parsed.bcc.map(a => a.address), ['bcc@example.test']); +}); +test('MSG: Unicode bodyHtml wins; binary HTML is not coerced to comma-separated numbers', async t => { + assert.equal((await normalizeMsg(reader(), fields({ bodyHtml: '

Unicode

', html: text.encode('

Binary

') }), 'email.msg', ...resources(t))).html, '

Unicode

'); + assert.equal((await normalizeMsg(reader(), fields({ html: text.encode('

中文

') }), 'email.msg', ...resources(t))).html, '

中文

'); +}); +test('MSG: HTML-encapsulated RTF supplies both HTML and fallback text', async t => { + const rtf = String.raw`{\rtf1\ansi\fromhtml1{\*\htmltag1 }Body{\*\htmltag1 }}`; + const parsed = await normalizeMsg(reader(), fields({ body: '', compressedRtf: rtfEnvelope(rtf, true) }), 'email.msg', ...resources(t)); + assert.equal(parsed.html, 'Body'); assert.equal(parsed.text, 'Body'); assert.ok(parsed.rtf instanceof ArrayBuffer); +}); +test('MSG: corrupt RTF preserves a usable body with a notice', async t => { + const parsed = await normalizeMsg(reader(), fields({ compressedRtf: Uint8Array.of(1) }), 'email.msg', ...resources(t)); + assert.equal(parsed.text, 'Body'); assert.deepEqual(parsed.warnings, ['email.msg.rtfUnavailable']); +}); +test('MSG: raster CID attachments are registered; ordinary and nested attachments remain lazy', async t => { + let extractions = 0; + const load = attachment => { extractions++; return { fileName: attachment.fileName, content: attachment.pidContentId ? PNG : text.encode('Exact attachment bytes') }; }; + const [urls, cids] = resources(t); + const parsed = await normalizeMsg(reader(load), fields({ attachments: [ + { fileName: 'inline.png', attachMimeTag: 'image/png', pidContentId: '', contentLength: PNG.length }, + { fileName: '../../report.txt', contentLength: 22 }, + { name: 'Forwarded', innerMsgContent: true, innerMsgContentFields: fields() }, + ] }), 'email.msg', urls, cids); + assert.equal(extractions, 1); assert.equal(urls.length, 1); assert.ok(cids.get('logo@example.test').startsWith('blob:')); + assert.deepEqual(parsed.attachments.map(a => [a.name, a.mimeType]), [['inline.png', 'image/png'], ['report.txt', 'text/plain'], ['Forwarded.msg', 'application/vnd.ms-outlook']]); + assert.equal(decode(await parsed.attachments[1].load()), 'Exact attachment bytes'); assert.equal(extractions, 2); +}); +test('MSG: attachment views respect typed-array offsets and unique names', async t => { + const raw = text.encode('xxPAYLOADyy'); + const parsed = await normalizeMsg(reader(() => ({ fileName: 'same.txt', content: raw.subarray(2, -2) })), fields({ attachments: [{ fileName: 'same.txt' }, { fileName: 'same.txt' }] }), 'email.msg', ...resources(t)); + assert.notEqual(parsed.attachments[0].id, parsed.attachments[1].id); + assert.equal(decode(await parsed.attachments[0].load()), 'PAYLOAD'); +}); +test('MSG: unavailable inline attachment preserves message and download entry', async t => { + const parsed = await normalizeMsg(reader(() => { throw new Error('missing data'); }), fields({ attachments: [{ fileName: 'a.png', pidContentId: 'a' }] }), 'email.msg', ...resources(t)); + assert.equal(parsed.text, 'Body'); assert.equal(parsed.attachments.length, 1); assert.deepEqual(parsed.warnings, ['email.msg.inlineUnavailable']); +}); +test('MSG: drafts synthesize explicitly labeled MAPI metadata, not invented transport headers', async t => { + const parsed = await normalizeMsg(reader(), fields({ subject: 'Title\r\nInjected: value' }), 'email.msg', ...resources(t)); + assert.match(parsed.headers, /not transport headers/); assert.ok(!parsed.headers.includes('\r\nInjected:')); +}); +test('MSG: existing raw headers are kept exactly', async t => { + const headers = 'X-Unknown: one\r\n continuation\r\nX-Unknown: two\r\n'; + assert.equal((await normalizeMsg(reader(), fields({ headers }), 'email.msg', ...resources(t))).headers, headers); +}); +test('MSG: empty/protected item gets explicit capability notices', async t => { + const parsed = await normalizeMsg(reader(), fields({ body: '', messageClass: 'IPM.Note.SMIME' }), 'email.msg', ...resources(t)); + assert.deepEqual(parsed.warnings, ['email.msg.noBody', 'email.msg.protected']); +}); +test('MSG: parser errors, recursive data and resource excess are rejected', async t => { + await assert.rejects(normalizeMsg(reader(), fields({ error: 'Not MSG' }), 'email.msg', ...resources(t)), /Not MSG/); + const cyclic = fields(); cyclic.attachments = [{ innerMsgContentFields: cyclic }]; + await assert.rejects(normalizeMsg(reader(), cyclic, 'email.msg', ...resources(t)), /nesting/); + await assert.rejects(normalizeMsg(reader(), fields({ recipients: Array(4097).fill({}) }), 'email.msg', ...resources(t)), /limit/); +}); +test('MSG: cancellation prevents parsing and attachment extraction', async t => { + const controller = new AbortController(); controller.abort(); + await assert.rejects(normalizeMsg(reader(), fields(), 'email.msg', ...resources(t), controller.signal), { name: 'AbortError' }); +}); +test('MSG: attachment basenames strip paths, control and bidi characters', () => { + assert.equal(safeMsgAttachmentName('C:\\folder\\invoice\u202epdf.txt', 'fallback'), 'invoicepdf.txt'); + assert.equal(safeMsgAttachmentName('../..', 'fallback'), 'fallback'); +}); + + +test('HTML: opaque-origin image fallback preserves bytes without leaking a blob URL', t => { + const create = URL.createObjectURL, revoke = URL.revokeObjectURL; + const revoked = []; URL.createObjectURL = () => 'blob:null/opaque'; URL.revokeObjectURL = value => revoked.push(value); + t.after(() => { URL.createObjectURL = create; URL.revokeObjectURL = revoke; }); + const bytes = new Uint8Array(50001).map((_, i) => i % 251), urls = []; + const data = createEmailImageResource(bytes.buffer, 'image/png', urls); + assert.deepEqual(new Uint8Array(Buffer.from(data.split(',')[1], 'base64')), bytes); + assert.deepEqual(urls, []); assert.deepEqual(revoked, ['blob:null/opaque']); +}); +test('HTML: BOM-less UTF-16 HTML is detected', () => assert.equal(decodeEmailHtmlBytes(Uint8Array.from(Buffer.from('

中文

', 'utf16le'))), '

中文

')); +test('MAPI: property observer captures Unicode and ANSI supplemental metadata only', () => { + const f = fields(); + captureMsgProperty(f, 0x0042001f, Uint8Array.from(Buffer.from('Author\noverflow\0', 'utf16le'))); + assert.equal(f.sentRepresentingName, 'Author\noverflow'); + captureMsgProperty(f, 0x3713001e, text.encode('images/inline.png\0')); + assert.equal(f.contentLocation, 'images/inline.png'); + captureMsgProperty(f, 0x3001001f, text.encode('ignored')); + assert.equal(f.name, undefined); + assert.throws(() => captureMsgProperty(f, 0x0042001f, new Uint8Array(65537)), /limit/); +}); +test('MSG: on-behalf-of identity keeps author and transport sender distinct', async t => { + const parsed = await normalizeMsg(reader(), fields({ senderName: 'Assistant', senderSmtpAddress: 'assistant@example.test', sentRepresentingName: 'Author', sentRepresentingSmtpAddress: 'author@example.test' }), 'mail.msg', ...resources(t)); + assert.deepEqual(parsed.from, [{ name: 'Author', address: 'author@example.test' }]); + assert.deepEqual(parsed.sender, [{ name: 'Assistant', address: 'assistant@example.test' }]); +}); +test('MSG: draft display lists remain display text rather than invented addresses', async t => { + const parsed = await normalizeMsg(reader(), fields({ displayTo: 'Alice; Bob', displayCc: 'Carol', displayBcc: 'David' }), 'draft.msg', ...resources(t)); + assert.deepEqual(parsed.to, [{ name: 'Alice; Bob' }]); assert.deepEqual(parsed.bcc, [{ name: 'David' }]); +}); +test('MSG: Content-Location and unique filenames resolve local images', async t => { + const [urls, cids] = resources(t); + await normalizeMsg(reader(() => ({ fileName: 'inline.png', content: PNG })), fields({ html: '', attachments: [{ fileName: 'inline.png', contentLocation: 'images/inline.png', attachMimeTag: 'image/png' }] }), 'mail.msg', urls, cids); + assert.ok(cids.get('images/inline.png')); assert.equal(cids.get('inline.png'), cids.get('images/inline.png')); +}); +test('MSG: unknown attachment sizes are not falsely reported as zero', async t => { + const parsed = await normalizeMsg(reader(() => ({ fileName: 'data.bin', content: Uint8Array.of(1, 2, 3) })), fields({ attachments: [{ fileName: 'data.bin' }] }), 'mail.msg', ...resources(t)); + assert.equal(parsed.attachments[0].size, -1); await parsed.attachments[0].load(); assert.equal(parsed.attachments[0].size, 3); +}); + +for (const [name, options] of [ + ['Unicode binary HTML', {}], ['Unicode string HTML', { unicodeHtml: true }], + ['ANSI Chinese', { ansi: true, html: false }], ['CFB v4', { version: 4 }], + ['compressed RTF', { html: false, text: '', rtf: String.raw`{\rtf1\ansi Rich \b body\b0}`, compressedRtf: true }], +]) test(`installed MsgReader: ${name}, recipients, CID and nested MSG round-trip`, { skip: unitOnly && 'offline unit subset; installed parser integration is required separately' }, async t => { + const [urls, cids] = resources(t); + const parsed = await parseMsg(createMsgFixture(options), 'fixture.msg', urls, cids); + assert.equal(parsed.subject, options.ansi ? '中文' : 'MSG preview — 中文 日本語'); + assert.deepEqual(parsed.to.map(a => a.address), ['alice@example.test']); + assert.deepEqual(parsed.cc.map(a => a.address), ['carol@example.test']); + assert.deepEqual(parsed.bcc.map(a => a.address), ['bob@example.test']); + assert.equal(parsed.from[0].address, 'sender@example.test'); + if (options.ansi) assert.equal(parsed.text, '中文'); + else if (options.rtf) { assert.equal(parsed.text, 'Rich body'); assert.ok(parsed.rtf); } + else assert.match(parsed.html, /HTML body, not comma-separated bytes/); + assert.equal(parsed.attachments.length, 3); assert.ok(cids.has('logo@example.test')); + assert.equal(decode(await parsed.attachments[1].load()), 'Attachment bytes\n'); + const nested = await parseMsg(await parsed.attachments[2].load(), parsed.attachments[2].name, urls, new Map()); + assert.equal(nested.subject, 'Nested message'); assert.equal(nested.text, 'Nested Outlook body'); +}); From 2f9244c5d97d5a3c687110ef4fd6003dc948ab06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E7=88=AC=E7=9A=84=E8=80=81=E7=B2=89=E4=B8=9D?= <727842003@qq.com> Date: Fri, 18 Sep 2026 17:34:50 +0800 Subject: [PATCH 2/2] fix(email): drop unsupported sourceUrl from nested render context Co-authored-by: Cursor --- packages/renderers/email/src/email.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/renderers/email/src/email.ts b/packages/renderers/email/src/email.ts index 174e2ccf2..b5c822beb 100644 --- a/packages/renderers/email/src/email.ts +++ b/packages/renderers/email/src/email.ts @@ -509,7 +509,6 @@ export default async function renderEmail( const rendered = await context.renderNestedBuffer(parsed.rtf, 'rtf', child, { ...context, filename: `${filename}.rtf`, - sourceUrl: undefined, signal: current.signal, options: { ...context.options, @@ -613,7 +612,6 @@ export default async function renderEmail( const nextRendered = await context.renderNestedBuffer(attachmentBuffer, extension, child, { ...context, filename: attachment.name, - sourceUrl: undefined, options: context.options, signal: previewAbortController.signal, });