Release Mail Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 10:47:30 +02:00
parent 9bc0870404
commit 2ab972a9f3
35 changed files with 2061 additions and 150 deletions
+39
View File
@@ -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
+9
View File
@@ -1,5 +1,14 @@
# Changelog # 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 ## 0.1.0 - 2026-09-01
- Initial local-first EML/MIME inspection release. - Initial local-first EML/MIME inspection release.
+25 -13
View File
@@ -1,14 +1,17 @@
# Mail Tools # Mail Tools
Mail Tools is a local-first browser workbench for inspecting `.eml` and MIME Mail Tools is a local-first browser workbench for inspecting `.eml`, MIME and
messages. Version 0.1.0 unfolds headers, decodes RFC 2047 words, builds a bounded mbox files. It unfolds headers, decodes RFC 2047 words, builds a bounded
multipart tree, decodes common transfer encodings, inventories attachments, multipart tree, threads mailbox messages, inventories attachments, compares
compares text bodies, and creates canonical or focused redacted exports. 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 HTML bodies are sanitized and displayed only in an opaque sandbox with an
embedded `default-src 'none'` policy. Remote images, links, forms, scripts, embedded `default-src 'none'` policy. Remote images, links, forms, scripts,
styles, media, frames and active documents are removed. No message, address, styles, media, frames and active documents are removed. Referenced CID images
attachment, URL or telemetry leaves the browser. 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 ## Development and release
@@ -21,7 +24,7 @@ npm run test:browser
npm run release:artifact 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 SHA-256 sidecar. The app uses relative assets and is tested beneath
`/deep/nested/mail/`. It can run standalone or from add·ideas Toolbox. `/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, - source limit: 8 MiB; at most 2,000 headers, 500 MIME parts, nesting depth 20,
and a conservative 16 MiB decoded-data budget; 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; - multipart and nested `message/rfc822`, Base64 and quoted-printable;
- safe text previews, sanitized HTML, attachment inventory/download; - bounded mbox/mboxrd import and local Message-ID/References threading;
- address/date and unverified SPF/DKIM/DMARC/ARC header diagnostics; - 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 - bounded line comparison and normalized EML plus top-level header redaction
with a JSON report. with a JSON report.
This is not a mail client, spam detector, forensic verifier or anonymity tool. 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, It does not contact mail or DNS servers, decrypt S/MIME/OpenPGP, establish
validate DKIM signatures, establish sender identity, scan malware, render CID sender identity, scan malware, open maildir/PST/OST containers, or guarantee
resources, or guarantee round-trip byte identity. See 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). [`docs/PRIVACY-SECURITY.md`](docs/PRIVACY-SECURITY.md).
Licensed under GPL-3.0-or-later. Licensed under GPL-3.0-or-later.
+2 -2
View File
@@ -1,8 +1,8 @@
# Corresponding source # 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 Build with Node.js 22+, npm 11+, and the exact dependencies in
`package-lock.json`. Run `npm ci && npm run release:artifact`. `package-lock.json`. Run `npm ci && npm run release:artifact`.
+14 -4
View File
@@ -1,11 +1,21 @@
# Architecture # Architecture
The React shell lazy-loads one workbench. `core/mime.ts` turns bounded source 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 bytes into immutable-looking header and MIME-part records; parsing never creates
DOM. Transfer decoding consumes a global byte budget. Diagnostics, comparison, 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. 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 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 an iframe without sandbox capabilities and with an inner policy that denies all
connections and active content. Attachment downloads are explicit Blob URLs connections and active content. Explicit `cid:` image references can resolve to
with sanitized filenames. The service worker caches only same-origin app files. 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.
+9 -2
View File
@@ -5,9 +5,16 @@ memory. There is no storage, telemetry, analytics, remote-resource request or
server integration. Closing/reloading the page clears the workbench. server integration. Closing/reloading the page clears the workbench.
Limits reduce accidental resource exhaustion but do not make Mail Tools a 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 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 Focused redaction removes a fixed set of top-level transport/authentication
headers. It does not rewrite nested `message/rfc822` content and can leave names, headers. It does not rewrite nested `message/rfc822` content and can leave names,
+20 -20
View File
@@ -1,23 +1,23 @@
{ {
"name": "mail-tools", "name": "mail-tools",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mail-tools", "name": "mail-tools",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"dompurify": "3.4.14", "dompurify": "3.4.14",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
@@ -43,24 +43,24 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz", "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-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==", "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@add-ideas/toolbox-helpers": { "node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0", "version": "0.2.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz", "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-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==", "integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"license": "GPL-3.0-or-later" "license": "GPL-3.0-or-later"
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3", "version": "0.3.0",
"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", "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-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==", "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -68,13 +68,13 @@
} }
}, },
"node_modules/@add-ideas/toolbox-testkit": { "node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz", "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-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==", "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "mail-tools", "name": "mail-tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect, compare, redact and export email messages locally in the browser.", "description": "Inspect, compare, redact and export email messages locally in the browser.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -39,15 +39,15 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"dompurify": "3.4.14", "dompurify": "3.4.14",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000, timeout: 180_000,
}, },
projects: [ 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"] },
},
], ],
}); });
+9
View File
@@ -1,5 +1,14 @@
# Changelog # 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 ## 0.1.0 - 2026-09-01
- Initial local-first EML/MIME inspection release. - Initial local-first EML/MIME inspection release.
+3 -3
View File
@@ -1,5 +1,5 @@
============================================================================== ==============================================================================
@add-ideas/toolbox-contract@0.2.3 @add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- 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 Declared licence: GPL-3.0-or-later
============================================================================== ==============================================================================
--- LICENSE --- --- 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 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
+25 -13
View File
@@ -1,14 +1,17 @@
# Mail Tools # Mail Tools
Mail Tools is a local-first browser workbench for inspecting `.eml` and MIME Mail Tools is a local-first browser workbench for inspecting `.eml`, MIME and
messages. Version 0.1.0 unfolds headers, decodes RFC 2047 words, builds a bounded mbox files. It unfolds headers, decodes RFC 2047 words, builds a bounded
multipart tree, decodes common transfer encodings, inventories attachments, multipart tree, threads mailbox messages, inventories attachments, compares
compares text bodies, and creates canonical or focused redacted exports. 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 HTML bodies are sanitized and displayed only in an opaque sandbox with an
embedded `default-src 'none'` policy. Remote images, links, forms, scripts, embedded `default-src 'none'` policy. Remote images, links, forms, scripts,
styles, media, frames and active documents are removed. No message, address, styles, media, frames and active documents are removed. Referenced CID images
attachment, URL or telemetry leaves the browser. 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 ## Development and release
@@ -21,7 +24,7 @@ npm run test:browser
npm run release:artifact 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 SHA-256 sidecar. The app uses relative assets and is tested beneath
`/deep/nested/mail/`. It can run standalone or from add·ideas Toolbox. `/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, - source limit: 8 MiB; at most 2,000 headers, 500 MIME parts, nesting depth 20,
and a conservative 16 MiB decoded-data budget; 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; - multipart and nested `message/rfc822`, Base64 and quoted-printable;
- safe text previews, sanitized HTML, attachment inventory/download; - bounded mbox/mboxrd import and local Message-ID/References threading;
- address/date and unverified SPF/DKIM/DMARC/ARC header diagnostics; - 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 - bounded line comparison and normalized EML plus top-level header redaction
with a JSON report. with a JSON report.
This is not a mail client, spam detector, forensic verifier or anonymity tool. 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, It does not contact mail or DNS servers, decrypt S/MIME/OpenPGP, establish
validate DKIM signatures, establish sender identity, scan malware, render CID sender identity, scan malware, open maildir/PST/OST containers, or guarantee
resources, or guarantee round-trip byte identity. See 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). [`docs/PRIVACY-SECURITY.md`](docs/PRIVACY-SECURITY.md).
Licensed under GPL-3.0-or-later. Licensed under GPL-3.0-or-later.
+2 -2
View File
@@ -1,8 +1,8 @@
# Corresponding source # 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 Build with Node.js 22+, npm 11+, and the exact dependencies in
`package-lock.json`. Run `npm ci && npm run release:artifact`. `package-lock.json`. Run `npm ci && npm run release:artifact`.
+14 -4
View File
@@ -1,11 +1,21 @@
# Architecture # Architecture
The React shell lazy-loads one workbench. `core/mime.ts` turns bounded source 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 bytes into immutable-looking header and MIME-part records; parsing never creates
DOM. Transfer decoding consumes a global byte budget. Diagnostics, comparison, 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. 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 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 an iframe without sandbox capabilities and with an inner policy that denies all
connections and active content. Attachment downloads are explicit Blob URLs connections and active content. Explicit `cid:` image references can resolve to
with sanitized filenames. The service worker caches only same-origin app files. 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.
+9 -2
View File
@@ -5,9 +5,16 @@ memory. There is no storage, telemetry, analytics, remote-resource request or
server integration. Closing/reloading the page clears the workbench. server integration. Closing/reloading the page clears the workbench.
Limits reduce accidental resource exhaustion but do not make Mail Tools a 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 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 Focused redaction removes a fixed set of top-level transport/authentication
headers. It does not rewrite nested `message/rfc822` content and can leave names, headers. It does not rewrite nested `message/rfc822` content and can leave names,
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "mail-tools-shell-"; const CACHE_PREFIX = "mail-tools-shell-";
const CACHE_NAME = CACHE_PREFIX + "0.1.0"; const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = [ const CORE = [
"./", "./",
"./manifest.webmanifest", "./manifest.webmanifest",
+15 -3
View File
@@ -3,12 +3,12 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.mail-tools", "id": "de.add-ideas.mail-tools",
"name": "Mail Tools", "name": "Mail Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect and redact email messages locally.", "description": "Inspect email and mailbox files locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
"categories": ["documents", "developer", "privacy"], "categories": ["documents", "developer", "privacy"],
"tags": ["email", "eml", "mime", "headers", "attachments"], "tags": ["email", "eml", "mbox", "mime", "dkim", "attachments"],
"integration": { "integration": {
"contextVersion": 1, "contextVersion": 1,
"launchModes": ["navigate", "new-tab"], "launchModes": ["navigate", "new-tab"],
@@ -21,6 +21,18 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+8 -4
View File
@@ -32,16 +32,20 @@ export function HelpDialog({
</button> </button>
</div> </div>
<p> <p>
Open EML/MIME source, inspect its header and multipart structure, Open EML/MIME source or a bounded mbox, inspect its header and multipart
compare body parts, save attachments, and make focused redacted copies. structure, compare body parts, save attachments, and make focused
redacted copies.
</p> </p>
<p> <p>
Parsing is bounded and entirely local. HTML is sanitized and shown in an Parsing is bounded and entirely local. HTML is sanitized and shown in an
opaque sandbox whose own policy blocks every network request. opaque sandbox whose own policy blocks every network request.
</p> </p>
<p> <p>
Authentication headers are unverified claims. Redaction removes selected Authentication headers remain claims. The DKIM lab can verify preserved
transport/authentication headers only; it is not an anonymity guarantee. 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> </p>
</dialog> </dialog>
); );
+366 -23
View File
@@ -1,9 +1,23 @@
import { useMemo, useState, type ChangeEvent } from "react"; import { useMemo, useState, type ChangeEvent, type CSSProperties } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers"; import {
sanitizeDownloadFilename,
triggerBlobDownload,
} from "@add-ideas/toolbox-helpers";
import { compareBodies } from "../core/compare"; import { compareBodies } from "../core/compare";
import {
inspectDkimSignatures,
verifyDkimSignature,
type DkimVerificationResult,
} from "../core/dkim";
import { diagnoseMessage } from "../core/diagnostics"; import { diagnoseMessage } from "../core/diagnostics";
import { canonicalMessage, redactMessage } from "../core/export"; import { canonicalMessage, deepRedactMessage } from "../core/export";
import { attachments, parseMessage, walkParts } from "../core/mime"; import {
attachments,
inlineCidResources,
parseMessage,
walkParts,
} from "../core/mime";
import { parseMbox, searchMailbox, type ParsedMailbox } from "../core/mbox";
import { sanitizeMailHtml } from "../core/sanitize"; import { sanitizeMailHtml } from "../core/sanitize";
import type { MimePart, ParsedMessage } from "../core/types"; import type { MimePart, ParsedMessage } from "../core/types";
@@ -37,7 +51,8 @@ Content-Transfer-Encoding: base64
QXR0YWNobWVudCBwcmV2aWV3Lg== QXR0YWNobWVudCBwcmV2aWV3Lg==
--outer--`; --outer--`;
type View = "structure" | "bodies" | "attachments" | "diagnostics" | "export"; type View =
"mailbox" | "structure" | "bodies" | "attachments" | "diagnostics" | "export";
function PartTree({ part }: { part: MimePart }) { function PartTree({ part }: { part: MimePart }) {
return ( return (
@@ -70,12 +85,30 @@ export function Workbench() {
); );
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [view, setView] = useState<View>("structure"); 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( const textParts = useMemo(
() => walkParts(message.root).filter((part) => part.text !== undefined), () => walkParts(message.root).filter((part) => part.text !== undefined),
[message], [message],
); );
const files = useMemo(() => attachments(message.root), [message]); const files = useMemo(() => attachments(message.root), [message]);
const diagnostics = useMemo(() => diagnoseMessage(message), [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 [leftId, setLeftId] = useState("1.1");
const [rightId, setRightId] = useState("1.2"); const [rightId, setRightId] = useState("1.2");
const left = textParts.find((part) => part.id === leftId) ?? textParts[0]; const left = textParts.find((part) => part.id === leftId) ?? textParts[0];
@@ -90,16 +123,28 @@ export function Workbench() {
const htmlPreview = useMemo( const htmlPreview = useMemo(
() => () =>
left?.mediaType === "text/html" left?.mediaType === "text/html"
? sanitizeMailHtml(left.text ?? "") ? sanitizeMailHtml(left.text ?? "", inlineCidResources(message.root))
: undefined, : undefined,
[left], [left, message],
);
const redacted = useMemo(
() =>
deepRedactMessage(message, {
redactTextBodies,
removeAttachmentPayloads,
}),
[message, redactTextBodies, removeAttachmentPayloads],
); );
const redacted = useMemo(() => redactMessage(message), [message]);
const inspect = () => { const inspect = () => {
try { try {
const parsed = parseMessage(source); const parsed = parseMessage(source);
setMessage(parsed); setMessage(parsed);
setMailbox(undefined);
setMailboxFilename("");
setMailboxQuery("");
setDkimIndex(0);
setDkimResult(undefined);
setError(undefined); setError(undefined);
} catch (reason) { } catch (reason) {
setError( setError(
@@ -113,16 +158,43 @@ export function Workbench() {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
event.target.value = ""; event.target.value = "";
if (!file) return; if (!file) return;
if (file.size > 8 * 1024 * 1024) { const isMbox = /\.(?:mbox|mbx)$/iu.test(file.name);
setError("File exceeds the 8 MiB source limit."); 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; return;
} }
try { try {
const text = await file.text(); const bytes = await file.arrayBuffer();
const parsed = parseMessage(text); if (isMbox) {
setSource(text); const parsedMailbox = parseMbox(bytes);
setFilename(file.name); const first = parsedMailbox.entries[0]!;
setMessage(parsed); 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); setError(undefined);
} catch (reason) { } catch (reason) {
setError( setError(
@@ -156,11 +228,11 @@ export function Workbench() {
</p> </p>
</div> </div>
<label className="button file-button"> <label className="button file-button">
Open .eml Open .eml / mbox
<input <input
data-testid="mail-file-input" data-testid="mail-file-input"
type="file" type="file"
accept=".eml,message/rfc822,text/plain" accept=".eml,.mbox,.mbx,message/rfc822,application/mbox,text/plain"
onChange={(event) => void openFile(event)} onChange={(event) => void openFile(event)}
/> />
</label> </label>
@@ -185,7 +257,10 @@ export function Workbench() {
</p> </p>
) : ( ) : (
<p className="success" role="status"> <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"}. attachment{files.length === 1 ? "" : "s"}.
</p> </p>
)} )}
@@ -194,6 +269,7 @@ export function Workbench() {
<nav className="tabs" aria-label="Mail workspaces"> <nav className="tabs" aria-label="Mail workspaces">
{( {(
[ [
...(mailbox ? (["mailbox"] as const) : []),
"structure", "structure",
"bodies", "bodies",
"attachments", "attachments",
@@ -212,6 +288,114 @@ export function Workbench() {
))} ))}
</nav> </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" ? ( {view === "structure" ? (
<section className="workspace-grid"> <section className="workspace-grid">
<article className="panel"> <article className="panel">
@@ -386,7 +570,8 @@ export function Workbench() {
<h2>Header diagnostics</h2> <h2>Header diagnostics</h2>
<p> <p>
Reported SPF, DKIM, DMARC and ARC results are displayed as 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> </p>
</div> </div>
</div> </div>
@@ -399,6 +584,140 @@ export function Workbench() {
</li> </li>
))} ))}
</ul> </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> </section>
) : null} ) : null}
@@ -424,12 +743,36 @@ export function Workbench() {
</button> </button>
</article> </article>
<article className="panel"> <article className="panel">
<h2>Focused redaction</h2> <h2>Redaction workspace</h2>
<p> <p>
Removes {redacted.removed.length} top-level Removes {redacted.removedHeaders.length} selected header field
transport/authentication header fields. It does not scan nested {redacted.removedHeaders.length === 1 ? "" : "s"} throughout the
messages, bodies or attachments for personal data. MIME tree. Optional destructive policies can replace decoded text
bodies and attachment payloads.
</p> </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"> <div className="action-row">
<button <button
type="button" type="button"
+523
View File
@@ -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
View File
@@ -1,5 +1,6 @@
import { stableStringify } from "@add-ideas/toolbox-helpers"; import { bytesToBase64, stableStringify } from "@add-ideas/toolbox-helpers";
import type { MailHeader, ParsedMessage } from "./types"; import { headerValues, parseParameterizedHeader } from "./mime";
import type { MailHeader, MimePart, ParsedMessage } from "./types";
export const DEFAULT_REDACTED_HEADERS = [ export const DEFAULT_REDACTED_HEADERS = [
"received", "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")}`; 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 { export function canonicalMessage(message: ParsedMessage): string {
return serialize(message.root.headers, message.bodySource); return serialize(message.root.headers, message.bodySource);
} }
+181
View File
@@ -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
View File
@@ -1,5 +1,4 @@
import { import {
assertBoundedText,
base64ToBytes, base64ToBytes,
sanitizeDownloadFilename, sanitizeDownloadFilename,
} from "@add-ideas/toolbox-helpers"; } from "@add-ideas/toolbox-helpers";
@@ -10,6 +9,7 @@ import type {
MimePart, MimePart,
ParsedMessage, ParsedMessage,
} from "./types"; } from "./types";
import type { CidResource } from "./sanitize";
export const DEFAULT_MAIL_LIMITS: MailLimits = Object.freeze({ export const DEFAULT_MAIL_LIMITS: MailLimits = Object.freeze({
maxChars: 8 * 1024 * 1024, maxChars: 8 * 1024 * 1024,
@@ -24,13 +24,53 @@ interface ParseBudget {
decodedBytes: number; 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); const match = /\r?\n\r?\n/u.exec(source);
if (!match || match.index === undefined) return [source, ""]; if (!match || match.index === undefined)
return [ return {
source.slice(0, match.index), headerSource: source,
source.slice(match.index + match[0].length), 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 { function decoderFor(charset: string | undefined): TextDecoder {
@@ -181,24 +221,67 @@ export function parseParameterizedHeader(input: string | undefined): {
string, string,
string string
>; >;
const continuations = new Map<
string,
{ index: number; encoded: boolean; value: string }[]
>();
for (const chunk of chunks) { for (const chunk of chunks) {
const equals = chunk.indexOf("="); const equals = chunk.indexOf("=");
if (equals <= 0) continue; if (equals <= 0) continue;
const key = chunk.slice(0, equals).trim().toLowerCase(); const key = chunk.slice(0, equals).trim().toLowerCase();
let parameter = unquote(chunk.slice(equals + 1).trim()); const parameter = unquote(chunk.slice(equals + 1).trim());
if (key.endsWith("*")) { const continuation = /^([^*]+)\*(\d+)(\*)?$/u.exec(key);
const match = /^[^']*'[^']*'(.*)$/u.exec(parameter); if (continuation) {
try { const name = continuation[1]!;
parameter = decodeURIComponent(match?.[1] ?? parameter); const list = continuations.get(name) ?? [];
} catch { list.push({
/* retain literal */ 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 }; 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> { function decodeQuotedPrintable(input: string): Uint8Array<ArrayBuffer> {
const normalized = input.replace(/=\r?\n/gu, ""); const normalized = input.replace(/=\r?\n/gu, "");
const bytes: number[] = []; const bytes: number[] = [];
@@ -231,30 +314,61 @@ function decodeBody(
}); });
} }
if (encoding === "quoted-printable") return decodeQuotedPrintable(input); 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)) if (!boundary || boundary.length > 200 || /[\r\n]/u.test(boundary))
throw new SyntaxError("Invalid MIME boundary"); throw new SyntaxError("Invalid MIME boundary");
const lines = body.replace(/\r\n?/gu, "\n").split("\n");
const open = `--${boundary}`; const open = `--${boundary}`;
const close = `${open}--`; const close = `${open}--`;
const segments: string[] = []; const segments: MultipartSegment[] = [];
let current: string[] | undefined; let currentStart: number | undefined;
let closed = false; 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 (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) { if (line === close) {
current = undefined; currentStart = undefined;
closed = true; closed = true;
break; break;
} }
current = []; currentStart = next;
} else if (current) current.push(line); }
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) if (segments.length === 0)
throw new SyntaxError("Multipart boundary was not found in the body"); throw new SyntaxError("Multipart boundary was not found in the body");
return segments; return segments;
@@ -265,13 +379,15 @@ function parseEntity(
path: number[], path: number[],
limits: MailLimits, limits: MailLimits,
budget: ParseBudget, budget: ParseBudget,
sourceStart?: number,
): MimePart { ): MimePart {
if (path.length > limits.maxDepth) if (path.length > limits.maxDepth)
throw new RangeError(`MIME nesting exceeds ${limits.maxDepth}`); throw new RangeError(`MIME nesting exceeds ${limits.maxDepth}`);
budget.parts += 1; budget.parts += 1;
if (budget.parts > limits.maxParts) if (budget.parts > limits.maxParts)
throw new RangeError(`MIME part count exceeds ${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 headers = parseHeaders(headerSource, limits);
const contentType = parseParameterizedHeader( const contentType = parseParameterizedHeader(
headerValues(headers, "content-type")[0] ?? "text/plain; charset=us-ascii", headerValues(headers, "content-type")[0] ?? "text/plain; charset=us-ascii",
@@ -313,14 +429,33 @@ function parseEntity(
bytes: new Uint8Array(), bytes: new Uint8Array(),
children: [], children: [],
warnings, warnings,
...(sourceStart === undefined
? {}
: {
sourceRange: {
start: sourceStart,
end: sourceStart + source.length,
headerEnd: sourceStart + split.headerEnd,
bodyStart: sourceStart + split.bodyStart,
},
}),
}; };
if (part.mediaType.startsWith("multipart/")) { if (part.mediaType.startsWith("multipart/")) {
const boundary = contentType.parameters.boundary; const boundary = contentType.parameters.boundary;
if (!boundary) if (!boundary)
throw new SyntaxError(`Multipart part ${part.id} has no boundary`); throw new SyntaxError(`Multipart part ${part.id} has no boundary`);
part.children = multipartSegments(sourceBody, boundary).map( part.children = multipartSegments(
(segment, index) => sourceBody,
parseEntity(segment, [...path, index + 1], limits, budget), 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; return part;
} }
@@ -363,21 +498,36 @@ function parseEntity(
} }
export function parseMessage( export function parseMessage(
raw: string, input: string | ArrayBuffer | Uint8Array,
limits: MailLimits = DEFAULT_MAIL_LIMITS, limits: MailLimits = DEFAULT_MAIL_LIMITS,
): ParsedMessage { ): ParsedMessage {
assertBoundedText(raw, limits.maxChars, "Message source length"); const rawBytes = inputBytes(input);
if (raw.includes("\0")) 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"); 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 budget: ParseBudget = { parts: 0, decodedBytes: 0 };
const root = parseEntity(raw, [], limits, budget); const root = parseEntity(raw, [], limits, budget, 0);
const warnings = [...root.warnings]; const warnings = [...root.warnings];
if (!headerValues(root.headers, "from").length) if (!headerValues(root.headers, "from").length)
warnings.push("No From header is present."); warnings.push("No From header is present.");
if (!headerValues(root.headers, "date").length) if (!headerValues(root.headers, "date").length)
warnings.push("No Date header is present."); 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[] { export function walkParts(root: MimePart): MimePart[] {
@@ -402,3 +552,25 @@ export function attachments(root: MimePart): AttachmentInfo[] {
inline: part.disposition === "inline", 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
View File
@@ -1,10 +1,53 @@
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { bytesToBase64 } from "@add-ideas/toolbox-helpers";
export interface CidResource {
contentId: string;
mediaType: string;
bytes: Uint8Array<ArrayBuffer>;
}
const FRAME_POLICY = 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'"; "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 { export function sanitizeMailHtml(
const clean = DOMPurify.sanitize(input, { 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, WHOLE_DOCUMENT: false,
FORBID_TAGS: [ FORBID_TAGS: [
"script", "script",
@@ -26,7 +69,6 @@ export function sanitizeMailHtml(input: string): string {
], ],
FORBID_ATTR: [ FORBID_ATTR: [
"style", "style",
"src",
"srcset", "srcset",
"href", "href",
"action", "action",
@@ -36,6 +78,7 @@ export function sanitizeMailHtml(input: string): string {
"xlink:href", "xlink:href",
], ],
ALLOW_DATA_ATTR: false, 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>`; 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>`;
} }
+9
View File
@@ -19,12 +19,21 @@ export interface MimePart {
text?: string; text?: string;
children: MimePart[]; children: MimePart[];
warnings: string[]; warnings: string[];
sourceRange?: {
start: number;
end: number;
headerEnd: number;
bodyStart: number;
};
} }
export interface ParsedMessage { export interface ParsedMessage {
raw: string; raw: string;
rawBytes: Uint8Array<ArrayBuffer>;
headerSource: string; headerSource: string;
bodySource: string; bodySource: string;
headerEnd: number;
bodyStart: number;
root: MimePart; root: MimePart;
warnings: string[]; warnings: string[];
} }
+82
View File
@@ -381,6 +381,48 @@ iframe {
.diagnostic-list p { .diagnostic-list p {
grid-column: 2; 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 { .empty {
padding: 1rem; padding: 1rem;
text-align: center; text-align: center;
@@ -388,6 +430,43 @@ iframe {
.report-preview { .report-preview {
min-height: 12rem; 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, .loading,
.fatal { .fatal {
width: min(100% - 2rem, 60rem); width: min(100% - 2rem, 60rem);
@@ -429,6 +508,9 @@ iframe {
.selector-grid { .selector-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.dkim-facts {
grid-template-columns: 1fr;
}
.cards button { .cards button {
width: 100%; width: 100%;
} }
+33 -3
View File
@@ -3,12 +3,12 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.mail-tools", "id": "de.add-ideas.mail-tools",
"name": "Mail Tools", "name": "Mail Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect and redact email messages locally.", "description": "Inspect email and mailbox files locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
"categories": ["documents", "developer", "privacy"], "categories": ["documents", "developer", "privacy"],
"tags": ["email", "eml", "mime", "headers", "attachments"], "tags": ["email", "eml", "mbox", "mime", "dkim", "attachments"],
"integration": { "integration": {
"contextVersion": 1, "contextVersion": 1,
"launchModes": ["navigate", "new-tab"], "launchModes": ["navigate", "new-tab"],
@@ -21,6 +21,36 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0"; export const APP_VERSION = "0.2.0";
+1 -1
View File
@@ -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"); const manifest = await request.get("/deep/nested/mail/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({ await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.mail-tools", id: "de.add-ideas.mail-tools",
version: "0.1.0", version: "0.2.0",
privacy: { processing: "local", telemetry: false }, privacy: { processing: "local", telemetry: false },
}); });
}); });
+18
View File
@@ -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);
});
+46
View File
@@ -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",
});
});
});
+110
View File
@@ -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",
});
});
});
+47
View File
@@ -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);
});
});
+27
View File
@@ -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", () => { it("parses nested message/rfc822 entities", () => {
const parsed = parseMessage( const parsed = parseMessage(
"From: a@example.test\nContent-Type: message/rfc822\n\nFrom: b@example.test\nContent-Type: text/plain\n\nnested", "From: a@example.test\nContent-Type: message/rfc822\n\nFrom: b@example.test\nContent-Type: text/plain\n\nnested",
+16
View File
@@ -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", () => { it("produces a bounded line comparison", () => {
expect(compareBodies("a\nb", "a\nc")).toMatchObject({ expect(compareBodies("a\nb", "a\nc")).toMatchObject({
added: 1, added: 1,