Release Mail Tools 0.2.0
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
name: Verify
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
- name: Select declared npm version
|
||||
run: npm install --global npm@11.17.0
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Audit runtime dependencies
|
||||
run: npm audit --omit=dev --audit-level=moderate
|
||||
- name: Check, test, and build
|
||||
run: npm run check
|
||||
- name: Install browser engines
|
||||
run: npx playwright install --with-deps chromium firefox webkit
|
||||
- name: Browser tests
|
||||
run: npm run test:browser
|
||||
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 - 2026-09-02
|
||||
|
||||
- Preserved original message octets and MIME entity ranges, including RFC 2231
|
||||
continued parameters and byte-safe attachment downloads.
|
||||
- Added bounded mbox/mboxrd import, threading and search, safe packaged CID-image
|
||||
preview, and deeper MIME-tree redaction with explicit evidence.
|
||||
- Added local RSA-SHA256 and Ed25519-SHA256 DKIM verification against public-key
|
||||
DNS text pasted by the user.
|
||||
|
||||
## 0.1.0 - 2026-09-01
|
||||
|
||||
- Initial local-first EML/MIME inspection release.
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
# Mail Tools
|
||||
|
||||
Mail Tools is a local-first browser workbench for inspecting `.eml` and MIME
|
||||
messages. Version 0.1.0 unfolds headers, decodes RFC 2047 words, builds a bounded
|
||||
multipart tree, decodes common transfer encodings, inventories attachments,
|
||||
compares text bodies, and creates canonical or focused redacted exports.
|
||||
Mail Tools is a local-first browser workbench for inspecting `.eml`, MIME and
|
||||
mbox files. It unfolds headers, decodes RFC 2047 words, builds a bounded
|
||||
multipart tree, threads mailbox messages, inventories attachments, compares
|
||||
text bodies, and creates canonical or focused redacted exports. File imports
|
||||
are parsed from their original octets, retain exact source byte ranges for raw
|
||||
MIME entities, and assemble RFC 2231 continued parameters.
|
||||
|
||||
HTML bodies are sanitized and displayed only in an opaque sandbox with an
|
||||
embedded `default-src 'none'` policy. Remote images, links, forms, scripts,
|
||||
styles, media, frames and active documents are removed. No message, address,
|
||||
attachment, URL or telemetry leaves the browser.
|
||||
styles, media, frames and active documents are removed. Referenced CID images
|
||||
of a small safe type can be embedded from the same message. No message,
|
||||
address, attachment, URL or telemetry leaves the browser.
|
||||
|
||||
## Development and release
|
||||
|
||||
@@ -21,7 +24,7 @@ npm run test:browser
|
||||
npm run release:artifact
|
||||
```
|
||||
|
||||
The last command creates deterministic `release/mail-tools-0.1.0.zip` and its
|
||||
The last command creates deterministic `release/mail-tools-0.2.0.zip` and its
|
||||
SHA-256 sidecar. The app uses relative assets and is tested beneath
|
||||
`/deep/nested/mail/`. It can run standalone or from add·ideas Toolbox.
|
||||
|
||||
@@ -29,17 +32,26 @@ SHA-256 sidecar. The app uses relative assets and is tested beneath
|
||||
|
||||
- source limit: 8 MiB; at most 2,000 headers, 500 MIME parts, nesting depth 20,
|
||||
and a conservative 16 MiB decoded-data budget;
|
||||
- folded headers, duplicate fields and RFC 2047 B/Q words;
|
||||
- folded headers, duplicate fields, RFC 2047 B/Q words, and RFC 2231 parameter
|
||||
continuations;
|
||||
- multipart and nested `message/rfc822`, Base64 and quoted-printable;
|
||||
- safe text previews, sanitized HTML, attachment inventory/download;
|
||||
- address/date and unverified SPF/DKIM/DMARC/ARC header diagnostics;
|
||||
- bounded mbox/mboxrd import and local Message-ID/References threading;
|
||||
- safe text previews, sanitized HTML with local CID images, attachment
|
||||
inventory/download;
|
||||
- address/date and SPF/DKIM/DMARC/ARC claim diagnostics;
|
||||
- RFC 6376 simple/relaxed RSA-SHA256 and RFC 8463 Ed25519-SHA256 DKIM
|
||||
verification against a public-key TXT record pasted by the user;
|
||||
- bounded line comparison and normalized EML plus top-level header redaction
|
||||
with a JSON report.
|
||||
|
||||
This is not a mail client, spam detector, forensic verifier or anonymity tool.
|
||||
It does not open mailbox containers, contact servers, decrypt S/MIME/OpenPGP,
|
||||
validate DKIM signatures, establish sender identity, scan malware, render CID
|
||||
resources, or guarantee round-trip byte identity. See
|
||||
It does not contact mail or DNS servers, decrypt S/MIME/OpenPGP, establish
|
||||
sender identity, scan malware, open maildir/PST/OST containers, or guarantee
|
||||
that the deliberately normalized canonical export is byte-identical. DKIM
|
||||
verification proves only that the preserved bytes match the pasted key record;
|
||||
the tool cannot establish whether that record is authentic or current. The
|
||||
untouched source octets remain available for inspection.
|
||||
See
|
||||
[`docs/PRIVACY-SECURITY.md`](docs/PRIVACY-SECURITY.md).
|
||||
|
||||
Licensed under GPL-3.0-or-later.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Corresponding source
|
||||
|
||||
The corresponding source for Mail Tools 0.1.0 is available at:
|
||||
The corresponding source for Mail Tools 0.2.0 is available at:
|
||||
|
||||
https://git.add-ideas.de/lotobo/mail-tools/src/tag/v0.1.0
|
||||
https://git.add-ideas.de/lotobo/mail-tools/src/tag/v0.2.0
|
||||
|
||||
Build with Node.js 22+, npm 11+, and the exact dependencies in
|
||||
`package-lock.json`. Run `npm ci && npm run release:artifact`.
|
||||
|
||||
+14
-4
@@ -1,11 +1,21 @@
|
||||
# Architecture
|
||||
|
||||
The React shell lazy-loads one workbench. `core/mime.ts` turns bounded source
|
||||
text into immutable-looking header and MIME-part records; parsing never creates
|
||||
DOM. Transfer decoding consumes a global byte budget. Diagnostics, comparison,
|
||||
bytes into immutable-looking header and MIME-part records; parsing never creates
|
||||
DOM. A one-byte internal syntax view preserves arbitrary octets, while every raw
|
||||
multipart entity records exact offsets into the retained source buffer.
|
||||
Transfer decoding consumes a global byte budget. Diagnostics, comparison,
|
||||
canonicalization and redaction are separate pure modules.
|
||||
|
||||
`core/mbox.ts` splits bounded mbox/mboxrd containers and derives a presentation
|
||||
tree from Message-ID, In-Reply-To and References. `core/dkim.ts` implements
|
||||
bounded RFC 6376 canonicalization and uses WebCrypto for RSA-SHA256 or
|
||||
Ed25519-SHA256 verification. The key record is explicit user input; the app has
|
||||
no DNS client and makes no network request.
|
||||
|
||||
Only the selected HTML text is passed through DOMPurify. It is then embedded in
|
||||
an iframe without sandbox capabilities and with an inner policy that denies all
|
||||
connections and active content. Attachment downloads are explicit Blob URLs
|
||||
with sanitized filenames. The service worker caches only same-origin app files.
|
||||
connections and active content. Explicit `cid:` image references can resolve to
|
||||
small safe image parts already present in the message; no external URI is
|
||||
retained. Attachment downloads are explicit Blob URLs with sanitized filenames.
|
||||
The service worker caches only same-origin app files.
|
||||
|
||||
@@ -5,9 +5,16 @@ memory. There is no storage, telemetry, analytics, remote-resource request or
|
||||
server integration. Closing/reloading the page clears the workbench.
|
||||
|
||||
Limits reduce accidental resource exhaustion but do not make Mail Tools a
|
||||
forensic parser. HTML loses links, styling and embedded resources by design.
|
||||
forensic parser. HTML loses links, styling and external embedded resources by
|
||||
design; only bounded safe image parts referenced by `cid:` can be embedded.
|
||||
Downloaded attachments remain untrusted. Authentication results and Received
|
||||
headers are self-asserted text; no DNS or cryptographic verification occurs.
|
||||
headers are self-asserted text.
|
||||
|
||||
The optional DKIM lab performs local cryptographic verification against a DNS
|
||||
TXT value pasted by the user. It never resolves DNS, so a passing result means
|
||||
only that the message bytes match that supplied key. It does not prove that the
|
||||
key is current, authoritative or obtained securely, and it does not turn other
|
||||
authentication headers into verified evidence.
|
||||
|
||||
Focused redaction removes a fixed set of top-level transport/authentication
|
||||
headers. It does not rewrite nested `message/rfc822` content and can leave names,
|
||||
|
||||
Generated
+20
-20
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"name": "mail-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"@add-ideas/toolbox-contract": "0.3.0",
|
||||
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||
"dompurify": "3.4.14",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
@@ -43,24 +43,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-contract": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
|
||||
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
|
||||
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-helpers": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz",
|
||||
"integrity": "sha512-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
|
||||
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
|
||||
"license": "GPL-3.0-or-later"
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz",
|
||||
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
|
||||
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18 <20",
|
||||
@@ -68,13 +68,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-testkit": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
|
||||
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
|
||||
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"toolbox-check": "dist/cli.js"
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Inspect, compare, redact and export email messages locally in the browser.",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Albrecht Degering",
|
||||
@@ -39,15 +39,15 @@
|
||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
||||
},
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"@add-ideas/toolbox-contract": "0.3.0",
|
||||
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||
"dompurify": "3.4.14",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
|
||||
+20
-2
@@ -15,7 +15,25 @@ export default defineConfig({
|
||||
timeout: 180_000,
|
||||
},
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||
{
|
||||
name: "chromium",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
{
|
||||
name: "mobile-chromium",
|
||||
testMatch: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Pixel 5"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 - 2026-09-02
|
||||
|
||||
- Preserved original message octets and MIME entity ranges, including RFC 2231
|
||||
continued parameters and byte-safe attachment downloads.
|
||||
- Added bounded mbox/mboxrd import, threading and search, safe packaged CID-image
|
||||
preview, and deeper MIME-tree redaction with explicit evidence.
|
||||
- Added local RSA-SHA256 and Ed25519-SHA256 DKIM verification against public-key
|
||||
DNS text pasted by the user.
|
||||
|
||||
## 0.1.0 - 2026-09-01
|
||||
|
||||
- Initial local-first EML/MIME inspection release.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
==============================================================================
|
||||
@add-ideas/toolbox-contract@0.2.3
|
||||
@add-ideas/toolbox-contract@0.3.0
|
||||
Declared licence: Apache-2.0
|
||||
==============================================================================
|
||||
--- LICENSE ---
|
||||
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
|
||||
|
||||
|
||||
==============================================================================
|
||||
@add-ideas/toolbox-helpers@0.1.0
|
||||
@add-ideas/toolbox-helpers@0.2.0
|
||||
Declared licence: GPL-3.0-or-later
|
||||
==============================================================================
|
||||
--- LICENSE ---
|
||||
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
|
||||
|
||||
|
||||
==============================================================================
|
||||
@add-ideas/toolbox-shell-react@0.2.3
|
||||
@add-ideas/toolbox-shell-react@0.3.0
|
||||
Declared licence: Apache-2.0
|
||||
==============================================================================
|
||||
--- LICENSE ---
|
||||
|
||||
+25
-13
@@ -1,14 +1,17 @@
|
||||
# Mail Tools
|
||||
|
||||
Mail Tools is a local-first browser workbench for inspecting `.eml` and MIME
|
||||
messages. Version 0.1.0 unfolds headers, decodes RFC 2047 words, builds a bounded
|
||||
multipart tree, decodes common transfer encodings, inventories attachments,
|
||||
compares text bodies, and creates canonical or focused redacted exports.
|
||||
Mail Tools is a local-first browser workbench for inspecting `.eml`, MIME and
|
||||
mbox files. It unfolds headers, decodes RFC 2047 words, builds a bounded
|
||||
multipart tree, threads mailbox messages, inventories attachments, compares
|
||||
text bodies, and creates canonical or focused redacted exports. File imports
|
||||
are parsed from their original octets, retain exact source byte ranges for raw
|
||||
MIME entities, and assemble RFC 2231 continued parameters.
|
||||
|
||||
HTML bodies are sanitized and displayed only in an opaque sandbox with an
|
||||
embedded `default-src 'none'` policy. Remote images, links, forms, scripts,
|
||||
styles, media, frames and active documents are removed. No message, address,
|
||||
attachment, URL or telemetry leaves the browser.
|
||||
styles, media, frames and active documents are removed. Referenced CID images
|
||||
of a small safe type can be embedded from the same message. No message,
|
||||
address, attachment, URL or telemetry leaves the browser.
|
||||
|
||||
## Development and release
|
||||
|
||||
@@ -21,7 +24,7 @@ npm run test:browser
|
||||
npm run release:artifact
|
||||
```
|
||||
|
||||
The last command creates deterministic `release/mail-tools-0.1.0.zip` and its
|
||||
The last command creates deterministic `release/mail-tools-0.2.0.zip` and its
|
||||
SHA-256 sidecar. The app uses relative assets and is tested beneath
|
||||
`/deep/nested/mail/`. It can run standalone or from add·ideas Toolbox.
|
||||
|
||||
@@ -29,17 +32,26 @@ SHA-256 sidecar. The app uses relative assets and is tested beneath
|
||||
|
||||
- source limit: 8 MiB; at most 2,000 headers, 500 MIME parts, nesting depth 20,
|
||||
and a conservative 16 MiB decoded-data budget;
|
||||
- folded headers, duplicate fields and RFC 2047 B/Q words;
|
||||
- folded headers, duplicate fields, RFC 2047 B/Q words, and RFC 2231 parameter
|
||||
continuations;
|
||||
- multipart and nested `message/rfc822`, Base64 and quoted-printable;
|
||||
- safe text previews, sanitized HTML, attachment inventory/download;
|
||||
- address/date and unverified SPF/DKIM/DMARC/ARC header diagnostics;
|
||||
- bounded mbox/mboxrd import and local Message-ID/References threading;
|
||||
- safe text previews, sanitized HTML with local CID images, attachment
|
||||
inventory/download;
|
||||
- address/date and SPF/DKIM/DMARC/ARC claim diagnostics;
|
||||
- RFC 6376 simple/relaxed RSA-SHA256 and RFC 8463 Ed25519-SHA256 DKIM
|
||||
verification against a public-key TXT record pasted by the user;
|
||||
- bounded line comparison and normalized EML plus top-level header redaction
|
||||
with a JSON report.
|
||||
|
||||
This is not a mail client, spam detector, forensic verifier or anonymity tool.
|
||||
It does not open mailbox containers, contact servers, decrypt S/MIME/OpenPGP,
|
||||
validate DKIM signatures, establish sender identity, scan malware, render CID
|
||||
resources, or guarantee round-trip byte identity. See
|
||||
It does not contact mail or DNS servers, decrypt S/MIME/OpenPGP, establish
|
||||
sender identity, scan malware, open maildir/PST/OST containers, or guarantee
|
||||
that the deliberately normalized canonical export is byte-identical. DKIM
|
||||
verification proves only that the preserved bytes match the pasted key record;
|
||||
the tool cannot establish whether that record is authentic or current. The
|
||||
untouched source octets remain available for inspection.
|
||||
See
|
||||
[`docs/PRIVACY-SECURITY.md`](docs/PRIVACY-SECURITY.md).
|
||||
|
||||
Licensed under GPL-3.0-or-later.
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
# Corresponding source
|
||||
|
||||
The corresponding source for Mail Tools 0.1.0 is available at:
|
||||
The corresponding source for Mail Tools 0.2.0 is available at:
|
||||
|
||||
https://git.add-ideas.de/lotobo/mail-tools/src/tag/v0.1.0
|
||||
https://git.add-ideas.de/lotobo/mail-tools/src/tag/v0.2.0
|
||||
|
||||
Build with Node.js 22+, npm 11+, and the exact dependencies in
|
||||
`package-lock.json`. Run `npm ci && npm run release:artifact`.
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
# Architecture
|
||||
|
||||
The React shell lazy-loads one workbench. `core/mime.ts` turns bounded source
|
||||
text into immutable-looking header and MIME-part records; parsing never creates
|
||||
DOM. Transfer decoding consumes a global byte budget. Diagnostics, comparison,
|
||||
bytes into immutable-looking header and MIME-part records; parsing never creates
|
||||
DOM. A one-byte internal syntax view preserves arbitrary octets, while every raw
|
||||
multipart entity records exact offsets into the retained source buffer.
|
||||
Transfer decoding consumes a global byte budget. Diagnostics, comparison,
|
||||
canonicalization and redaction are separate pure modules.
|
||||
|
||||
`core/mbox.ts` splits bounded mbox/mboxrd containers and derives a presentation
|
||||
tree from Message-ID, In-Reply-To and References. `core/dkim.ts` implements
|
||||
bounded RFC 6376 canonicalization and uses WebCrypto for RSA-SHA256 or
|
||||
Ed25519-SHA256 verification. The key record is explicit user input; the app has
|
||||
no DNS client and makes no network request.
|
||||
|
||||
Only the selected HTML text is passed through DOMPurify. It is then embedded in
|
||||
an iframe without sandbox capabilities and with an inner policy that denies all
|
||||
connections and active content. Attachment downloads are explicit Blob URLs
|
||||
with sanitized filenames. The service worker caches only same-origin app files.
|
||||
connections and active content. Explicit `cid:` image references can resolve to
|
||||
small safe image parts already present in the message; no external URI is
|
||||
retained. Attachment downloads are explicit Blob URLs with sanitized filenames.
|
||||
The service worker caches only same-origin app files.
|
||||
|
||||
@@ -5,9 +5,16 @@ memory. There is no storage, telemetry, analytics, remote-resource request or
|
||||
server integration. Closing/reloading the page clears the workbench.
|
||||
|
||||
Limits reduce accidental resource exhaustion but do not make Mail Tools a
|
||||
forensic parser. HTML loses links, styling and embedded resources by design.
|
||||
forensic parser. HTML loses links, styling and external embedded resources by
|
||||
design; only bounded safe image parts referenced by `cid:` can be embedded.
|
||||
Downloaded attachments remain untrusted. Authentication results and Received
|
||||
headers are self-asserted text; no DNS or cryptographic verification occurs.
|
||||
headers are self-asserted text.
|
||||
|
||||
The optional DKIM lab performs local cryptographic verification against a DNS
|
||||
TXT value pasted by the user. It never resolves DNS, so a passing result means
|
||||
only that the message bytes match that supplied key. It does not prove that the
|
||||
key is current, authoritative or obtained securely, and it does not turn other
|
||||
authentication headers into verified evidence.
|
||||
|
||||
Focused redaction removes a fixed set of top-level transport/authentication
|
||||
headers. It does not rewrite nested `message/rfc822` content and can leave names,
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
const CACHE_PREFIX = "mail-tools-shell-";
|
||||
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
|
||||
const CACHE_NAME = CACHE_PREFIX + "0.2.0";
|
||||
const CORE = [
|
||||
"./",
|
||||
"./manifest.webmanifest",
|
||||
|
||||
+15
-3
@@ -3,12 +3,12 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.mail-tools",
|
||||
"name": "Mail Tools",
|
||||
"version": "0.1.0",
|
||||
"description": "Inspect and redact email messages locally.",
|
||||
"version": "0.2.0",
|
||||
"description": "Inspect email and mailbox files locally.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["documents", "developer", "privacy"],
|
||||
"tags": ["email", "eml", "mime", "headers", "attachments"],
|
||||
"tags": ["email", "eml", "mbox", "mime", "dkim", "attachments"],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
@@ -21,6 +21,18 @@
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"io": {
|
||||
"accepts": [
|
||||
{ "mediaType": "message/rfc822", "extensions": [".eml"] },
|
||||
{ "mediaType": "application/mbox", "extensions": [".mbox", ".mbx"] },
|
||||
{ "mediaType": "text/plain", "extensions": [".txt"] }
|
||||
],
|
||||
"produces": [
|
||||
{ "mediaType": "message/rfc822", "extensions": [".eml"] },
|
||||
{ "mediaType": "application/json", "extensions": [".json"] }
|
||||
]
|
||||
},
|
||||
"capabilities": { "required": [], "optional": ["web-crypto"] },
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
|
||||
@@ -32,16 +32,20 @@ export function HelpDialog({
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
Open EML/MIME source, inspect its header and multipart structure,
|
||||
compare body parts, save attachments, and make focused redacted copies.
|
||||
Open EML/MIME source or a bounded mbox, inspect its header and multipart
|
||||
structure, compare body parts, save attachments, and make focused
|
||||
redacted copies.
|
||||
</p>
|
||||
<p>
|
||||
Parsing is bounded and entirely local. HTML is sanitized and shown in an
|
||||
opaque sandbox whose own policy blocks every network request.
|
||||
</p>
|
||||
<p>
|
||||
Authentication headers are unverified claims. Redaction removes selected
|
||||
transport/authentication headers only; it is not an anonymity guarantee.
|
||||
Authentication headers remain claims. The DKIM lab can verify preserved
|
||||
bytes against a public-key TXT record you paste, but deliberately makes
|
||||
no DNS request and cannot authenticate that key. Redaction removes
|
||||
selected transport/authentication headers only; it is not an anonymity
|
||||
guarantee.
|
||||
</p>
|
||||
</dialog>
|
||||
);
|
||||
|
||||
+366
-23
@@ -1,9 +1,23 @@
|
||||
import { useMemo, useState, type ChangeEvent } from "react";
|
||||
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
||||
import { useMemo, useState, type ChangeEvent, type CSSProperties } from "react";
|
||||
import {
|
||||
sanitizeDownloadFilename,
|
||||
triggerBlobDownload,
|
||||
} from "@add-ideas/toolbox-helpers";
|
||||
import { compareBodies } from "../core/compare";
|
||||
import {
|
||||
inspectDkimSignatures,
|
||||
verifyDkimSignature,
|
||||
type DkimVerificationResult,
|
||||
} from "../core/dkim";
|
||||
import { diagnoseMessage } from "../core/diagnostics";
|
||||
import { canonicalMessage, redactMessage } from "../core/export";
|
||||
import { attachments, parseMessage, walkParts } from "../core/mime";
|
||||
import { canonicalMessage, deepRedactMessage } from "../core/export";
|
||||
import {
|
||||
attachments,
|
||||
inlineCidResources,
|
||||
parseMessage,
|
||||
walkParts,
|
||||
} from "../core/mime";
|
||||
import { parseMbox, searchMailbox, type ParsedMailbox } from "../core/mbox";
|
||||
import { sanitizeMailHtml } from "../core/sanitize";
|
||||
import type { MimePart, ParsedMessage } from "../core/types";
|
||||
|
||||
@@ -37,7 +51,8 @@ Content-Transfer-Encoding: base64
|
||||
QXR0YWNobWVudCBwcmV2aWV3Lg==
|
||||
--outer--`;
|
||||
|
||||
type View = "structure" | "bodies" | "attachments" | "diagnostics" | "export";
|
||||
type View =
|
||||
"mailbox" | "structure" | "bodies" | "attachments" | "diagnostics" | "export";
|
||||
|
||||
function PartTree({ part }: { part: MimePart }) {
|
||||
return (
|
||||
@@ -70,12 +85,30 @@ export function Workbench() {
|
||||
);
|
||||
const [error, setError] = useState<string>();
|
||||
const [view, setView] = useState<View>("structure");
|
||||
const [mailbox, setMailbox] = useState<ParsedMailbox>();
|
||||
const [mailboxFilename, setMailboxFilename] = useState("");
|
||||
const [mailboxQuery, setMailboxQuery] = useState("");
|
||||
const [dkimIndex, setDkimIndex] = useState(0);
|
||||
const [dkimKeyRecord, setDkimKeyRecord] = useState("");
|
||||
const [dkimResult, setDkimResult] = useState<DkimVerificationResult>();
|
||||
const [dkimBusy, setDkimBusy] = useState(false);
|
||||
const [redactTextBodies, setRedactTextBodies] = useState(false);
|
||||
const [removeAttachmentPayloads, setRemoveAttachmentPayloads] =
|
||||
useState(false);
|
||||
const textParts = useMemo(
|
||||
() => walkParts(message.root).filter((part) => part.text !== undefined),
|
||||
[message],
|
||||
);
|
||||
const files = useMemo(() => attachments(message.root), [message]);
|
||||
const diagnostics = useMemo(() => diagnoseMessage(message), [message]);
|
||||
const dkimSignatures = useMemo(
|
||||
() => inspectDkimSignatures(message),
|
||||
[message],
|
||||
);
|
||||
const mailboxEntries = useMemo(
|
||||
() => (mailbox ? searchMailbox(mailbox, mailboxQuery) : []),
|
||||
[mailbox, mailboxQuery],
|
||||
);
|
||||
const [leftId, setLeftId] = useState("1.1");
|
||||
const [rightId, setRightId] = useState("1.2");
|
||||
const left = textParts.find((part) => part.id === leftId) ?? textParts[0];
|
||||
@@ -90,16 +123,28 @@ export function Workbench() {
|
||||
const htmlPreview = useMemo(
|
||||
() =>
|
||||
left?.mediaType === "text/html"
|
||||
? sanitizeMailHtml(left.text ?? "")
|
||||
? sanitizeMailHtml(left.text ?? "", inlineCidResources(message.root))
|
||||
: undefined,
|
||||
[left],
|
||||
[left, message],
|
||||
);
|
||||
const redacted = useMemo(
|
||||
() =>
|
||||
deepRedactMessage(message, {
|
||||
redactTextBodies,
|
||||
removeAttachmentPayloads,
|
||||
}),
|
||||
[message, redactTextBodies, removeAttachmentPayloads],
|
||||
);
|
||||
const redacted = useMemo(() => redactMessage(message), [message]);
|
||||
|
||||
const inspect = () => {
|
||||
try {
|
||||
const parsed = parseMessage(source);
|
||||
setMessage(parsed);
|
||||
setMailbox(undefined);
|
||||
setMailboxFilename("");
|
||||
setMailboxQuery("");
|
||||
setDkimIndex(0);
|
||||
setDkimResult(undefined);
|
||||
setError(undefined);
|
||||
} catch (reason) {
|
||||
setError(
|
||||
@@ -113,16 +158,43 @@ export function Workbench() {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (file.size > 8 * 1024 * 1024) {
|
||||
setError("File exceeds the 8 MiB source limit.");
|
||||
const isMbox = /\.(?:mbox|mbx)$/iu.test(file.name);
|
||||
const limit = isMbox ? 64 * 1024 * 1024 : 8 * 1024 * 1024;
|
||||
if (file.size > limit) {
|
||||
setError(
|
||||
isMbox
|
||||
? "Mailbox exceeds the 64 MiB local limit."
|
||||
: "File exceeds the 8 MiB source limit.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = parseMessage(text);
|
||||
setSource(text);
|
||||
setFilename(file.name);
|
||||
setMessage(parsed);
|
||||
const bytes = await file.arrayBuffer();
|
||||
if (isMbox) {
|
||||
const parsedMailbox = parseMbox(bytes);
|
||||
const first = parsedMailbox.entries[0]!;
|
||||
setMailbox(parsedMailbox);
|
||||
setMailboxFilename(file.name);
|
||||
setMailboxQuery("");
|
||||
setMessage(first.message);
|
||||
setSource(
|
||||
new TextDecoder("utf-8", { fatal: false }).decode(
|
||||
first.message.rawBytes,
|
||||
),
|
||||
);
|
||||
setFilename(`${file.name} · message 1`);
|
||||
setView("mailbox");
|
||||
} else {
|
||||
const parsed = parseMessage(bytes);
|
||||
setMailbox(undefined);
|
||||
setMailboxFilename("");
|
||||
setMailboxQuery("");
|
||||
setSource(new TextDecoder("utf-8", { fatal: false }).decode(bytes));
|
||||
setFilename(file.name);
|
||||
setMessage(parsed);
|
||||
}
|
||||
setDkimIndex(0);
|
||||
setDkimResult(undefined);
|
||||
setError(undefined);
|
||||
} catch (reason) {
|
||||
setError(
|
||||
@@ -156,11 +228,11 @@ export function Workbench() {
|
||||
</p>
|
||||
</div>
|
||||
<label className="button file-button">
|
||||
Open .eml
|
||||
Open .eml / mbox
|
||||
<input
|
||||
data-testid="mail-file-input"
|
||||
type="file"
|
||||
accept=".eml,message/rfc822,text/plain"
|
||||
accept=".eml,.mbox,.mbx,message/rfc822,application/mbox,text/plain"
|
||||
onChange={(event) => void openFile(event)}
|
||||
/>
|
||||
</label>
|
||||
@@ -185,7 +257,10 @@ export function Workbench() {
|
||||
</p>
|
||||
) : (
|
||||
<p className="success" role="status">
|
||||
Parsed {walkParts(message.root).length} MIME parts; {files.length}{" "}
|
||||
{mailbox
|
||||
? `Parsed ${mailbox.entries.length.toLocaleString()} mailbox messages; selected message has `
|
||||
: "Parsed "}
|
||||
{walkParts(message.root).length} MIME parts; {files.length}{" "}
|
||||
attachment{files.length === 1 ? "" : "s"}.
|
||||
</p>
|
||||
)}
|
||||
@@ -194,6 +269,7 @@ export function Workbench() {
|
||||
<nav className="tabs" aria-label="Mail workspaces">
|
||||
{(
|
||||
[
|
||||
...(mailbox ? (["mailbox"] as const) : []),
|
||||
"structure",
|
||||
"bodies",
|
||||
"attachments",
|
||||
@@ -212,6 +288,114 @@ export function Workbench() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{view === "mailbox" && mailbox ? (
|
||||
<section className="workspace-grid">
|
||||
<article className="panel mailbox-list">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Mailbox threads</h2>
|
||||
<p>
|
||||
{mailboxFilename} · {mailbox.bytes.toLocaleString()} bytes
|
||||
</p>
|
||||
</div>
|
||||
<span className="count-pill">
|
||||
{mailboxEntries.length} / {mailbox.entries.length}
|
||||
</span>
|
||||
</div>
|
||||
<label className="mailbox-search">
|
||||
Search headers, filenames and bounded text bodies
|
||||
<input
|
||||
type="search"
|
||||
aria-label="Search mailbox"
|
||||
value={mailboxQuery}
|
||||
onChange={(event) => setMailboxQuery(event.target.value)}
|
||||
placeholder="subject, sender, phrase…"
|
||||
/>
|
||||
</label>
|
||||
<ol>
|
||||
{mailboxEntries.map((entry) => (
|
||||
<li
|
||||
key={entry.index}
|
||||
style={{ "--thread-depth": entry.depth } as CSSProperties}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={entry.message === message}
|
||||
onClick={() => {
|
||||
setMessage(entry.message);
|
||||
setSource(
|
||||
new TextDecoder("utf-8", { fatal: false }).decode(
|
||||
entry.message.rawBytes,
|
||||
),
|
||||
);
|
||||
setFilename(
|
||||
`${mailboxFilename} · message ${entry.index + 1}`,
|
||||
);
|
||||
setDkimIndex(0);
|
||||
setDkimResult(undefined);
|
||||
}}
|
||||
>
|
||||
<strong>{entry.subject}</strong>
|
||||
<span>{entry.from}</span>
|
||||
<small>{entry.date}</small>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{mailbox.warnings.map((warning) => (
|
||||
<p className="warning" key={warning}>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</article>
|
||||
<article className="panel">
|
||||
<h2>Selected message</h2>
|
||||
<dl className="facts">
|
||||
<div>
|
||||
<dt>Subject</dt>
|
||||
<dd>
|
||||
{mailbox.entries.find((entry) => entry.message === message)
|
||||
?.subject ?? "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>MIME parts</dt>
|
||||
<dd>{walkParts(message.root).length}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Attachments</dt>
|
||||
<dd>{files.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="disclosure">
|
||||
Threading uses only Message-ID, In-Reply-To and References from
|
||||
messages already present in this local file. Envelope separators
|
||||
and mailbox contents are never contacted or executed.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const entry = mailbox.entries.find(
|
||||
(candidate) => candidate.message === message,
|
||||
);
|
||||
const base = sanitizeDownloadFilename(
|
||||
entry?.subject || `message-${(entry?.index ?? 0) + 1}`,
|
||||
"message",
|
||||
);
|
||||
triggerBlobDownload(
|
||||
new Blob([message.rawBytes as BlobPart], {
|
||||
type: "message/rfc822",
|
||||
}),
|
||||
`${base.replace(/\.eml$/iu, "")}.eml`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Download selected original EML
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{view === "structure" ? (
|
||||
<section className="workspace-grid">
|
||||
<article className="panel">
|
||||
@@ -386,7 +570,8 @@ export function Workbench() {
|
||||
<h2>Header diagnostics</h2>
|
||||
<p>
|
||||
Reported SPF, DKIM, DMARC and ARC results are displayed as
|
||||
claims, never re-verified.
|
||||
claims. The optional DKIM lab verifies a signature only against
|
||||
a public-key record you paste locally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -399,6 +584,140 @@ export function Workbench() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<section className="dkim-lab" aria-labelledby="dkim-lab-title">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h3 id="dkim-lab-title">DKIM verification lab</h3>
|
||||
<p>
|
||||
No DNS request is made. Copy the TXT value for the displayed
|
||||
selector from a source you trust, then verify the preserved
|
||||
message bytes.
|
||||
</p>
|
||||
</div>
|
||||
<span className="count-pill">{dkimSignatures.length}</span>
|
||||
</div>
|
||||
{dkimSignatures.length ? (
|
||||
<>
|
||||
<label>
|
||||
Signature
|
||||
<select
|
||||
aria-label="DKIM signature"
|
||||
value={dkimIndex}
|
||||
onChange={(event) => {
|
||||
setDkimIndex(Number(event.target.value));
|
||||
setDkimResult(undefined);
|
||||
}}
|
||||
>
|
||||
{dkimSignatures.map((signature) => (
|
||||
<option key={signature.index} value={signature.index}>
|
||||
{signature.index + 1} · {signature.domain || "invalid"}
|
||||
{signature.selector ? ` / ${signature.selector}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{dkimSignatures[dkimIndex] ? (
|
||||
<dl className="facts dkim-facts">
|
||||
<div>
|
||||
<dt>DNS query name</dt>
|
||||
<dd>
|
||||
<code>
|
||||
{dkimSignatures[dkimIndex]!.queryName || "Invalid"}
|
||||
</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Algorithm</dt>
|
||||
<dd>{dkimSignatures[dkimIndex]!.algorithm || "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Canonicalization</dt>
|
||||
<dd>
|
||||
{dkimSignatures[dkimIndex]!.headerCanonicalization ||
|
||||
"—"}
|
||||
/
|
||||
{dkimSignatures[dkimIndex]!.bodyCanonicalization || "—"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
{dkimSignatures[dkimIndex]?.problems.map((problem) => (
|
||||
<p className="warning" key={problem}>
|
||||
{problem}
|
||||
</p>
|
||||
))}
|
||||
<label>
|
||||
DKIM DNS TXT record
|
||||
<textarea
|
||||
aria-label="DKIM DNS TXT record"
|
||||
rows={4}
|
||||
spellCheck={false}
|
||||
placeholder="v=DKIM1; k=rsa; p=…"
|
||||
value={dkimKeyRecord}
|
||||
onChange={(event) => {
|
||||
setDkimKeyRecord(event.target.value);
|
||||
setDkimResult(undefined);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="action-row">
|
||||
<button
|
||||
className="primary-button"
|
||||
type="button"
|
||||
disabled={
|
||||
dkimBusy ||
|
||||
!dkimKeyRecord.trim() ||
|
||||
!dkimSignatures[dkimIndex]?.supported
|
||||
}
|
||||
onClick={() => {
|
||||
setDkimBusy(true);
|
||||
void verifyDkimSignature(
|
||||
message,
|
||||
dkimIndex,
|
||||
dkimKeyRecord,
|
||||
)
|
||||
.then(setDkimResult, (reason: unknown) => {
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "DKIM verification failed.",
|
||||
);
|
||||
})
|
||||
.finally(() => setDkimBusy(false));
|
||||
}}
|
||||
>
|
||||
{dkimBusy ? "Verifying…" : "Verify locally"}
|
||||
</button>
|
||||
<span className="muted">
|
||||
SHA-256 · RSA or Ed25519 · no key lookup
|
||||
</span>
|
||||
</div>
|
||||
{dkimResult ? (
|
||||
<div
|
||||
className={`verification-result ${dkimResult.status}`}
|
||||
role="status"
|
||||
>
|
||||
<strong>{dkimResult.status.toUpperCase()}</strong>
|
||||
<span>
|
||||
Body hash: {dkimResult.bodyHash} · Header signature:{" "}
|
||||
{dkimResult.signature}
|
||||
</span>
|
||||
{dkimResult.unsignedBodyBytes ? (
|
||||
<span>
|
||||
{dkimResult.unsignedBodyBytes.toLocaleString()} body
|
||||
bytes are outside the l= signature limit.
|
||||
</span>
|
||||
) : null}
|
||||
{dkimResult.details.map((detail) => (
|
||||
<span key={detail}>{detail}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="empty">This message has no DKIM-Signature field.</p>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -424,12 +743,36 @@ export function Workbench() {
|
||||
</button>
|
||||
</article>
|
||||
<article className="panel">
|
||||
<h2>Focused redaction</h2>
|
||||
<h2>Redaction workspace</h2>
|
||||
<p>
|
||||
Removes {redacted.removed.length} top-level
|
||||
transport/authentication header fields. It does not scan nested
|
||||
messages, bodies or attachments for personal data.
|
||||
Removes {redacted.removedHeaders.length} selected header field
|
||||
{redacted.removedHeaders.length === 1 ? "" : "s"} throughout the
|
||||
MIME tree. Optional destructive policies can replace decoded text
|
||||
bodies and attachment payloads.
|
||||
</p>
|
||||
<div className="redaction-options">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={redactTextBodies}
|
||||
onChange={(event) =>
|
||||
setRedactTextBodies(event.target.checked)
|
||||
}
|
||||
/>
|
||||
Replace all text bodies ({redacted.redactedTextParts.length})
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removeAttachmentPayloads}
|
||||
onChange={(event) =>
|
||||
setRemoveAttachmentPayloads(event.target.checked)
|
||||
}
|
||||
/>
|
||||
Replace attachment payloads (
|
||||
{redacted.removedAttachments.length})
|
||||
</label>
|
||||
</div>
|
||||
<div className="action-row">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
import { base64ToBytes, bytesToBase64 } from "@add-ideas/toolbox-helpers";
|
||||
import type { ParsedMessage } from "./types";
|
||||
|
||||
type Canonicalization = "simple" | "relaxed";
|
||||
|
||||
interface RawHeader {
|
||||
name: string;
|
||||
lowerName: string;
|
||||
raw: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface DkimSignatureInspection {
|
||||
index: number;
|
||||
domain: string;
|
||||
selector: string;
|
||||
queryName: string;
|
||||
algorithm: string;
|
||||
headerCanonicalization: string;
|
||||
bodyCanonicalization: string;
|
||||
signedHeaders: string[];
|
||||
bodyLength?: number;
|
||||
timestamp?: number;
|
||||
expires?: number;
|
||||
supported: boolean;
|
||||
problems: string[];
|
||||
}
|
||||
|
||||
export interface DkimVerificationResult extends DkimSignatureInspection {
|
||||
bodyHash: "pass" | "fail" | "error";
|
||||
signature: "pass" | "fail" | "not-checked" | "error";
|
||||
status: "pass" | "fail" | "permerror" | "expired";
|
||||
computedBodyHash?: string;
|
||||
unsignedBodyBytes: number;
|
||||
details: string[];
|
||||
}
|
||||
|
||||
export interface PreparedDkimVerification {
|
||||
inspection: DkimSignatureInspection;
|
||||
bodyBytes: Uint8Array<ArrayBuffer>;
|
||||
headerBytes: Uint8Array<ArrayBuffer>;
|
||||
computedBodyHash: string;
|
||||
claimedBodyHash: string;
|
||||
signatureBytes: Uint8Array<ArrayBuffer>;
|
||||
unsignedBodyBytes: number;
|
||||
}
|
||||
|
||||
function binaryBytes(value: string): Uint8Array<ArrayBuffer> {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let index = 0; index < value.length; index += 1)
|
||||
bytes[index] = value.charCodeAt(index) & 0xff;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function parseRawHeaders(source: string): RawHeader[] {
|
||||
const lines = source.match(/[^\r\n]*(?:\r\n|\r|\n|$)/gu) ?? [];
|
||||
const fields: RawHeader[] = [];
|
||||
let current = "";
|
||||
const finish = () => {
|
||||
if (!current) return;
|
||||
const colon = current.indexOf(":");
|
||||
if (colon <= 0) throw new SyntaxError("Malformed raw header field.");
|
||||
const name = current.slice(0, colon);
|
||||
fields.push({
|
||||
name,
|
||||
lowerName: name.toLowerCase(),
|
||||
raw: current,
|
||||
value: current.slice(colon + 1).replace(/(?:\r\n|\r|\n)$/u, ""),
|
||||
});
|
||||
current = "";
|
||||
};
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
if (/^[ \t]/u.test(line) && current) current += line;
|
||||
else {
|
||||
finish();
|
||||
current = line;
|
||||
}
|
||||
}
|
||||
finish();
|
||||
return fields;
|
||||
}
|
||||
|
||||
function tagList(value: string, label: string): Map<string, string> {
|
||||
const unfolded = value.replace(/(?:\r\n|\r|\n)[ \t]+/gu, " ");
|
||||
const output = new Map<string, string>();
|
||||
for (const chunk of unfolded.split(";")) {
|
||||
if (!chunk.trim()) continue;
|
||||
const equals = chunk.indexOf("=");
|
||||
if (equals <= 0)
|
||||
throw new SyntaxError(`${label} contains a malformed tag.`);
|
||||
const name = chunk.slice(0, equals).trim().toLowerCase();
|
||||
if (!/^[a-z][a-z0-9_]*$/u.test(name))
|
||||
throw new SyntaxError(`${label} contains invalid tag ${name}.`);
|
||||
if (output.has(name))
|
||||
throw new SyntaxError(`${label} repeats tag ${name}.`);
|
||||
output.set(name, chunk.slice(equals + 1).trim());
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function positiveInteger(
|
||||
value: string | undefined,
|
||||
label: string,
|
||||
): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!/^(?:0|[1-9]\d{0,15})$/u.test(value))
|
||||
throw new SyntaxError(`${label} must be a bounded non-negative integer.`);
|
||||
const number = Number(value);
|
||||
if (!Number.isSafeInteger(number))
|
||||
throw new RangeError(`${label} exceeds the safe integer range.`);
|
||||
return number;
|
||||
}
|
||||
|
||||
function canonicalizations(
|
||||
value: string | undefined,
|
||||
): [Canonicalization, Canonicalization] {
|
||||
const [header = "simple", body = "simple"] = (value ?? "simple/simple")
|
||||
.toLowerCase()
|
||||
.split("/", 2);
|
||||
if (!(["simple", "relaxed"] as string[]).includes(header))
|
||||
throw new SyntaxError(
|
||||
`Unsupported DKIM header canonicalization ${header}.`,
|
||||
);
|
||||
if (!(["simple", "relaxed"] as string[]).includes(body))
|
||||
throw new SyntaxError(`Unsupported DKIM body canonicalization ${body}.`);
|
||||
return [header as Canonicalization, body as Canonicalization];
|
||||
}
|
||||
|
||||
function canonicalBody(source: string, mode: Canonicalization): string {
|
||||
const lines = source.replace(/\r\n|\r/gu, "\n").split("\n");
|
||||
if (mode === "relaxed")
|
||||
for (let index = 0; index < lines.length; index += 1)
|
||||
lines[index] = lines[index]!.replace(/[ \t]+/gu, " ").replace(
|
||||
/[ \t]+$/u,
|
||||
"",
|
||||
);
|
||||
while (lines.length && lines.at(-1) === "") lines.pop();
|
||||
return `${lines.join("\r\n")}\r\n`;
|
||||
}
|
||||
|
||||
function withoutSignatureValue(raw: string): string {
|
||||
return raw.replace(
|
||||
/([;\t \r\n]b[\t \r\n]*=[\t \r\n]*)(?:[A-Za-z0-9+/=][\t \r\n]*)*(?=;|$)/iu,
|
||||
"$1",
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalHeader(raw: string, mode: Canonicalization): string {
|
||||
const normalized = raw.replace(/\r\n|\r|\n/gu, "\r\n").replace(/\r\n$/u, "");
|
||||
if (mode === "simple") return `${normalized}\r\n`;
|
||||
const unfolded = normalized.replace(/\r\n[ \t]+/gu, " ");
|
||||
const colon = unfolded.indexOf(":");
|
||||
if (colon <= 0)
|
||||
throw new SyntaxError("Cannot canonicalize malformed header.");
|
||||
const name = unfolded.slice(0, colon).toLowerCase();
|
||||
const value = unfolded
|
||||
.slice(colon + 1)
|
||||
.replace(/[ \t]+/gu, " ")
|
||||
.replace(/^[ \t]+|[ \t]+$/gu, "");
|
||||
return `${name}:${value}\r\n`;
|
||||
}
|
||||
|
||||
function signatureFields(message: ParsedMessage): RawHeader[] {
|
||||
return parseRawHeaders(message.headerSource).filter(
|
||||
(field) => field.lowerName === "dkim-signature",
|
||||
);
|
||||
}
|
||||
|
||||
function inspectionFor(
|
||||
field: RawHeader,
|
||||
index: number,
|
||||
): DkimSignatureInspection {
|
||||
const problems: string[] = [];
|
||||
let tags: Map<string, string>;
|
||||
try {
|
||||
tags = tagList(field.value, "DKIM-Signature");
|
||||
} catch (reason) {
|
||||
return {
|
||||
index,
|
||||
domain: "",
|
||||
selector: "",
|
||||
queryName: "",
|
||||
algorithm: "",
|
||||
headerCanonicalization: "",
|
||||
bodyCanonicalization: "",
|
||||
signedHeaders: [],
|
||||
supported: false,
|
||||
problems: [
|
||||
reason instanceof Error ? reason.message : "Malformed signature.",
|
||||
],
|
||||
};
|
||||
}
|
||||
const domain = tags.get("d") ?? "";
|
||||
const selector = tags.get("s") ?? "";
|
||||
const algorithm = (tags.get("a") ?? "").toLowerCase();
|
||||
let headerCanonicalization = "",
|
||||
bodyCanonicalization = "";
|
||||
try {
|
||||
[headerCanonicalization, bodyCanonicalization] = canonicalizations(
|
||||
tags.get("c"),
|
||||
);
|
||||
} catch (reason) {
|
||||
problems.push(
|
||||
reason instanceof Error ? reason.message : "Invalid canonicalization.",
|
||||
);
|
||||
}
|
||||
const signedHeaders = (tags.get("h") ?? "")
|
||||
.split(":")
|
||||
.map((name) => name.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
for (const required of ["v", "a", "d", "s", "h", "bh", "b"])
|
||||
if (!tags.has(required))
|
||||
problems.push(`Required tag ${required}= is missing.`);
|
||||
if (tags.get("v") !== "1")
|
||||
problems.push("Only DKIM-Signature v=1 is supported.");
|
||||
if (!/^[A-Za-z0-9._-]{1,63}$/u.test(selector))
|
||||
problems.push("Selector is empty or invalid.");
|
||||
if (
|
||||
!/^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9-]{2,63}$/u.test(
|
||||
domain,
|
||||
)
|
||||
)
|
||||
problems.push("Signing domain is empty or invalid.");
|
||||
if (!signedHeaders.includes("from"))
|
||||
problems.push("Signed header list omits From.");
|
||||
if (!["rsa-sha256", "ed25519-sha256"].includes(algorithm))
|
||||
problems.push(
|
||||
`Unsupported or obsolete signing algorithm ${algorithm || "(missing)"}.`,
|
||||
);
|
||||
let bodyLength: number | undefined,
|
||||
timestamp: number | undefined,
|
||||
expires: number | undefined;
|
||||
try {
|
||||
bodyLength = positiveInteger(tags.get("l"), "DKIM l=");
|
||||
timestamp = positiveInteger(tags.get("t"), "DKIM t=");
|
||||
expires = positiveInteger(tags.get("x"), "DKIM x=");
|
||||
} catch (reason) {
|
||||
problems.push(
|
||||
reason instanceof Error ? reason.message : "Invalid numeric tag.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
index,
|
||||
domain,
|
||||
selector,
|
||||
queryName: selector && domain ? `${selector}._domainkey.${domain}` : "",
|
||||
algorithm,
|
||||
headerCanonicalization,
|
||||
bodyCanonicalization,
|
||||
signedHeaders,
|
||||
bodyLength,
|
||||
timestamp,
|
||||
expires,
|
||||
supported: problems.length === 0,
|
||||
problems,
|
||||
};
|
||||
}
|
||||
|
||||
export function inspectDkimSignatures(
|
||||
message: ParsedMessage,
|
||||
): DkimSignatureInspection[] {
|
||||
return signatureFields(message).map(inspectionFor);
|
||||
}
|
||||
|
||||
export async function prepareDkimVerification(
|
||||
message: ParsedMessage,
|
||||
signatureIndex = 0,
|
||||
): Promise<PreparedDkimVerification> {
|
||||
const fields = parseRawHeaders(message.headerSource);
|
||||
const signatures = fields.filter(
|
||||
(field) => field.lowerName === "dkim-signature",
|
||||
);
|
||||
const signatureField = signatures[signatureIndex];
|
||||
if (!signatureField)
|
||||
throw new RangeError(
|
||||
`DKIM signature ${signatureIndex + 1} does not exist.`,
|
||||
);
|
||||
const inspection = inspectionFor(signatureField, signatureIndex);
|
||||
if (!inspection.supported) throw new TypeError(inspection.problems.join(" "));
|
||||
const tags = tagList(signatureField.value, "DKIM-Signature");
|
||||
const [, bodyMode] = canonicalizations(tags.get("c"));
|
||||
const canonical = binaryBytes(
|
||||
canonicalBody(message.raw.slice(message.bodyStart), bodyMode),
|
||||
);
|
||||
const requestedLength = inspection.bodyLength ?? canonical.byteLength;
|
||||
if (requestedLength > canonical.byteLength)
|
||||
throw new RangeError(
|
||||
`DKIM l=${requestedLength} exceeds the ${canonical.byteLength}-byte canonical body.`,
|
||||
);
|
||||
const bodyBytes = canonical.slice(0, requestedLength);
|
||||
const bodyDigest = new Uint8Array(
|
||||
await crypto.subtle.digest("SHA-256", bodyBytes),
|
||||
);
|
||||
const computedBodyHash = bytesToBase64(bodyDigest);
|
||||
|
||||
const selected: string[] = [];
|
||||
const usage = new Map<string, number>();
|
||||
for (const name of inspection.signedHeaders) {
|
||||
const alreadyUsed = usage.get(name) ?? 0;
|
||||
let seen = 0,
|
||||
found: RawHeader | undefined;
|
||||
for (let index = fields.length - 1; index >= 0; index -= 1) {
|
||||
if (fields[index]!.lowerName !== name) continue;
|
||||
if (seen === alreadyUsed) {
|
||||
found = fields[index];
|
||||
break;
|
||||
}
|
||||
seen += 1;
|
||||
}
|
||||
usage.set(name, alreadyUsed + 1);
|
||||
if (found)
|
||||
selected.push(
|
||||
canonicalHeader(
|
||||
found.raw,
|
||||
inspection.headerCanonicalization as Canonicalization,
|
||||
),
|
||||
);
|
||||
}
|
||||
selected.push(
|
||||
canonicalHeader(
|
||||
withoutSignatureValue(signatureField.raw),
|
||||
inspection.headerCanonicalization as Canonicalization,
|
||||
),
|
||||
);
|
||||
return {
|
||||
inspection,
|
||||
bodyBytes,
|
||||
headerBytes: binaryBytes(selected.join("")),
|
||||
computedBodyHash,
|
||||
claimedBodyHash: (tags.get("bh") ?? "").replace(/[ \t\r\n]/gu, ""),
|
||||
signatureBytes: base64ToBytes(
|
||||
(tags.get("b") ?? "").replace(/[ \t\r\n]/gu, ""),
|
||||
{ maxOutputBytes: 1024 * 1024 },
|
||||
),
|
||||
unsignedBodyBytes: canonical.byteLength - requestedLength,
|
||||
};
|
||||
}
|
||||
|
||||
function domainOfIdentity(identity: string): string | undefined {
|
||||
const at = identity.lastIndexOf("@");
|
||||
return at >= 0 ? identity.slice(at + 1).toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
export async function verifyDkimSignature(
|
||||
message: ParsedMessage,
|
||||
signatureIndex: number,
|
||||
dnsTxtRecord: string,
|
||||
now = Date.now(),
|
||||
): Promise<DkimVerificationResult> {
|
||||
let prepared: PreparedDkimVerification;
|
||||
try {
|
||||
prepared = await prepareDkimVerification(message, signatureIndex);
|
||||
} catch (reason) {
|
||||
const inspection =
|
||||
inspectDkimSignatures(message)[signatureIndex] ??
|
||||
inspectionFor(
|
||||
{
|
||||
name: "DKIM-Signature",
|
||||
lowerName: "dkim-signature",
|
||||
raw: "",
|
||||
value: "",
|
||||
},
|
||||
signatureIndex,
|
||||
);
|
||||
return {
|
||||
...inspection,
|
||||
bodyHash: "error",
|
||||
signature: "error",
|
||||
status: "permerror",
|
||||
unsignedBodyBytes: 0,
|
||||
details: [
|
||||
reason instanceof Error ? reason.message : "Preparation failed.",
|
||||
],
|
||||
};
|
||||
}
|
||||
const { inspection } = prepared;
|
||||
const details: string[] = [];
|
||||
const result = (
|
||||
overrides: Partial<DkimVerificationResult>,
|
||||
): DkimVerificationResult => ({
|
||||
...inspection,
|
||||
bodyHash: "pass",
|
||||
signature: "not-checked",
|
||||
status: "fail",
|
||||
computedBodyHash: prepared.computedBodyHash,
|
||||
unsignedBodyBytes: prepared.unsignedBodyBytes,
|
||||
details,
|
||||
...overrides,
|
||||
});
|
||||
if (prepared.computedBodyHash !== prepared.claimedBodyHash) {
|
||||
details.push("Canonicalized body SHA-256 does not match bh=.");
|
||||
return result({
|
||||
bodyHash: "fail",
|
||||
signature: "not-checked",
|
||||
status: "fail",
|
||||
});
|
||||
}
|
||||
if (prepared.unsignedBodyBytes)
|
||||
details.push(
|
||||
`The l= tag leaves ${prepared.unsignedBodyBytes.toLocaleString()} canonical body bytes unsigned.`,
|
||||
);
|
||||
let keyTags: Map<string, string>;
|
||||
try {
|
||||
const record = dnsTxtRecord
|
||||
.trim()
|
||||
.replace(/^\([^)]*\)$/u, (value) => value.slice(1, -1))
|
||||
.replace(/^"|"$/gu, "")
|
||||
.replace(/"\s*"/gu, "");
|
||||
keyTags = tagList(record, "DKIM key record");
|
||||
if (keyTags.has("v") && keyTags.get("v") !== "DKIM1")
|
||||
throw new SyntaxError("Key record v= is not DKIM1.");
|
||||
const services = (keyTags.get("s") ?? "*").toLowerCase().split(":");
|
||||
if (!services.includes("*") && !services.includes("email"))
|
||||
throw new TypeError("Key record is not authorized for email service.");
|
||||
const hashes = keyTags.get("h")?.toLowerCase().split(":");
|
||||
if (hashes && !hashes.includes("sha256"))
|
||||
throw new TypeError("Key record does not allow SHA-256.");
|
||||
if (!keyTags.has("p")) throw new SyntaxError("Key record has no p= tag.");
|
||||
if (!(keyTags.get("p") ?? "").replace(/\s/gu, ""))
|
||||
throw new TypeError("DKIM public key is revoked (empty p=). ");
|
||||
const expectedKeyType = inspection.algorithm.startsWith("ed25519")
|
||||
? "ed25519"
|
||||
: "rsa";
|
||||
if ((keyTags.get("k") ?? "rsa").toLowerCase() !== expectedKeyType)
|
||||
throw new TypeError(`Key type does not match ${inspection.algorithm}.`);
|
||||
const signatureTags = tagList(
|
||||
signatureFields(message)[signatureIndex]!.value,
|
||||
"DKIM-Signature",
|
||||
);
|
||||
const identityDomain = domainOfIdentity(
|
||||
signatureTags.get("i") ?? `@${inspection.domain}`,
|
||||
);
|
||||
if (
|
||||
!identityDomain ||
|
||||
(identityDomain !== inspection.domain.toLowerCase() &&
|
||||
!identityDomain.endsWith(`.${inspection.domain.toLowerCase()}`))
|
||||
)
|
||||
throw new TypeError("AUID i= is not within the signing domain d=.");
|
||||
if (
|
||||
(keyTags.get("t") ?? "").toLowerCase().split(":").includes("s") &&
|
||||
identityDomain !== inspection.domain.toLowerCase()
|
||||
)
|
||||
throw new TypeError("Strict key flag t=s requires an exact AUID domain.");
|
||||
} catch (reason) {
|
||||
details.push(
|
||||
reason instanceof Error ? reason.message : "Key record is invalid.",
|
||||
);
|
||||
return result({ signature: "error", status: "permerror" });
|
||||
}
|
||||
try {
|
||||
const publicBytes = base64ToBytes(
|
||||
(keyTags.get("p") ?? "").replace(/\s/gu, ""),
|
||||
{ maxOutputBytes: 64 * 1024 },
|
||||
);
|
||||
let verified: boolean;
|
||||
if (inspection.algorithm === "rsa-sha256") {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"spki",
|
||||
publicBytes,
|
||||
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
const modulusLength = (key.algorithm as RsaHashedKeyAlgorithm)
|
||||
.modulusLength;
|
||||
if (modulusLength < 1024)
|
||||
throw new TypeError(
|
||||
`RSA key is only ${modulusLength} bits; 1,024 are required.`,
|
||||
);
|
||||
verified = await crypto.subtle.verify(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
key,
|
||||
prepared.signatureBytes,
|
||||
prepared.headerBytes,
|
||||
);
|
||||
} else {
|
||||
if (publicBytes.byteLength !== 32)
|
||||
throw new TypeError("Ed25519 DKIM public key must contain 32 bytes.");
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
publicBytes,
|
||||
{ name: "Ed25519" },
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
const headerHash = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
prepared.headerBytes,
|
||||
);
|
||||
verified = await crypto.subtle.verify(
|
||||
"Ed25519",
|
||||
key,
|
||||
prepared.signatureBytes,
|
||||
headerHash,
|
||||
);
|
||||
}
|
||||
if (!verified) {
|
||||
details.push("Cryptographic header signature did not verify.");
|
||||
return result({ signature: "fail", status: "fail" });
|
||||
}
|
||||
} catch (reason) {
|
||||
details.push(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "Cryptographic verification failed.",
|
||||
);
|
||||
return result({ signature: "error", status: "permerror" });
|
||||
}
|
||||
if (inspection.expires !== undefined && now / 1_000 > inspection.expires) {
|
||||
details.push("Signature x= time has expired.");
|
||||
return result({ signature: "pass", status: "expired" });
|
||||
}
|
||||
if (
|
||||
inspection.timestamp !== undefined &&
|
||||
inspection.timestamp > now / 1_000 + 300
|
||||
)
|
||||
details.push("Signature t= time is more than five minutes in the future.");
|
||||
details.push(
|
||||
"Body hash and cryptographic header signature verify against the pasted key.",
|
||||
);
|
||||
return result({ signature: "pass", status: "pass" });
|
||||
}
|
||||
+117
-2
@@ -1,5 +1,6 @@
|
||||
import { stableStringify } from "@add-ideas/toolbox-helpers";
|
||||
import type { MailHeader, ParsedMessage } from "./types";
|
||||
import { bytesToBase64, stableStringify } from "@add-ideas/toolbox-helpers";
|
||||
import { headerValues, parseParameterizedHeader } from "./mime";
|
||||
import type { MailHeader, MimePart, ParsedMessage } from "./types";
|
||||
|
||||
export const DEFAULT_REDACTED_HEADERS = [
|
||||
"received",
|
||||
@@ -44,6 +45,120 @@ function serialize(headers: readonly MailHeader[], body: string): string {
|
||||
return `${headers.map((header) => foldHeader(header.name, header.rawValue)).join("\r\n")}\r\n\r\n${body.replace(/\r\n?|\n/gu, "\r\n")}`;
|
||||
}
|
||||
|
||||
export interface DeepRedactionOptions {
|
||||
headerNames?: readonly string[];
|
||||
redactTextBodies?: boolean;
|
||||
removeAttachmentPayloads?: boolean;
|
||||
}
|
||||
|
||||
export interface DeepRedactionResult {
|
||||
output: string;
|
||||
report: string;
|
||||
removedHeaders: Array<{ part: string; name: string; valueLength: number }>;
|
||||
redactedTextParts: string[];
|
||||
removedAttachments: Array<{
|
||||
part: string;
|
||||
filename?: string;
|
||||
mediaType: string;
|
||||
bytes: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
function encodeReplacement(part: MimePart, value: string): string {
|
||||
if (part.transferEncoding === "base64")
|
||||
return bytesToBase64(new TextEncoder().encode(value)).replace(
|
||||
/.{76}(?=.)/gu,
|
||||
"$&\r\n",
|
||||
);
|
||||
if (part.transferEncoding === "quoted-printable")
|
||||
return value.replace(/=/gu, "=3D");
|
||||
return value;
|
||||
}
|
||||
|
||||
function deepSerialize(
|
||||
part: MimePart,
|
||||
options: Required<DeepRedactionOptions>,
|
||||
report: Omit<DeepRedactionResult, "output" | "report">,
|
||||
): string {
|
||||
const wanted = new Set(options.headerNames.map((name) => name.toLowerCase()));
|
||||
const headers = part.headers.filter((header) => {
|
||||
if (!wanted.has(header.lowerName)) return true;
|
||||
report.removedHeaders.push({
|
||||
part: part.id,
|
||||
name: header.name,
|
||||
valueLength: header.rawValue.length,
|
||||
});
|
||||
return false;
|
||||
});
|
||||
const isAttachment = part.disposition === "attachment" || !!part.filename;
|
||||
let body: string;
|
||||
if (options.removeAttachmentPayloads && isAttachment) {
|
||||
report.removedAttachments.push({
|
||||
part: part.id,
|
||||
...(part.filename ? { filename: part.filename } : {}),
|
||||
mediaType: part.mediaType,
|
||||
bytes: part.bytes.byteLength,
|
||||
});
|
||||
body = encodeReplacement(part, "[Attachment payload removed locally.]\r\n");
|
||||
} else if (part.children.length && part.mediaType.startsWith("multipart/")) {
|
||||
const boundary = parseParameterizedHeader(
|
||||
headerValues(part.headers, "content-type")[0],
|
||||
).parameters.boundary;
|
||||
if (!boundary)
|
||||
throw new SyntaxError(`Multipart part ${part.id} has no boundary.`);
|
||||
body = `${part.children
|
||||
.map(
|
||||
(child) => `--${boundary}\r\n${deepSerialize(child, options, report)}`,
|
||||
)
|
||||
.join("\r\n")}\r\n--${boundary}--\r\n`;
|
||||
} else if (
|
||||
part.mediaType === "message/rfc822" &&
|
||||
part.children[0] &&
|
||||
!isAttachment
|
||||
) {
|
||||
body = encodeReplacement(
|
||||
part,
|
||||
deepSerialize(part.children[0], options, report),
|
||||
);
|
||||
} else if (options.redactTextBodies && part.mediaType.startsWith("text/")) {
|
||||
report.redactedTextParts.push(part.id);
|
||||
body = encodeReplacement(part, "[Text body removed locally.]\r\n");
|
||||
} else body = part.sourceBody.replace(/\r\n?|\n/gu, "\r\n");
|
||||
return serialize(headers, body);
|
||||
}
|
||||
|
||||
export function deepRedactMessage(
|
||||
message: ParsedMessage,
|
||||
options: DeepRedactionOptions = {},
|
||||
): DeepRedactionResult {
|
||||
const normalized: Required<DeepRedactionOptions> = {
|
||||
headerNames: options.headerNames ?? DEFAULT_REDACTED_HEADERS,
|
||||
redactTextBodies: options.redactTextBodies ?? false,
|
||||
removeAttachmentPayloads: options.removeAttachmentPayloads ?? false,
|
||||
};
|
||||
const facts = {
|
||||
removedHeaders: [] as DeepRedactionResult["removedHeaders"],
|
||||
redactedTextParts: [] as string[],
|
||||
removedAttachments: [] as DeepRedactionResult["removedAttachments"],
|
||||
};
|
||||
const output = deepSerialize(message.root, normalized, facts);
|
||||
const report = stableStringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: "mail-deep-redaction",
|
||||
options: normalized,
|
||||
...facts,
|
||||
caveats: [
|
||||
"The message was rebuilt canonically; MIME preambles, epilogues and original folding are not preserved.",
|
||||
"Unselected headers and payloads may still contain personal or identifying data.",
|
||||
"The result invalidates existing message signatures.",
|
||||
],
|
||||
},
|
||||
2,
|
||||
);
|
||||
return { output, report, ...facts };
|
||||
}
|
||||
|
||||
export function canonicalMessage(message: ParsedMessage): string {
|
||||
return serialize(message.root.headers, message.bodySource);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { headerValues, parseMessage, walkParts } from "./mime";
|
||||
import type { ParsedMessage } from "./types";
|
||||
|
||||
export interface MailboxEntry {
|
||||
index: number;
|
||||
envelope: string;
|
||||
message: ParsedMessage;
|
||||
messageId?: string;
|
||||
parentIndex?: number;
|
||||
depth: number;
|
||||
subject: string;
|
||||
from: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface ParsedMailbox {
|
||||
entries: MailboxEntry[];
|
||||
bytes: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface MailboxSearchOptions {
|
||||
includeBodies?: boolean;
|
||||
maxBodyCharactersPerMessage?: number;
|
||||
}
|
||||
|
||||
export const MBOX_LIMITS = {
|
||||
bytes: 64 * 1024 * 1024,
|
||||
messages: 2_000,
|
||||
threadDepth: 100,
|
||||
} as const;
|
||||
|
||||
function inputBytes(input: ArrayBuffer | Uint8Array): Uint8Array<ArrayBuffer> {
|
||||
if (input instanceof ArrayBuffer) return new Uint8Array(input.slice(0));
|
||||
const copy = new Uint8Array(input.byteLength);
|
||||
copy.set(input);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function binaryString(bytes: Uint8Array): string {
|
||||
const chunks: string[] = [];
|
||||
for (let offset = 0; offset < bytes.length; offset += 32_768)
|
||||
chunks.push(
|
||||
String.fromCharCode(...bytes.subarray(offset, offset + 32_768)),
|
||||
);
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
function bytesFromBinary(value: string): Uint8Array<ArrayBuffer> {
|
||||
const output = new Uint8Array(value.length);
|
||||
for (let index = 0; index < value.length; index += 1)
|
||||
output[index] = value.charCodeAt(index) & 0xff;
|
||||
return output;
|
||||
}
|
||||
|
||||
function messageIds(value: string): string[] {
|
||||
const bracketed = value.match(/<[^<>\s]+>/gu);
|
||||
if (bracketed) return bracketed.map((item) => item.toLowerCase());
|
||||
return value
|
||||
.split(/\s+/u)
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function unescapeMboxrd(source: string): string {
|
||||
return source.replace(
|
||||
/^(>+)From /gmu,
|
||||
(_whole, quote: string) => `${quote.slice(1)}From `,
|
||||
);
|
||||
}
|
||||
|
||||
export function parseMbox(input: ArrayBuffer | Uint8Array): ParsedMailbox {
|
||||
const bytes = inputBytes(input);
|
||||
if (bytes.byteLength > MBOX_LIMITS.bytes)
|
||||
throw new RangeError("Mailbox exceeds the 64 MiB local limit.");
|
||||
const source = binaryString(bytes);
|
||||
const delimiter = /^From [^\r\n]*(?:\r\n|\n|\r)/gmu;
|
||||
const boundaries: Array<{
|
||||
start: number;
|
||||
bodyStart: number;
|
||||
envelope: string;
|
||||
}> = [];
|
||||
for (const match of source.matchAll(delimiter)) {
|
||||
boundaries.push({
|
||||
start: match.index,
|
||||
bodyStart: match.index + match[0].length,
|
||||
envelope: match[0].replace(/[\r\n]+$/u, ""),
|
||||
});
|
||||
if (boundaries.length > MBOX_LIMITS.messages)
|
||||
throw new RangeError(
|
||||
`Mailbox exceeds ${MBOX_LIMITS.messages.toLocaleString()} messages.`,
|
||||
);
|
||||
}
|
||||
if (!boundaries.length)
|
||||
throw new SyntaxError("No mbox envelope separator line was found.");
|
||||
const warnings: string[] = [];
|
||||
if (boundaries[0]!.start > 0 && source.slice(0, boundaries[0]!.start).trim())
|
||||
warnings.push(
|
||||
"Bytes before the first mbox envelope separator were ignored.",
|
||||
);
|
||||
const entries: MailboxEntry[] = boundaries.map((boundary, index) => {
|
||||
const end = boundaries[index + 1]?.start ?? source.length;
|
||||
let entity = source.slice(boundary.bodyStart, end);
|
||||
entity = entity.replace(/(?:\r\n|\n|\r)$/u, "");
|
||||
const message = parseMessage(bytesFromBinary(unescapeMboxrd(entity)));
|
||||
const headers = message.root.headers;
|
||||
return {
|
||||
index,
|
||||
envelope: boundary.envelope,
|
||||
message,
|
||||
messageId: messageIds(headerValues(headers, "message-id")[0] ?? "")[0],
|
||||
depth: 0,
|
||||
subject: headerValues(headers, "subject")[0] ?? "(no subject)",
|
||||
from: headerValues(headers, "from")[0] ?? "(unknown sender)",
|
||||
date: headerValues(headers, "date")[0] ?? "(no date)",
|
||||
};
|
||||
});
|
||||
const byId = new Map<string, number>();
|
||||
for (const entry of entries) {
|
||||
const headers = entry.message.root.headers;
|
||||
const candidates = [
|
||||
...messageIds(headerValues(headers, "in-reply-to")[0] ?? ""),
|
||||
...messageIds(headerValues(headers, "references")[0] ?? "").reverse(),
|
||||
];
|
||||
const parent = candidates
|
||||
.map((id) => byId.get(id))
|
||||
.find(
|
||||
(index): index is number => index !== undefined && index < entry.index,
|
||||
);
|
||||
if (parent !== undefined) {
|
||||
entry.parentIndex = parent;
|
||||
entry.depth = Math.min(
|
||||
entries[parent]!.depth + 1,
|
||||
MBOX_LIMITS.threadDepth,
|
||||
);
|
||||
if (entry.depth === MBOX_LIMITS.threadDepth)
|
||||
warnings.push(
|
||||
`Thread depth was capped at ${MBOX_LIMITS.threadDepth} for message ${entry.index + 1}.`,
|
||||
);
|
||||
}
|
||||
if (entry.messageId) byId.set(entry.messageId, entry.index);
|
||||
}
|
||||
return { entries, bytes: bytes.byteLength, warnings: [...new Set(warnings)] };
|
||||
}
|
||||
|
||||
export function searchMailbox(
|
||||
mailbox: ParsedMailbox,
|
||||
query: string,
|
||||
options: MailboxSearchOptions = {},
|
||||
): MailboxEntry[] {
|
||||
const terms = query
|
||||
.normalize("NFKC")
|
||||
.toLocaleLowerCase()
|
||||
.split(/\s+/u)
|
||||
.filter(Boolean)
|
||||
.slice(0, 20);
|
||||
if (!terms.length) return mailbox.entries;
|
||||
const includeBodies = options.includeBodies ?? true;
|
||||
const bodyLimit = Math.min(
|
||||
256 * 1024,
|
||||
Math.max(0, options.maxBodyCharactersPerMessage ?? 64 * 1024),
|
||||
);
|
||||
return mailbox.entries.filter((entry) => {
|
||||
const headers = entry.message.root.headers
|
||||
.map((header) => `${header.name} ${header.value}`)
|
||||
.join("\n");
|
||||
const filenames = walkParts(entry.message.root)
|
||||
.map((part) => part.filename ?? "")
|
||||
.join("\n");
|
||||
const bodies = includeBodies
|
||||
? walkParts(entry.message.root)
|
||||
.map((part) => part.text ?? "")
|
||||
.join("\n")
|
||||
.slice(0, bodyLimit)
|
||||
: "";
|
||||
const haystack = `${entry.envelope}\n${headers}\n${filenames}\n${bodies}`
|
||||
.normalize("NFKC")
|
||||
.toLocaleLowerCase();
|
||||
return terms.every((term) => haystack.includes(term));
|
||||
});
|
||||
}
|
||||
+209
-37
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
assertBoundedText,
|
||||
base64ToBytes,
|
||||
sanitizeDownloadFilename,
|
||||
} from "@add-ideas/toolbox-helpers";
|
||||
@@ -10,6 +9,7 @@ import type {
|
||||
MimePart,
|
||||
ParsedMessage,
|
||||
} from "./types";
|
||||
import type { CidResource } from "./sanitize";
|
||||
|
||||
export const DEFAULT_MAIL_LIMITS: MailLimits = Object.freeze({
|
||||
maxChars: 8 * 1024 * 1024,
|
||||
@@ -24,13 +24,53 @@ interface ParseBudget {
|
||||
decodedBytes: number;
|
||||
}
|
||||
|
||||
function splitEntity(source: string): [string, string] {
|
||||
function splitEntity(source: string): {
|
||||
headerSource: string;
|
||||
bodySource: string;
|
||||
headerEnd: number;
|
||||
bodyStart: number;
|
||||
} {
|
||||
const match = /\r?\n\r?\n/u.exec(source);
|
||||
if (!match || match.index === undefined) return [source, ""];
|
||||
return [
|
||||
source.slice(0, match.index),
|
||||
source.slice(match.index + match[0].length),
|
||||
];
|
||||
if (!match || match.index === undefined)
|
||||
return {
|
||||
headerSource: source,
|
||||
bodySource: "",
|
||||
headerEnd: source.length,
|
||||
bodyStart: source.length,
|
||||
};
|
||||
return {
|
||||
headerSource: source.slice(0, match.index),
|
||||
bodySource: source.slice(match.index + match[0].length),
|
||||
headerEnd: match.index,
|
||||
bodyStart: match.index + match[0].length,
|
||||
};
|
||||
}
|
||||
|
||||
function bytesToBinaryString(bytes: Uint8Array): string {
|
||||
const chunks: string[] = [];
|
||||
const size = 32_768;
|
||||
for (let offset = 0; offset < bytes.length; offset += size) {
|
||||
chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + size)));
|
||||
}
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
function binaryStringToBytes(input: string): Uint8Array<ArrayBuffer> {
|
||||
const bytes = new Uint8Array(input.length);
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
bytes[index] = input.charCodeAt(index) & 0xff;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function inputBytes(
|
||||
raw: string | ArrayBuffer | Uint8Array,
|
||||
): Uint8Array<ArrayBuffer> {
|
||||
if (typeof raw === "string") return new TextEncoder().encode(raw);
|
||||
if (raw instanceof ArrayBuffer) return new Uint8Array(raw.slice(0));
|
||||
const copy = new Uint8Array(raw.byteLength);
|
||||
copy.set(raw);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function decoderFor(charset: string | undefined): TextDecoder {
|
||||
@@ -181,24 +221,67 @@ export function parseParameterizedHeader(input: string | undefined): {
|
||||
string,
|
||||
string
|
||||
>;
|
||||
const continuations = new Map<
|
||||
string,
|
||||
{ index: number; encoded: boolean; value: string }[]
|
||||
>();
|
||||
for (const chunk of chunks) {
|
||||
const equals = chunk.indexOf("=");
|
||||
if (equals <= 0) continue;
|
||||
const key = chunk.slice(0, equals).trim().toLowerCase();
|
||||
let parameter = unquote(chunk.slice(equals + 1).trim());
|
||||
if (key.endsWith("*")) {
|
||||
const match = /^[^']*'[^']*'(.*)$/u.exec(parameter);
|
||||
try {
|
||||
parameter = decodeURIComponent(match?.[1] ?? parameter);
|
||||
} catch {
|
||||
/* retain literal */
|
||||
}
|
||||
const parameter = unquote(chunk.slice(equals + 1).trim());
|
||||
const continuation = /^([^*]+)\*(\d+)(\*)?$/u.exec(key);
|
||||
if (continuation) {
|
||||
const name = continuation[1]!;
|
||||
const list = continuations.get(name) ?? [];
|
||||
list.push({
|
||||
index: Number(continuation[2]),
|
||||
encoded: continuation[3] === "*",
|
||||
value: parameter,
|
||||
});
|
||||
continuations.set(name, list);
|
||||
continue;
|
||||
}
|
||||
parameters[key] = decodeHeaderValue(parameter);
|
||||
parameters[key] = decodeHeaderValue(
|
||||
key.endsWith("*") ? decodeExtendedParameter(parameter) : parameter,
|
||||
);
|
||||
}
|
||||
for (const [name, values] of continuations) {
|
||||
values.sort((left, right) => left.index - right.index);
|
||||
if (values[0]?.index !== 0) continue;
|
||||
const contiguous: typeof values = [];
|
||||
for (const value of values) {
|
||||
if (value.index !== contiguous.length) break;
|
||||
contiguous.push(value);
|
||||
}
|
||||
const joined = contiguous.map((value) => value.value).join("");
|
||||
const encoded = contiguous.some((value) => value.encoded);
|
||||
parameters[encoded ? `${name}*` : name] = decodeHeaderValue(
|
||||
encoded ? decodeExtendedParameter(joined) : joined,
|
||||
);
|
||||
}
|
||||
return { value, parameters };
|
||||
}
|
||||
|
||||
function decodeExtendedParameter(input: string): string {
|
||||
const match = /^([^']*)'[^']*'(.*)$/u.exec(input);
|
||||
const charset = match?.[1] || "utf-8";
|
||||
const encoded = match?.[2] ?? input;
|
||||
const bytes: number[] = [];
|
||||
for (let index = 0; index < encoded.length; index += 1) {
|
||||
if (
|
||||
encoded[index] === "%" &&
|
||||
/^[0-9a-f]{2}$/iu.test(encoded.slice(index + 1, index + 3))
|
||||
) {
|
||||
bytes.push(Number.parseInt(encoded.slice(index + 1, index + 3), 16));
|
||||
index += 2;
|
||||
} else {
|
||||
bytes.push(encoded.charCodeAt(index) & 0xff);
|
||||
}
|
||||
}
|
||||
return decoderFor(charset).decode(new Uint8Array(bytes));
|
||||
}
|
||||
|
||||
function decodeQuotedPrintable(input: string): Uint8Array<ArrayBuffer> {
|
||||
const normalized = input.replace(/=\r?\n/gu, "");
|
||||
const bytes: number[] = [];
|
||||
@@ -231,30 +314,61 @@ function decodeBody(
|
||||
});
|
||||
}
|
||||
if (encoding === "quoted-printable") return decodeQuotedPrintable(input);
|
||||
return new TextEncoder().encode(input);
|
||||
return binaryStringToBytes(input);
|
||||
}
|
||||
|
||||
function multipartSegments(body: string, boundary: string): string[] {
|
||||
interface MultipartSegment {
|
||||
source: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function multipartSegments(
|
||||
body: string,
|
||||
boundary: string,
|
||||
bodyStart: number | undefined,
|
||||
): MultipartSegment[] {
|
||||
if (!boundary || boundary.length > 200 || /[\r\n]/u.test(boundary))
|
||||
throw new SyntaxError("Invalid MIME boundary");
|
||||
const lines = body.replace(/\r\n?/gu, "\n").split("\n");
|
||||
const open = `--${boundary}`;
|
||||
const close = `${open}--`;
|
||||
const segments: string[] = [];
|
||||
let current: string[] | undefined;
|
||||
const segments: MultipartSegment[] = [];
|
||||
let currentStart: number | undefined;
|
||||
let closed = false;
|
||||
for (const line of lines) {
|
||||
let offset = 0;
|
||||
while (offset <= body.length) {
|
||||
const match = /\r\n|\n|\r/gu.exec(body.slice(offset));
|
||||
const lineEnd = match ? offset + (match.index ?? 0) : body.length;
|
||||
const next = match ? lineEnd + match[0].length : body.length + 1;
|
||||
const line = body.slice(offset, lineEnd).replace(/[ \t]+$/u, "");
|
||||
if (line === open || line === close) {
|
||||
if (current) segments.push(current.join("\r\n"));
|
||||
if (currentStart !== undefined) {
|
||||
let end = offset;
|
||||
if (body.slice(Math.max(0, end - 2), end) === "\r\n") end -= 2;
|
||||
else if (/[\r\n]/u.test(body.slice(Math.max(0, end - 1), end)))
|
||||
end -= 1;
|
||||
segments.push({
|
||||
source: body.slice(currentStart, end),
|
||||
start: (bodyStart ?? 0) + currentStart,
|
||||
end: (bodyStart ?? 0) + end,
|
||||
});
|
||||
}
|
||||
if (line === close) {
|
||||
current = undefined;
|
||||
currentStart = undefined;
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
current = [];
|
||||
} else if (current) current.push(line);
|
||||
currentStart = next;
|
||||
}
|
||||
if (!match) break;
|
||||
offset = next;
|
||||
}
|
||||
if (!closed && current) segments.push(current.join("\r\n"));
|
||||
if (!closed && currentStart !== undefined)
|
||||
segments.push({
|
||||
source: body.slice(currentStart),
|
||||
start: (bodyStart ?? 0) + currentStart,
|
||||
end: (bodyStart ?? 0) + body.length,
|
||||
});
|
||||
if (segments.length === 0)
|
||||
throw new SyntaxError("Multipart boundary was not found in the body");
|
||||
return segments;
|
||||
@@ -265,13 +379,15 @@ function parseEntity(
|
||||
path: number[],
|
||||
limits: MailLimits,
|
||||
budget: ParseBudget,
|
||||
sourceStart?: number,
|
||||
): MimePart {
|
||||
if (path.length > limits.maxDepth)
|
||||
throw new RangeError(`MIME nesting exceeds ${limits.maxDepth}`);
|
||||
budget.parts += 1;
|
||||
if (budget.parts > limits.maxParts)
|
||||
throw new RangeError(`MIME part count exceeds ${limits.maxParts}`);
|
||||
const [headerSource, sourceBody] = splitEntity(source);
|
||||
const split = splitEntity(source);
|
||||
const { headerSource, bodySource: sourceBody } = split;
|
||||
const headers = parseHeaders(headerSource, limits);
|
||||
const contentType = parseParameterizedHeader(
|
||||
headerValues(headers, "content-type")[0] ?? "text/plain; charset=us-ascii",
|
||||
@@ -313,14 +429,33 @@ function parseEntity(
|
||||
bytes: new Uint8Array(),
|
||||
children: [],
|
||||
warnings,
|
||||
...(sourceStart === undefined
|
||||
? {}
|
||||
: {
|
||||
sourceRange: {
|
||||
start: sourceStart,
|
||||
end: sourceStart + source.length,
|
||||
headerEnd: sourceStart + split.headerEnd,
|
||||
bodyStart: sourceStart + split.bodyStart,
|
||||
},
|
||||
}),
|
||||
};
|
||||
if (part.mediaType.startsWith("multipart/")) {
|
||||
const boundary = contentType.parameters.boundary;
|
||||
if (!boundary)
|
||||
throw new SyntaxError(`Multipart part ${part.id} has no boundary`);
|
||||
part.children = multipartSegments(sourceBody, boundary).map(
|
||||
(segment, index) =>
|
||||
parseEntity(segment, [...path, index + 1], limits, budget),
|
||||
part.children = multipartSegments(
|
||||
sourceBody,
|
||||
boundary,
|
||||
sourceStart === undefined ? undefined : sourceStart + split.bodyStart,
|
||||
).map((segment, index) =>
|
||||
parseEntity(
|
||||
segment.source,
|
||||
[...path, index + 1],
|
||||
limits,
|
||||
budget,
|
||||
sourceStart === undefined ? undefined : segment.start,
|
||||
),
|
||||
);
|
||||
return part;
|
||||
}
|
||||
@@ -363,21 +498,36 @@ function parseEntity(
|
||||
}
|
||||
|
||||
export function parseMessage(
|
||||
raw: string,
|
||||
input: string | ArrayBuffer | Uint8Array,
|
||||
limits: MailLimits = DEFAULT_MAIL_LIMITS,
|
||||
): ParsedMessage {
|
||||
assertBoundedText(raw, limits.maxChars, "Message source length");
|
||||
if (raw.includes("\0"))
|
||||
const rawBytes = inputBytes(input);
|
||||
if (rawBytes.byteLength > limits.maxChars)
|
||||
throw new RangeError(
|
||||
`Message source length exceeds the ${limits.maxChars}-byte limit`,
|
||||
);
|
||||
const raw = bytesToBinaryString(rawBytes);
|
||||
if (rawBytes.includes(0))
|
||||
throw new SyntaxError("Message source contains NUL bytes");
|
||||
const [headerSource, bodySource] = splitEntity(raw);
|
||||
const split = splitEntity(raw);
|
||||
const { headerSource, bodySource } = split;
|
||||
const budget: ParseBudget = { parts: 0, decodedBytes: 0 };
|
||||
const root = parseEntity(raw, [], limits, budget);
|
||||
const root = parseEntity(raw, [], limits, budget, 0);
|
||||
const warnings = [...root.warnings];
|
||||
if (!headerValues(root.headers, "from").length)
|
||||
warnings.push("No From header is present.");
|
||||
if (!headerValues(root.headers, "date").length)
|
||||
warnings.push("No Date header is present.");
|
||||
return { raw, headerSource, bodySource, root, warnings };
|
||||
return {
|
||||
raw,
|
||||
rawBytes,
|
||||
headerSource,
|
||||
bodySource,
|
||||
headerEnd: split.headerEnd,
|
||||
bodyStart: split.bodyStart,
|
||||
root,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function walkParts(root: MimePart): MimePart[] {
|
||||
@@ -402,3 +552,25 @@ export function attachments(root: MimePart): AttachmentInfo[] {
|
||||
inline: part.disposition === "inline",
|
||||
}));
|
||||
}
|
||||
|
||||
export function inlineCidResources(root: MimePart): CidResource[] {
|
||||
const resources: CidResource[] = [];
|
||||
let total = 0;
|
||||
for (const part of walkParts(root)) {
|
||||
const contentId = headerValues(part.headers, "content-id")[0];
|
||||
if (
|
||||
!contentId ||
|
||||
!/^image\/(?:png|jpeg|gif|webp|avif)$/u.test(part.mediaType)
|
||||
)
|
||||
continue;
|
||||
if (part.bytes.byteLength > 5 * 1024 * 1024) continue;
|
||||
total += part.bytes.byteLength;
|
||||
if (total > 16 * 1024 * 1024) break;
|
||||
resources.push({
|
||||
contentId: contentId.trim().replace(/^<|>$/gu, ""),
|
||||
mediaType: part.mediaType,
|
||||
bytes: part.bytes,
|
||||
});
|
||||
}
|
||||
return resources.slice(0, 100);
|
||||
}
|
||||
|
||||
+46
-3
@@ -1,10 +1,53 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { bytesToBase64 } from "@add-ideas/toolbox-helpers";
|
||||
|
||||
export interface CidResource {
|
||||
contentId: string;
|
||||
mediaType: string;
|
||||
bytes: Uint8Array<ArrayBuffer>;
|
||||
}
|
||||
|
||||
const FRAME_POLICY =
|
||||
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src 'none'; media-src 'none'; connect-src 'none'; form-action 'none'; base-uri 'none'";
|
||||
|
||||
export function sanitizeMailHtml(input: string): string {
|
||||
const clean = DOMPurify.sanitize(input, {
|
||||
export function sanitizeMailHtml(
|
||||
input: string,
|
||||
resources: readonly CidResource[] = [],
|
||||
): string {
|
||||
const safeResources = new Map(
|
||||
resources
|
||||
.filter(
|
||||
(item) =>
|
||||
/^(?:image\/(?:png|jpeg|gif|webp|avif))$/u.test(item.mediaType) &&
|
||||
item.bytes.byteLength <= 5 * 1024 * 1024,
|
||||
)
|
||||
.map((item) => [
|
||||
item.contentId.trim().replace(/^<|>$/gu, "").toLowerCase(),
|
||||
item,
|
||||
]),
|
||||
);
|
||||
const document = new DOMParser().parseFromString(input, "text/html");
|
||||
for (const image of document.querySelectorAll("img")) {
|
||||
const source = image.getAttribute("src") ?? "";
|
||||
image.removeAttribute("src");
|
||||
image.removeAttribute("srcset");
|
||||
if (!/^cid:/iu.test(source)) continue;
|
||||
let identifier = source.slice(4);
|
||||
try {
|
||||
identifier = decodeURIComponent(identifier);
|
||||
} catch {
|
||||
// Retain the literal identifier for matching.
|
||||
}
|
||||
const resource = safeResources.get(
|
||||
identifier.trim().replace(/^<|>$/gu, "").toLowerCase(),
|
||||
);
|
||||
if (resource)
|
||||
image.setAttribute(
|
||||
"src",
|
||||
`data:${resource.mediaType};base64,${bytesToBase64(resource.bytes)}`,
|
||||
);
|
||||
}
|
||||
const clean = DOMPurify.sanitize(document.body.innerHTML, {
|
||||
WHOLE_DOCUMENT: false,
|
||||
FORBID_TAGS: [
|
||||
"script",
|
||||
@@ -26,7 +69,6 @@ export function sanitizeMailHtml(input: string): string {
|
||||
],
|
||||
FORBID_ATTR: [
|
||||
"style",
|
||||
"src",
|
||||
"srcset",
|
||||
"href",
|
||||
"action",
|
||||
@@ -36,6 +78,7 @@ export function sanitizeMailHtml(input: string): string {
|
||||
"xlink:href",
|
||||
],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ALLOWED_URI_REGEXP: /^data:image\/(?:png|jpeg|gif|webp|avif);base64,/u,
|
||||
});
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${FRAME_POLICY}"><style>body{font:16px/1.5 system-ui,sans-serif;color:#202332;background:#fff;padding:1rem;overflow-wrap:anywhere}pre{white-space:pre-wrap}</style></head><body>${clean}</body></html>`;
|
||||
}
|
||||
|
||||
@@ -19,12 +19,21 @@ export interface MimePart {
|
||||
text?: string;
|
||||
children: MimePart[];
|
||||
warnings: string[];
|
||||
sourceRange?: {
|
||||
start: number;
|
||||
end: number;
|
||||
headerEnd: number;
|
||||
bodyStart: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ParsedMessage {
|
||||
raw: string;
|
||||
rawBytes: Uint8Array<ArrayBuffer>;
|
||||
headerSource: string;
|
||||
bodySource: string;
|
||||
headerEnd: number;
|
||||
bodyStart: number;
|
||||
root: MimePart;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
@@ -381,6 +381,48 @@ iframe {
|
||||
.diagnostic-list p {
|
||||
grid-column: 2;
|
||||
}
|
||||
.dkim-lab {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--toolbox-border);
|
||||
}
|
||||
.dkim-lab > label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.dkim-facts {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.dkim-facts code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.verification-result {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--mail-warning);
|
||||
border-radius: 0.65rem;
|
||||
background: color-mix(in srgb, var(--mail-warning) 9%, transparent);
|
||||
}
|
||||
.verification-result.pass {
|
||||
border-color: var(--mail-success);
|
||||
background: color-mix(in srgb, var(--mail-success) 9%, transparent);
|
||||
}
|
||||
.verification-result.fail,
|
||||
.verification-result.permerror,
|
||||
.verification-result.expired {
|
||||
border-color: var(--toolbox-danger);
|
||||
background: color-mix(in srgb, var(--toolbox-danger) 8%, transparent);
|
||||
}
|
||||
.verification-result span {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.empty {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
@@ -388,6 +430,43 @@ iframe {
|
||||
.report-preview {
|
||||
min-height: 12rem;
|
||||
}
|
||||
.mailbox-list ol {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.mailbox-search,
|
||||
.redaction-options {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
margin-block: 0.7rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.redaction-options label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.redaction-options input {
|
||||
width: auto;
|
||||
}
|
||||
.mailbox-list li {
|
||||
margin-inline-start: min(calc(var(--thread-depth, 0) * 1rem), 8rem);
|
||||
}
|
||||
.mailbox-list li > button {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: 0.2rem;
|
||||
padding: 0.65rem;
|
||||
text-align: start;
|
||||
}
|
||||
.mailbox-list li > button span,
|
||||
.mailbox-list li > button small {
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
.loading,
|
||||
.fatal {
|
||||
width: min(100% - 2rem, 60rem);
|
||||
@@ -429,6 +508,9 @@ iframe {
|
||||
.selector-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dkim-facts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.cards button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.mail-tools",
|
||||
"name": "Mail Tools",
|
||||
"version": "0.1.0",
|
||||
"description": "Inspect and redact email messages locally.",
|
||||
"version": "0.2.0",
|
||||
"description": "Inspect email and mailbox files locally.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["documents", "developer", "privacy"],
|
||||
"tags": ["email", "eml", "mime", "headers", "attachments"],
|
||||
"tags": ["email", "eml", "mbox", "mime", "dkim", "attachments"],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
@@ -21,6 +21,36 @@
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"io": {
|
||||
"accepts": [
|
||||
{
|
||||
"mediaType": "message/rfc822",
|
||||
"extensions": [".eml"]
|
||||
},
|
||||
{
|
||||
"mediaType": "application/mbox",
|
||||
"extensions": [".mbox", ".mbx"]
|
||||
},
|
||||
{
|
||||
"mediaType": "text/plain",
|
||||
"extensions": [".txt"]
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
{
|
||||
"mediaType": "message/rfc822",
|
||||
"extensions": [".eml"]
|
||||
},
|
||||
{
|
||||
"mediaType": "application/json",
|
||||
"extensions": [".json"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"required": [],
|
||||
"optional": ["web-crypto"]
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
@@ -128,7 +128,7 @@ test("integrates help, dark theme, PWA identity and hardened headers", async ({
|
||||
const manifest = await request.get("/deep/nested/mail/toolbox-app.json");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.mail-tools",
|
||||
version: "0.1.0",
|
||||
version: "0.2.0",
|
||||
privacy: { processing: "local", telemetry: false },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("keeps the primary workspace inside a narrow viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/mail/");
|
||||
await expect(page.locator("main").first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator("main .loading, main .workbench-loading"),
|
||||
).toHaveCount(0);
|
||||
|
||||
const widths = await page.evaluate(() => ({
|
||||
content: document.documentElement.scrollWidth,
|
||||
viewport: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(widths.viewport).toBeLessThanOrEqual(430);
|
||||
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deepRedactMessage } from "../../src/core/export";
|
||||
import { parseMessage } from "../../src/core/mime";
|
||||
|
||||
describe("deep redaction", () => {
|
||||
it("redacts nested headers, text bodies, and attachment payloads", () => {
|
||||
const message = parseMessage(`From: root@example.test
|
||||
Received: private-root
|
||||
Content-Type: multipart/mixed; boundary=x
|
||||
|
||||
--x
|
||||
Content-Type: message/rfc822
|
||||
|
||||
From: nested@example.test
|
||||
Received: private-nested
|
||||
Content-Type: text/plain
|
||||
|
||||
Nested secret
|
||||
--x
|
||||
Content-Type: application/octet-stream; name="secret.bin"
|
||||
Content-Disposition: attachment; filename="secret.bin"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
AQIDBA==
|
||||
--x--
|
||||
`);
|
||||
const result = deepRedactMessage(message, {
|
||||
redactTextBodies: true,
|
||||
removeAttachmentPayloads: true,
|
||||
});
|
||||
expect(result.output).not.toMatch(
|
||||
/private-root|private-nested|Nested secret|AQIDBA/iu,
|
||||
);
|
||||
expect(result.output).toContain(
|
||||
"W0F0dGFjaG1lbnQgcGF5bG9hZCByZW1vdmVkIGxvY2FsbHkuXQ",
|
||||
);
|
||||
expect(result.removedHeaders).toHaveLength(2);
|
||||
expect(result.redactedTextParts).toEqual(["1.1"]);
|
||||
expect(result.removedAttachments).toMatchObject([
|
||||
{ part: "2", filename: "secret.bin", bytes: 4 },
|
||||
]);
|
||||
expect(JSON.parse(result.report)).toMatchObject({
|
||||
operation: "mail-deep-redaction",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { bytesToBase64 } from "@add-ideas/toolbox-helpers";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
inspectDkimSignatures,
|
||||
prepareDkimVerification,
|
||||
verifyDkimSignature,
|
||||
} from "../../src/core/dkim";
|
||||
import { parseMessage } from "../../src/core/mime";
|
||||
|
||||
async function signedMessage() {
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: "RSASSA-PKCS1-v1_5",
|
||||
modulusLength: 1024,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: "SHA-256",
|
||||
},
|
||||
true,
|
||||
["sign", "verify"],
|
||||
);
|
||||
const body = "Hello DKIM!\r\n";
|
||||
const bodyHash = bytesToBase64(
|
||||
new Uint8Array(
|
||||
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body)),
|
||||
),
|
||||
);
|
||||
const unsigned = [
|
||||
"From: Ada <ada@example.test>",
|
||||
"Subject: Local verification",
|
||||
`DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.test; s=mail; h=from:subject; bh=${bodyHash}; b=`,
|
||||
"",
|
||||
body,
|
||||
].join("\r\n");
|
||||
const prepared = await prepareDkimVerification(parseMessage(unsigned));
|
||||
const signature = bytesToBase64(
|
||||
new Uint8Array(
|
||||
await crypto.subtle.sign(
|
||||
"RSASSA-PKCS1-v1_5",
|
||||
keyPair.privateKey,
|
||||
prepared.headerBytes,
|
||||
),
|
||||
),
|
||||
);
|
||||
const publicKey = bytesToBase64(
|
||||
new Uint8Array(await crypto.subtle.exportKey("spki", keyPair.publicKey)),
|
||||
);
|
||||
return {
|
||||
source: unsigned.replace(/; b=(?=\r\n)/u, `; b=${signature}`),
|
||||
keyRecord: `v=DKIM1; k=rsa; h=sha256; s=email; p=${publicKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DKIM laboratory", () => {
|
||||
it("inspects signatures without network access", () => {
|
||||
const message = parseMessage(
|
||||
"From: a@example.test\r\nDKIM-Signature: v=1; a=rsa-sha256; d=example.test; s=mail; c=relaxed/relaxed; h=from; bh=YQ==; b=Yg==\r\n\r\na",
|
||||
);
|
||||
expect(inspectDkimSignatures(message)[0]).toMatchObject({
|
||||
queryName: "mail._domainkey.example.test",
|
||||
algorithm: "rsa-sha256",
|
||||
headerCanonicalization: "relaxed",
|
||||
bodyCanonicalization: "relaxed",
|
||||
supported: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies body and header signatures against a pasted key record", async () => {
|
||||
const fixture = await signedMessage();
|
||||
const result = await verifyDkimSignature(
|
||||
parseMessage(fixture.source),
|
||||
0,
|
||||
fixture.keyRecord,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
bodyHash: "pass",
|
||||
signature: "pass",
|
||||
status: "pass",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a changed body before checking the signature", async () => {
|
||||
const fixture = await signedMessage();
|
||||
const changed = fixture.source.replace("Hello DKIM!", "Hello altered!");
|
||||
const result = await verifyDkimSignature(
|
||||
parseMessage(changed),
|
||||
0,
|
||||
fixture.keyRecord,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
bodyHash: "fail",
|
||||
signature: "not-checked",
|
||||
status: "fail",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed for malformed key records and unsupported signatures", async () => {
|
||||
const fixture = await signedMessage();
|
||||
expect(
|
||||
await verifyDkimSignature(parseMessage(fixture.source), 0, "v=DKIM1; p="),
|
||||
).toMatchObject({ signature: "error", status: "permerror" });
|
||||
const obsolete = parseMessage(
|
||||
fixture.source.replace("a=rsa-sha256", "a=rsa-sha1"),
|
||||
);
|
||||
expect(
|
||||
await verifyDkimSignature(obsolete, 0, fixture.keyRecord),
|
||||
).toMatchObject({
|
||||
status: "permerror",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMbox, searchMailbox } from "../../src/core/mbox";
|
||||
|
||||
describe("mbox import and threading", () => {
|
||||
it("splits mboxrd messages, unescapes body From lines, and threads replies", () => {
|
||||
const source = `From sender@example.test Tue Sep 1 10:00:00 2026
|
||||
From: Sender <sender@example.test>
|
||||
Date: Tue, 01 Sep 2026 10:00:00 +0000
|
||||
Message-ID: <root@example.test>
|
||||
Subject: Root
|
||||
|
||||
First body
|
||||
>From escaped body line
|
||||
From reply@example.test Tue Sep 1 10:01:00 2026
|
||||
From: Reply <reply@example.test>
|
||||
Date: Tue, 01 Sep 2026 10:01:00 +0000
|
||||
Message-ID: <reply@example.test>
|
||||
In-Reply-To: <root@example.test>
|
||||
References: <root@example.test>
|
||||
Subject: Re: Root
|
||||
|
||||
Reply body
|
||||
`;
|
||||
const mailbox = parseMbox(new TextEncoder().encode(source));
|
||||
expect(mailbox.entries).toHaveLength(2);
|
||||
expect(mailbox.entries[0]?.message.bodySource).toContain(
|
||||
"From escaped body line",
|
||||
);
|
||||
expect(mailbox.entries[1]).toMatchObject({
|
||||
parentIndex: 0,
|
||||
depth: 1,
|
||||
subject: "Re: Root",
|
||||
});
|
||||
expect(
|
||||
searchMailbox(mailbox, "escaped body").map((entry) => entry.index),
|
||||
).toEqual([0]);
|
||||
expect(
|
||||
searchMailbox(mailbox, "reply root", { includeBodies: false }),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("requires an envelope separator", () => {
|
||||
expect(() =>
|
||||
parseMbox(new TextEncoder().encode("From: a@example.test\n\nbody")),
|
||||
).toThrow(/envelope separator/iu);
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,33 @@ describe("MIME parser", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves arbitrary source octets and exact multipart byte ranges", () => {
|
||||
const prefix = new TextEncoder().encode(
|
||||
"From: a@example.test\r\nContent-Type: multipart/mixed; boundary=x\r\n\r\n--x\r\nContent-Type: application/octet-stream\r\n\r\n",
|
||||
);
|
||||
const suffix = new TextEncoder().encode("\r\n--x--\r\n");
|
||||
const bytes = new Uint8Array(prefix.length + 4 + suffix.length);
|
||||
bytes.set(prefix);
|
||||
bytes.set([0x80, 0x81, 0xfe, 0xff], prefix.length);
|
||||
bytes.set(suffix, prefix.length + 4);
|
||||
const parsed = parseMessage(bytes);
|
||||
expect([...parsed.root.children[0]!.bytes]).toEqual([
|
||||
0x80, 0x81, 0xfe, 0xff,
|
||||
]);
|
||||
expect([...parsed.rawBytes]).toEqual([...bytes]);
|
||||
const range = parsed.root.children[0]!.sourceRange!;
|
||||
expect([...parsed.rawBytes.slice(range.bodyStart, range.end)]).toEqual([
|
||||
0x80, 0x81, 0xfe, 0xff,
|
||||
]);
|
||||
});
|
||||
|
||||
it("assembles RFC 2231 parameter continuations", () => {
|
||||
const parsed = parseMessage(
|
||||
"From: a@example.test\r\nContent-Type: application/octet-stream\r\nContent-Disposition: attachment; filename*0*=UTF-8''long%20; filename*1*=name%E2%9C%93.bin\r\n\r\ndata",
|
||||
);
|
||||
expect(attachments(parsed.root)[0]?.filename).toBe("long name✓.bin");
|
||||
});
|
||||
|
||||
it("parses nested message/rfc822 entities", () => {
|
||||
const parsed = parseMessage(
|
||||
"From: a@example.test\nContent-Type: message/rfc822\n\nFrom: b@example.test\nContent-Type: text/plain\n\nnested",
|
||||
|
||||
@@ -17,6 +17,22 @@ describe("inert rendering and output", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("embeds only explicitly supplied safe CID image bytes", () => {
|
||||
const output = sanitizeMailHtml(
|
||||
'<img src="cid:logo@example.test"><img src="https://bad.test/pixel">',
|
||||
[
|
||||
{
|
||||
contentId: "logo@example.test",
|
||||
mediaType: "image/png",
|
||||
bytes: new Uint8Array([1, 2, 3]),
|
||||
},
|
||||
],
|
||||
);
|
||||
expect(output).toContain("data:image/png;base64,AQID");
|
||||
expect(output).not.toContain("bad.test");
|
||||
expect(output).not.toContain("cid:");
|
||||
});
|
||||
|
||||
it("produces a bounded line comparison", () => {
|
||||
expect(compareBodies("a\nb", "a\nc")).toMatchObject({
|
||||
added: 1,
|
||||
|
||||
Reference in New Issue
Block a user