From 2ab972a9f3935dfa654baf41ced93a661af56095 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 2 Sep 2026 10:47:30 +0200 Subject: [PATCH] Release Mail Tools 0.2.0 --- .gitea/workflows/verify.yml | 39 ++ CHANGELOG.md | 9 + README.md | 38 +- SOURCE.md | 4 +- docs/ARCHITECTURE.md | 18 +- docs/PRIVACY-SECURITY.md | 11 +- package-lock.json | 40 +- package.json | 10 +- playwright.config.ts | 22 +- public/CHANGELOG.md | 9 + public/LICENSES/npm-runtime-licenses.txt | 6 +- public/README.md | 38 +- public/SOURCE.md | 4 +- public/docs/ARCHITECTURE.md | 18 +- public/docs/PRIVACY-SECURITY.md | 11 +- public/sw.js | 2 +- public/toolbox-app.json | 18 +- src/components/HelpDialog.tsx | 12 +- src/components/Workbench.tsx | 389 ++++++++++++++++- src/core/dkim.ts | 523 +++++++++++++++++++++++ src/core/export.ts | 119 +++++- src/core/mbox.ts | 181 ++++++++ src/core/mime.ts | 246 +++++++++-- src/core/sanitize.ts | 49 ++- src/core/types.ts | 9 + src/styles.css | 82 ++++ src/toolbox/manifest.source.json | 36 +- src/version.ts | 2 +- tests/browser/app.spec.ts | 2 +- tests/browser/responsive.spec.ts | 18 + tests/core/deep-redaction.test.ts | 46 ++ tests/core/dkim.test.ts | 110 +++++ tests/core/mbox.test.ts | 47 ++ tests/core/mime.test.ts | 27 ++ tests/core/security-export.test.ts | 16 + 35 files changed, 2061 insertions(+), 150 deletions(-) create mode 100644 .gitea/workflows/verify.yml create mode 100644 src/core/dkim.ts create mode 100644 src/core/mbox.ts create mode 100644 tests/browser/responsive.spec.ts create mode 100644 tests/core/deep-redaction.test.ts create mode 100644 tests/core/dkim.test.ts create mode 100644 tests/core/mbox.test.ts diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml new file mode 100644 index 0000000..b84a7cb --- /dev/null +++ b/.gitea/workflows/verify.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 517e3d1..55ea7bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.2.0 - 2026-09-02 + +- Preserved original message octets and MIME entity ranges, including RFC 2231 + continued parameters and byte-safe attachment downloads. +- Added bounded mbox/mboxrd import, threading and search, safe packaged CID-image + preview, and deeper MIME-tree redaction with explicit evidence. +- Added local RSA-SHA256 and Ed25519-SHA256 DKIM verification against public-key + DNS text pasted by the user. + ## 0.1.0 - 2026-09-01 - Initial local-first EML/MIME inspection release. diff --git a/README.md b/README.md index 25d60e2..394cc75 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,17 @@ # Mail Tools -Mail Tools is a local-first browser workbench for inspecting `.eml` and MIME -messages. Version 0.1.0 unfolds headers, decodes RFC 2047 words, builds a bounded -multipart tree, decodes common transfer encodings, inventories attachments, -compares text bodies, and creates canonical or focused redacted exports. +Mail Tools is a local-first browser workbench for inspecting `.eml`, MIME and +mbox files. It unfolds headers, decodes RFC 2047 words, builds a bounded +multipart tree, threads mailbox messages, inventories attachments, compares +text bodies, and creates canonical or focused redacted exports. File imports +are parsed from their original octets, retain exact source byte ranges for raw +MIME entities, and assemble RFC 2231 continued parameters. HTML bodies are sanitized and displayed only in an opaque sandbox with an embedded `default-src 'none'` policy. Remote images, links, forms, scripts, -styles, media, frames and active documents are removed. No message, address, -attachment, URL or telemetry leaves the browser. +styles, media, frames and active documents are removed. Referenced CID images +of a small safe type can be embedded from the same message. No message, +address, attachment, URL or telemetry leaves the browser. ## Development and release @@ -21,7 +24,7 @@ npm run test:browser npm run release:artifact ``` -The last command creates deterministic `release/mail-tools-0.1.0.zip` and its +The last command creates deterministic `release/mail-tools-0.2.0.zip` and its SHA-256 sidecar. The app uses relative assets and is tested beneath `/deep/nested/mail/`. It can run standalone or from add·ideas Toolbox. @@ -29,17 +32,26 @@ SHA-256 sidecar. The app uses relative assets and is tested beneath - source limit: 8 MiB; at most 2,000 headers, 500 MIME parts, nesting depth 20, and a conservative 16 MiB decoded-data budget; -- folded headers, duplicate fields and RFC 2047 B/Q words; +- folded headers, duplicate fields, RFC 2047 B/Q words, and RFC 2231 parameter + continuations; - multipart and nested `message/rfc822`, Base64 and quoted-printable; -- safe text previews, sanitized HTML, attachment inventory/download; -- address/date and unverified SPF/DKIM/DMARC/ARC header diagnostics; +- bounded mbox/mboxrd import and local Message-ID/References threading; +- safe text previews, sanitized HTML with local CID images, attachment + inventory/download; +- address/date and SPF/DKIM/DMARC/ARC claim diagnostics; +- RFC 6376 simple/relaxed RSA-SHA256 and RFC 8463 Ed25519-SHA256 DKIM + verification against a public-key TXT record pasted by the user; - bounded line comparison and normalized EML plus top-level header redaction with a JSON report. This is not a mail client, spam detector, forensic verifier or anonymity tool. -It does not open mailbox containers, contact servers, decrypt S/MIME/OpenPGP, -validate DKIM signatures, establish sender identity, scan malware, render CID -resources, or guarantee round-trip byte identity. See +It does not contact mail or DNS servers, decrypt S/MIME/OpenPGP, establish +sender identity, scan malware, open maildir/PST/OST containers, or guarantee +that the deliberately normalized canonical export is byte-identical. DKIM +verification proves only that the preserved bytes match the pasted key record; +the tool cannot establish whether that record is authentic or current. The +untouched source octets remain available for inspection. +See [`docs/PRIVACY-SECURITY.md`](docs/PRIVACY-SECURITY.md). Licensed under GPL-3.0-or-later. diff --git a/SOURCE.md b/SOURCE.md index 7c6627e..4d5f069 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -1,8 +1,8 @@ # Corresponding source -The corresponding source for Mail Tools 0.1.0 is available at: +The corresponding source for Mail Tools 0.2.0 is available at: -https://git.add-ideas.de/lotobo/mail-tools/src/tag/v0.1.0 +https://git.add-ideas.de/lotobo/mail-tools/src/tag/v0.2.0 Build with Node.js 22+, npm 11+, and the exact dependencies in `package-lock.json`. Run `npm ci && npm run release:artifact`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9102c9a..6676098 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,11 +1,21 @@ # Architecture The React shell lazy-loads one workbench. `core/mime.ts` turns bounded source -text into immutable-looking header and MIME-part records; parsing never creates -DOM. Transfer decoding consumes a global byte budget. Diagnostics, comparison, +bytes into immutable-looking header and MIME-part records; parsing never creates +DOM. A one-byte internal syntax view preserves arbitrary octets, while every raw +multipart entity records exact offsets into the retained source buffer. +Transfer decoding consumes a global byte budget. Diagnostics, comparison, canonicalization and redaction are separate pure modules. +`core/mbox.ts` splits bounded mbox/mboxrd containers and derives a presentation +tree from Message-ID, In-Reply-To and References. `core/dkim.ts` implements +bounded RFC 6376 canonicalization and uses WebCrypto for RSA-SHA256 or +Ed25519-SHA256 verification. The key record is explicit user input; the app has +no DNS client and makes no network request. + Only the selected HTML text is passed through DOMPurify. It is then embedded in an iframe without sandbox capabilities and with an inner policy that denies all -connections and active content. Attachment downloads are explicit Blob URLs -with sanitized filenames. The service worker caches only same-origin app files. +connections and active content. Explicit `cid:` image references can resolve to +small safe image parts already present in the message; no external URI is +retained. Attachment downloads are explicit Blob URLs with sanitized filenames. +The service worker caches only same-origin app files. diff --git a/docs/PRIVACY-SECURITY.md b/docs/PRIVACY-SECURITY.md index 6b13e96..8300b09 100644 --- a/docs/PRIVACY-SECURITY.md +++ b/docs/PRIVACY-SECURITY.md @@ -5,9 +5,16 @@ memory. There is no storage, telemetry, analytics, remote-resource request or server integration. Closing/reloading the page clears the workbench. Limits reduce accidental resource exhaustion but do not make Mail Tools a -forensic parser. HTML loses links, styling and embedded resources by design. +forensic parser. HTML loses links, styling and external embedded resources by +design; only bounded safe image parts referenced by `cid:` can be embedded. Downloaded attachments remain untrusted. Authentication results and Received -headers are self-asserted text; no DNS or cryptographic verification occurs. +headers are self-asserted text. + +The optional DKIM lab performs local cryptographic verification against a DNS +TXT value pasted by the user. It never resolves DNS, so a passing result means +only that the message bytes match that supplied key. It does not prove that the +key is current, authoritative or obtained securely, and it does not turn other +authentication headers into verified evidence. Focused redaction removes a fixed set of top-level transport/authentication headers. It does not rewrite nested `message/rfc822` content and can leave names, diff --git a/package-lock.json b/package-lock.json index 258a5e0..54f359b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,23 @@ { "name": "mail-tools", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mail-tools", - "version": "0.1.0", + "version": "0.2.0", "license": "GPL-3.0-or-later", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3", - "@add-ideas/toolbox-helpers": "0.1.0", - "@add-ideas/toolbox-shell-react": "0.2.3", + "@add-ideas/toolbox-contract": "0.3.0", + "@add-ideas/toolbox-helpers": "0.2.0", + "@add-ideas/toolbox-shell-react": "0.3.0", "dompurify": "3.4.14", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { - "@add-ideas/toolbox-testkit": "0.2.3", + "@add-ideas/toolbox-testkit": "0.3.0", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@testing-library/jest-dom": "6.9.1", @@ -43,24 +43,24 @@ } }, "node_modules/@add-ideas/toolbox-contract": { - "version": "0.2.3", - "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz", - "integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz", + "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==", "license": "Apache-2.0" }, "node_modules/@add-ideas/toolbox-helpers": { - "version": "0.1.0", - "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz", - "integrity": "sha512-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==", + "version": "0.2.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz", + "integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==", "license": "GPL-3.0-or-later" }, "node_modules/@add-ideas/toolbox-shell-react": { - "version": "0.2.3", - "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz", - "integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz", + "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==", "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "peerDependencies": { "react": ">=18 <20", @@ -68,13 +68,13 @@ } }, "node_modules/@add-ideas/toolbox-testkit": { - "version": "0.2.3", - "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz", - "integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==", + "version": "0.3.0", + "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz", + "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3" + "@add-ideas/toolbox-contract": "0.3.0" }, "bin": { "toolbox-check": "dist/cli.js" diff --git a/package.json b/package.json index 1a57a3f..45a599d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mail-tools", - "version": "0.1.0", + "version": "0.2.0", "description": "Inspect, compare, redact and export email messages locally in the browser.", "license": "GPL-3.0-or-later", "author": "Albrecht Degering", @@ -39,15 +39,15 @@ "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" }, "dependencies": { - "@add-ideas/toolbox-contract": "0.2.3", - "@add-ideas/toolbox-helpers": "0.1.0", - "@add-ideas/toolbox-shell-react": "0.2.3", + "@add-ideas/toolbox-contract": "0.3.0", + "@add-ideas/toolbox-helpers": "0.2.0", + "@add-ideas/toolbox-shell-react": "0.3.0", "dompurify": "3.4.14", "react": "19.2.8", "react-dom": "19.2.8" }, "devDependencies": { - "@add-ideas/toolbox-testkit": "0.2.3", + "@add-ideas/toolbox-testkit": "0.3.0", "@eslint/js": "10.0.1", "@playwright/test": "1.62.1", "@testing-library/jest-dom": "6.9.1", diff --git a/playwright.config.ts b/playwright.config.ts index 2ec09cb..f59c348 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -15,7 +15,25 @@ export default defineConfig({ timeout: 180_000, }, projects: [ - { name: "chromium", use: { ...devices["Desktop Chrome"] } }, - { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + { + name: "chromium", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + testIgnore: /responsive\.spec\.ts/, + use: { ...devices["Desktop Safari"] }, + }, + { + name: "mobile-chromium", + testMatch: /responsive\.spec\.ts/, + use: { ...devices["Pixel 5"] }, + }, ], }); diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index 517e3d1..55ea7bc 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.2.0 - 2026-09-02 + +- Preserved original message octets and MIME entity ranges, including RFC 2231 + continued parameters and byte-safe attachment downloads. +- Added bounded mbox/mboxrd import, threading and search, safe packaged CID-image + preview, and deeper MIME-tree redaction with explicit evidence. +- Added local RSA-SHA256 and Ed25519-SHA256 DKIM verification against public-key + DNS text pasted by the user. + ## 0.1.0 - 2026-09-01 - Initial local-first EML/MIME inspection release. diff --git a/public/LICENSES/npm-runtime-licenses.txt b/public/LICENSES/npm-runtime-licenses.txt index 790670c..02895b7 100644 --- a/public/LICENSES/npm-runtime-licenses.txt +++ b/public/LICENSES/npm-runtime-licenses.txt @@ -1,5 +1,5 @@ ============================================================================== -@add-ideas/toolbox-contract@0.2.3 +@add-ideas/toolbox-contract@0.3.0 Declared licence: Apache-2.0 ============================================================================== --- LICENSE --- @@ -198,7 +198,7 @@ Declared licence: Apache-2.0 ============================================================================== -@add-ideas/toolbox-helpers@0.1.0 +@add-ideas/toolbox-helpers@0.2.0 Declared licence: GPL-3.0-or-later ============================================================================== --- LICENSE --- @@ -879,7 +879,7 @@ Public License instead of this License. But first, please read ============================================================================== -@add-ideas/toolbox-shell-react@0.2.3 +@add-ideas/toolbox-shell-react@0.3.0 Declared licence: Apache-2.0 ============================================================================== --- LICENSE --- diff --git a/public/README.md b/public/README.md index 25d60e2..394cc75 100644 --- a/public/README.md +++ b/public/README.md @@ -1,14 +1,17 @@ # Mail Tools -Mail Tools is a local-first browser workbench for inspecting `.eml` and MIME -messages. Version 0.1.0 unfolds headers, decodes RFC 2047 words, builds a bounded -multipart tree, decodes common transfer encodings, inventories attachments, -compares text bodies, and creates canonical or focused redacted exports. +Mail Tools is a local-first browser workbench for inspecting `.eml`, MIME and +mbox files. It unfolds headers, decodes RFC 2047 words, builds a bounded +multipart tree, threads mailbox messages, inventories attachments, compares +text bodies, and creates canonical or focused redacted exports. File imports +are parsed from their original octets, retain exact source byte ranges for raw +MIME entities, and assemble RFC 2231 continued parameters. HTML bodies are sanitized and displayed only in an opaque sandbox with an embedded `default-src 'none'` policy. Remote images, links, forms, scripts, -styles, media, frames and active documents are removed. No message, address, -attachment, URL or telemetry leaves the browser. +styles, media, frames and active documents are removed. Referenced CID images +of a small safe type can be embedded from the same message. No message, +address, attachment, URL or telemetry leaves the browser. ## Development and release @@ -21,7 +24,7 @@ npm run test:browser npm run release:artifact ``` -The last command creates deterministic `release/mail-tools-0.1.0.zip` and its +The last command creates deterministic `release/mail-tools-0.2.0.zip` and its SHA-256 sidecar. The app uses relative assets and is tested beneath `/deep/nested/mail/`. It can run standalone or from add·ideas Toolbox. @@ -29,17 +32,26 @@ SHA-256 sidecar. The app uses relative assets and is tested beneath - source limit: 8 MiB; at most 2,000 headers, 500 MIME parts, nesting depth 20, and a conservative 16 MiB decoded-data budget; -- folded headers, duplicate fields and RFC 2047 B/Q words; +- folded headers, duplicate fields, RFC 2047 B/Q words, and RFC 2231 parameter + continuations; - multipart and nested `message/rfc822`, Base64 and quoted-printable; -- safe text previews, sanitized HTML, attachment inventory/download; -- address/date and unverified SPF/DKIM/DMARC/ARC header diagnostics; +- bounded mbox/mboxrd import and local Message-ID/References threading; +- safe text previews, sanitized HTML with local CID images, attachment + inventory/download; +- address/date and SPF/DKIM/DMARC/ARC claim diagnostics; +- RFC 6376 simple/relaxed RSA-SHA256 and RFC 8463 Ed25519-SHA256 DKIM + verification against a public-key TXT record pasted by the user; - bounded line comparison and normalized EML plus top-level header redaction with a JSON report. This is not a mail client, spam detector, forensic verifier or anonymity tool. -It does not open mailbox containers, contact servers, decrypt S/MIME/OpenPGP, -validate DKIM signatures, establish sender identity, scan malware, render CID -resources, or guarantee round-trip byte identity. See +It does not contact mail or DNS servers, decrypt S/MIME/OpenPGP, establish +sender identity, scan malware, open maildir/PST/OST containers, or guarantee +that the deliberately normalized canonical export is byte-identical. DKIM +verification proves only that the preserved bytes match the pasted key record; +the tool cannot establish whether that record is authentic or current. The +untouched source octets remain available for inspection. +See [`docs/PRIVACY-SECURITY.md`](docs/PRIVACY-SECURITY.md). Licensed under GPL-3.0-or-later. diff --git a/public/SOURCE.md b/public/SOURCE.md index 7c6627e..4d5f069 100644 --- a/public/SOURCE.md +++ b/public/SOURCE.md @@ -1,8 +1,8 @@ # Corresponding source -The corresponding source for Mail Tools 0.1.0 is available at: +The corresponding source for Mail Tools 0.2.0 is available at: -https://git.add-ideas.de/lotobo/mail-tools/src/tag/v0.1.0 +https://git.add-ideas.de/lotobo/mail-tools/src/tag/v0.2.0 Build with Node.js 22+, npm 11+, and the exact dependencies in `package-lock.json`. Run `npm ci && npm run release:artifact`. diff --git a/public/docs/ARCHITECTURE.md b/public/docs/ARCHITECTURE.md index 9102c9a..6676098 100644 --- a/public/docs/ARCHITECTURE.md +++ b/public/docs/ARCHITECTURE.md @@ -1,11 +1,21 @@ # Architecture The React shell lazy-loads one workbench. `core/mime.ts` turns bounded source -text into immutable-looking header and MIME-part records; parsing never creates -DOM. Transfer decoding consumes a global byte budget. Diagnostics, comparison, +bytes into immutable-looking header and MIME-part records; parsing never creates +DOM. A one-byte internal syntax view preserves arbitrary octets, while every raw +multipart entity records exact offsets into the retained source buffer. +Transfer decoding consumes a global byte budget. Diagnostics, comparison, canonicalization and redaction are separate pure modules. +`core/mbox.ts` splits bounded mbox/mboxrd containers and derives a presentation +tree from Message-ID, In-Reply-To and References. `core/dkim.ts` implements +bounded RFC 6376 canonicalization and uses WebCrypto for RSA-SHA256 or +Ed25519-SHA256 verification. The key record is explicit user input; the app has +no DNS client and makes no network request. + Only the selected HTML text is passed through DOMPurify. It is then embedded in an iframe without sandbox capabilities and with an inner policy that denies all -connections and active content. Attachment downloads are explicit Blob URLs -with sanitized filenames. The service worker caches only same-origin app files. +connections and active content. Explicit `cid:` image references can resolve to +small safe image parts already present in the message; no external URI is +retained. Attachment downloads are explicit Blob URLs with sanitized filenames. +The service worker caches only same-origin app files. diff --git a/public/docs/PRIVACY-SECURITY.md b/public/docs/PRIVACY-SECURITY.md index 6b13e96..8300b09 100644 --- a/public/docs/PRIVACY-SECURITY.md +++ b/public/docs/PRIVACY-SECURITY.md @@ -5,9 +5,16 @@ memory. There is no storage, telemetry, analytics, remote-resource request or server integration. Closing/reloading the page clears the workbench. Limits reduce accidental resource exhaustion but do not make Mail Tools a -forensic parser. HTML loses links, styling and embedded resources by design. +forensic parser. HTML loses links, styling and external embedded resources by +design; only bounded safe image parts referenced by `cid:` can be embedded. Downloaded attachments remain untrusted. Authentication results and Received -headers are self-asserted text; no DNS or cryptographic verification occurs. +headers are self-asserted text. + +The optional DKIM lab performs local cryptographic verification against a DNS +TXT value pasted by the user. It never resolves DNS, so a passing result means +only that the message bytes match that supplied key. It does not prove that the +key is current, authoritative or obtained securely, and it does not turn other +authentication headers into verified evidence. Focused redaction removes a fixed set of top-level transport/authentication headers. It does not rewrite nested `message/rfc822` content and can leave names, diff --git a/public/sw.js b/public/sw.js index 42529c2..7fdc32b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ const CACHE_PREFIX = "mail-tools-shell-"; -const CACHE_NAME = CACHE_PREFIX + "0.1.0"; +const CACHE_NAME = CACHE_PREFIX + "0.2.0"; const CORE = [ "./", "./manifest.webmanifest", diff --git a/public/toolbox-app.json b/public/toolbox-app.json index c5e5fbc..4560b88 100644 --- a/public/toolbox-app.json +++ b/public/toolbox-app.json @@ -3,12 +3,12 @@ "schemaVersion": 1, "id": "de.add-ideas.mail-tools", "name": "Mail Tools", - "version": "0.1.0", - "description": "Inspect and redact email messages locally.", + "version": "0.2.0", + "description": "Inspect email and mailbox files locally.", "entry": "./", "icon": "./favicon.svg", "categories": ["documents", "developer", "privacy"], - "tags": ["email", "eml", "mime", "headers", "attachments"], + "tags": ["email", "eml", "mbox", "mime", "dkim", "attachments"], "integration": { "contextVersion": 1, "launchModes": ["navigate", "new-tab"], @@ -21,6 +21,18 @@ "crossOriginIsolated": false, "topLevelContext": false }, + "io": { + "accepts": [ + { "mediaType": "message/rfc822", "extensions": [".eml"] }, + { "mediaType": "application/mbox", "extensions": [".mbox", ".mbx"] }, + { "mediaType": "text/plain", "extensions": [".txt"] } + ], + "produces": [ + { "mediaType": "message/rfc822", "extensions": [".eml"] }, + { "mediaType": "application/json", "extensions": [".json"] } + ] + }, + "capabilities": { "required": [], "optional": ["web-crypto"] }, "privacy": { "processing": "local", "fileUploads": true, diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx index b0b8233..0cbcb87 100644 --- a/src/components/HelpDialog.tsx +++ b/src/components/HelpDialog.tsx @@ -32,16 +32,20 @@ export function HelpDialog({

- Open EML/MIME source, inspect its header and multipart structure, - compare body parts, save attachments, and make focused redacted copies. + Open EML/MIME source or a bounded mbox, inspect its header and multipart + structure, compare body parts, save attachments, and make focused + redacted copies.

Parsing is bounded and entirely local. HTML is sanitized and shown in an opaque sandbox whose own policy blocks every network request.

- Authentication headers are unverified claims. Redaction removes selected - transport/authentication headers only; it is not an anonymity guarantee. + Authentication headers remain claims. The DKIM lab can verify preserved + bytes against a public-key TXT record you paste, but deliberately makes + no DNS request and cannot authenticate that key. Redaction removes + selected transport/authentication headers only; it is not an anonymity + guarantee.

); diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index 4324b21..d3eefec 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -1,9 +1,23 @@ -import { useMemo, useState, type ChangeEvent } from "react"; -import { triggerBlobDownload } from "@add-ideas/toolbox-helpers"; +import { useMemo, useState, type ChangeEvent, type CSSProperties } from "react"; +import { + sanitizeDownloadFilename, + triggerBlobDownload, +} from "@add-ideas/toolbox-helpers"; import { compareBodies } from "../core/compare"; +import { + inspectDkimSignatures, + verifyDkimSignature, + type DkimVerificationResult, +} from "../core/dkim"; import { diagnoseMessage } from "../core/diagnostics"; -import { canonicalMessage, redactMessage } from "../core/export"; -import { attachments, parseMessage, walkParts } from "../core/mime"; +import { canonicalMessage, deepRedactMessage } from "../core/export"; +import { + attachments, + inlineCidResources, + parseMessage, + walkParts, +} from "../core/mime"; +import { parseMbox, searchMailbox, type ParsedMailbox } from "../core/mbox"; import { sanitizeMailHtml } from "../core/sanitize"; import type { MimePart, ParsedMessage } from "../core/types"; @@ -37,7 +51,8 @@ Content-Transfer-Encoding: base64 QXR0YWNobWVudCBwcmV2aWV3Lg== --outer--`; -type View = "structure" | "bodies" | "attachments" | "diagnostics" | "export"; +type View = + "mailbox" | "structure" | "bodies" | "attachments" | "diagnostics" | "export"; function PartTree({ part }: { part: MimePart }) { return ( @@ -70,12 +85,30 @@ export function Workbench() { ); const [error, setError] = useState(); const [view, setView] = useState("structure"); + const [mailbox, setMailbox] = useState(); + const [mailboxFilename, setMailboxFilename] = useState(""); + const [mailboxQuery, setMailboxQuery] = useState(""); + const [dkimIndex, setDkimIndex] = useState(0); + const [dkimKeyRecord, setDkimKeyRecord] = useState(""); + const [dkimResult, setDkimResult] = useState(); + const [dkimBusy, setDkimBusy] = useState(false); + const [redactTextBodies, setRedactTextBodies] = useState(false); + const [removeAttachmentPayloads, setRemoveAttachmentPayloads] = + useState(false); const textParts = useMemo( () => walkParts(message.root).filter((part) => part.text !== undefined), [message], ); const files = useMemo(() => attachments(message.root), [message]); const diagnostics = useMemo(() => diagnoseMessage(message), [message]); + const dkimSignatures = useMemo( + () => inspectDkimSignatures(message), + [message], + ); + const mailboxEntries = useMemo( + () => (mailbox ? searchMailbox(mailbox, mailboxQuery) : []), + [mailbox, mailboxQuery], + ); const [leftId, setLeftId] = useState("1.1"); const [rightId, setRightId] = useState("1.2"); const left = textParts.find((part) => part.id === leftId) ?? textParts[0]; @@ -90,16 +123,28 @@ export function Workbench() { const htmlPreview = useMemo( () => left?.mediaType === "text/html" - ? sanitizeMailHtml(left.text ?? "") + ? sanitizeMailHtml(left.text ?? "", inlineCidResources(message.root)) : undefined, - [left], + [left, message], + ); + const redacted = useMemo( + () => + deepRedactMessage(message, { + redactTextBodies, + removeAttachmentPayloads, + }), + [message, redactTextBodies, removeAttachmentPayloads], ); - const redacted = useMemo(() => redactMessage(message), [message]); const inspect = () => { try { const parsed = parseMessage(source); setMessage(parsed); + setMailbox(undefined); + setMailboxFilename(""); + setMailboxQuery(""); + setDkimIndex(0); + setDkimResult(undefined); setError(undefined); } catch (reason) { setError( @@ -113,16 +158,43 @@ export function Workbench() { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; - if (file.size > 8 * 1024 * 1024) { - setError("File exceeds the 8 MiB source limit."); + const isMbox = /\.(?:mbox|mbx)$/iu.test(file.name); + const limit = isMbox ? 64 * 1024 * 1024 : 8 * 1024 * 1024; + if (file.size > limit) { + setError( + isMbox + ? "Mailbox exceeds the 64 MiB local limit." + : "File exceeds the 8 MiB source limit.", + ); return; } try { - const text = await file.text(); - const parsed = parseMessage(text); - setSource(text); - setFilename(file.name); - setMessage(parsed); + const bytes = await file.arrayBuffer(); + if (isMbox) { + const parsedMailbox = parseMbox(bytes); + const first = parsedMailbox.entries[0]!; + setMailbox(parsedMailbox); + setMailboxFilename(file.name); + setMailboxQuery(""); + setMessage(first.message); + setSource( + new TextDecoder("utf-8", { fatal: false }).decode( + first.message.rawBytes, + ), + ); + setFilename(`${file.name} · message 1`); + setView("mailbox"); + } else { + const parsed = parseMessage(bytes); + setMailbox(undefined); + setMailboxFilename(""); + setMailboxQuery(""); + setSource(new TextDecoder("utf-8", { fatal: false }).decode(bytes)); + setFilename(file.name); + setMessage(parsed); + } + setDkimIndex(0); + setDkimResult(undefined); setError(undefined); } catch (reason) { setError( @@ -156,11 +228,11 @@ export function Workbench() {

@@ -185,7 +257,10 @@ export function Workbench() {

) : (

- 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"}.

)} @@ -194,6 +269,7 @@ export function Workbench() { + {view === "mailbox" && mailbox ? ( +
+
+
+
+

Mailbox threads

+

+ {mailboxFilename} · {mailbox.bytes.toLocaleString()} bytes +

+
+ + {mailboxEntries.length} / {mailbox.entries.length} + +
+ +
    + {mailboxEntries.map((entry) => ( +
  1. + +
  2. + ))} +
+ {mailbox.warnings.map((warning) => ( +

+ {warning} +

+ ))} +
+
+

Selected message

+
+
+
Subject
+
+ {mailbox.entries.find((entry) => entry.message === message) + ?.subject ?? "—"} +
+
+
+
MIME parts
+
{walkParts(message.root).length}
+
+
+
Attachments
+
{files.length}
+
+
+

+ 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. +

+ +
+
+ ) : null} + {view === "structure" ? (
@@ -386,7 +570,8 @@ export function Workbench() {

Header diagnostics

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.

@@ -399,6 +584,140 @@ export function Workbench() { ))} +
+
+
+

DKIM verification lab

+

+ No DNS request is made. Copy the TXT value for the displayed + selector from a source you trust, then verify the preserved + message bytes. +

+
+ {dkimSignatures.length} +
+ {dkimSignatures.length ? ( + <> + + {dkimSignatures[dkimIndex] ? ( +
+
+
DNS query name
+
+ + {dkimSignatures[dkimIndex]!.queryName || "Invalid"} + +
+
+
+
Algorithm
+
{dkimSignatures[dkimIndex]!.algorithm || "—"}
+
+
+
Canonicalization
+
+ {dkimSignatures[dkimIndex]!.headerCanonicalization || + "—"} + / + {dkimSignatures[dkimIndex]!.bodyCanonicalization || "—"} +
+
+
+ ) : null} + {dkimSignatures[dkimIndex]?.problems.map((problem) => ( +

+ {problem} +

+ ))} +