@@ -0,0 +1,39 @@
|
|||||||
|
name: Verify
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
env:
|
||||||
|
CI: "true"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
- name: Select declared npm version
|
||||||
|
run: npm install --global npm@11.17.0
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Audit runtime dependencies
|
||||||
|
run: npm audit --omit=dev --audit-level=moderate
|
||||||
|
- name: Check, test, and build
|
||||||
|
run: npm run check
|
||||||
|
- name: Install browser engines
|
||||||
|
run: npx playwright install --with-deps chromium firefox webkit
|
||||||
|
- name: Browser tests
|
||||||
|
run: npm run test:browser
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# 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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Generated
+20
-20
@@ -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
@@ -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
@@ -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"] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,523 @@
|
|||||||
|
import { base64ToBytes, bytesToBase64 } from "@add-ideas/toolbox-helpers";
|
||||||
|
import type { ParsedMessage } from "./types";
|
||||||
|
|
||||||
|
type Canonicalization = "simple" | "relaxed";
|
||||||
|
|
||||||
|
interface RawHeader {
|
||||||
|
name: string;
|
||||||
|
lowerName: string;
|
||||||
|
raw: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DkimSignatureInspection {
|
||||||
|
index: number;
|
||||||
|
domain: string;
|
||||||
|
selector: string;
|
||||||
|
queryName: string;
|
||||||
|
algorithm: string;
|
||||||
|
headerCanonicalization: string;
|
||||||
|
bodyCanonicalization: string;
|
||||||
|
signedHeaders: string[];
|
||||||
|
bodyLength?: number;
|
||||||
|
timestamp?: number;
|
||||||
|
expires?: number;
|
||||||
|
supported: boolean;
|
||||||
|
problems: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DkimVerificationResult extends DkimSignatureInspection {
|
||||||
|
bodyHash: "pass" | "fail" | "error";
|
||||||
|
signature: "pass" | "fail" | "not-checked" | "error";
|
||||||
|
status: "pass" | "fail" | "permerror" | "expired";
|
||||||
|
computedBodyHash?: string;
|
||||||
|
unsignedBodyBytes: number;
|
||||||
|
details: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreparedDkimVerification {
|
||||||
|
inspection: DkimSignatureInspection;
|
||||||
|
bodyBytes: Uint8Array<ArrayBuffer>;
|
||||||
|
headerBytes: Uint8Array<ArrayBuffer>;
|
||||||
|
computedBodyHash: string;
|
||||||
|
claimedBodyHash: string;
|
||||||
|
signatureBytes: Uint8Array<ArrayBuffer>;
|
||||||
|
unsignedBodyBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function binaryBytes(value: string): Uint8Array<ArrayBuffer> {
|
||||||
|
const bytes = new Uint8Array(value.length);
|
||||||
|
for (let index = 0; index < value.length; index += 1)
|
||||||
|
bytes[index] = value.charCodeAt(index) & 0xff;
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRawHeaders(source: string): RawHeader[] {
|
||||||
|
const lines = source.match(/[^\r\n]*(?:\r\n|\r|\n|$)/gu) ?? [];
|
||||||
|
const fields: RawHeader[] = [];
|
||||||
|
let current = "";
|
||||||
|
const finish = () => {
|
||||||
|
if (!current) return;
|
||||||
|
const colon = current.indexOf(":");
|
||||||
|
if (colon <= 0) throw new SyntaxError("Malformed raw header field.");
|
||||||
|
const name = current.slice(0, colon);
|
||||||
|
fields.push({
|
||||||
|
name,
|
||||||
|
lowerName: name.toLowerCase(),
|
||||||
|
raw: current,
|
||||||
|
value: current.slice(colon + 1).replace(/(?:\r\n|\r|\n)$/u, ""),
|
||||||
|
});
|
||||||
|
current = "";
|
||||||
|
};
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line) continue;
|
||||||
|
if (/^[ \t]/u.test(line) && current) current += line;
|
||||||
|
else {
|
||||||
|
finish();
|
||||||
|
current = line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagList(value: string, label: string): Map<string, string> {
|
||||||
|
const unfolded = value.replace(/(?:\r\n|\r|\n)[ \t]+/gu, " ");
|
||||||
|
const output = new Map<string, string>();
|
||||||
|
for (const chunk of unfolded.split(";")) {
|
||||||
|
if (!chunk.trim()) continue;
|
||||||
|
const equals = chunk.indexOf("=");
|
||||||
|
if (equals <= 0)
|
||||||
|
throw new SyntaxError(`${label} contains a malformed tag.`);
|
||||||
|
const name = chunk.slice(0, equals).trim().toLowerCase();
|
||||||
|
if (!/^[a-z][a-z0-9_]*$/u.test(name))
|
||||||
|
throw new SyntaxError(`${label} contains invalid tag ${name}.`);
|
||||||
|
if (output.has(name))
|
||||||
|
throw new SyntaxError(`${label} repeats tag ${name}.`);
|
||||||
|
output.set(name, chunk.slice(equals + 1).trim());
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInteger(
|
||||||
|
value: string | undefined,
|
||||||
|
label: string,
|
||||||
|
): number | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (!/^(?:0|[1-9]\d{0,15})$/u.test(value))
|
||||||
|
throw new SyntaxError(`${label} must be a bounded non-negative integer.`);
|
||||||
|
const number = Number(value);
|
||||||
|
if (!Number.isSafeInteger(number))
|
||||||
|
throw new RangeError(`${label} exceeds the safe integer range.`);
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalizations(
|
||||||
|
value: string | undefined,
|
||||||
|
): [Canonicalization, Canonicalization] {
|
||||||
|
const [header = "simple", body = "simple"] = (value ?? "simple/simple")
|
||||||
|
.toLowerCase()
|
||||||
|
.split("/", 2);
|
||||||
|
if (!(["simple", "relaxed"] as string[]).includes(header))
|
||||||
|
throw new SyntaxError(
|
||||||
|
`Unsupported DKIM header canonicalization ${header}.`,
|
||||||
|
);
|
||||||
|
if (!(["simple", "relaxed"] as string[]).includes(body))
|
||||||
|
throw new SyntaxError(`Unsupported DKIM body canonicalization ${body}.`);
|
||||||
|
return [header as Canonicalization, body as Canonicalization];
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalBody(source: string, mode: Canonicalization): string {
|
||||||
|
const lines = source.replace(/\r\n|\r/gu, "\n").split("\n");
|
||||||
|
if (mode === "relaxed")
|
||||||
|
for (let index = 0; index < lines.length; index += 1)
|
||||||
|
lines[index] = lines[index]!.replace(/[ \t]+/gu, " ").replace(
|
||||||
|
/[ \t]+$/u,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
while (lines.length && lines.at(-1) === "") lines.pop();
|
||||||
|
return `${lines.join("\r\n")}\r\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutSignatureValue(raw: string): string {
|
||||||
|
return raw.replace(
|
||||||
|
/([;\t \r\n]b[\t \r\n]*=[\t \r\n]*)(?:[A-Za-z0-9+/=][\t \r\n]*)*(?=;|$)/iu,
|
||||||
|
"$1",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalHeader(raw: string, mode: Canonicalization): string {
|
||||||
|
const normalized = raw.replace(/\r\n|\r|\n/gu, "\r\n").replace(/\r\n$/u, "");
|
||||||
|
if (mode === "simple") return `${normalized}\r\n`;
|
||||||
|
const unfolded = normalized.replace(/\r\n[ \t]+/gu, " ");
|
||||||
|
const colon = unfolded.indexOf(":");
|
||||||
|
if (colon <= 0)
|
||||||
|
throw new SyntaxError("Cannot canonicalize malformed header.");
|
||||||
|
const name = unfolded.slice(0, colon).toLowerCase();
|
||||||
|
const value = unfolded
|
||||||
|
.slice(colon + 1)
|
||||||
|
.replace(/[ \t]+/gu, " ")
|
||||||
|
.replace(/^[ \t]+|[ \t]+$/gu, "");
|
||||||
|
return `${name}:${value}\r\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signatureFields(message: ParsedMessage): RawHeader[] {
|
||||||
|
return parseRawHeaders(message.headerSource).filter(
|
||||||
|
(field) => field.lowerName === "dkim-signature",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectionFor(
|
||||||
|
field: RawHeader,
|
||||||
|
index: number,
|
||||||
|
): DkimSignatureInspection {
|
||||||
|
const problems: string[] = [];
|
||||||
|
let tags: Map<string, string>;
|
||||||
|
try {
|
||||||
|
tags = tagList(field.value, "DKIM-Signature");
|
||||||
|
} catch (reason) {
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
domain: "",
|
||||||
|
selector: "",
|
||||||
|
queryName: "",
|
||||||
|
algorithm: "",
|
||||||
|
headerCanonicalization: "",
|
||||||
|
bodyCanonicalization: "",
|
||||||
|
signedHeaders: [],
|
||||||
|
supported: false,
|
||||||
|
problems: [
|
||||||
|
reason instanceof Error ? reason.message : "Malformed signature.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const domain = tags.get("d") ?? "";
|
||||||
|
const selector = tags.get("s") ?? "";
|
||||||
|
const algorithm = (tags.get("a") ?? "").toLowerCase();
|
||||||
|
let headerCanonicalization = "",
|
||||||
|
bodyCanonicalization = "";
|
||||||
|
try {
|
||||||
|
[headerCanonicalization, bodyCanonicalization] = canonicalizations(
|
||||||
|
tags.get("c"),
|
||||||
|
);
|
||||||
|
} catch (reason) {
|
||||||
|
problems.push(
|
||||||
|
reason instanceof Error ? reason.message : "Invalid canonicalization.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const signedHeaders = (tags.get("h") ?? "")
|
||||||
|
.split(":")
|
||||||
|
.map((name) => name.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
for (const required of ["v", "a", "d", "s", "h", "bh", "b"])
|
||||||
|
if (!tags.has(required))
|
||||||
|
problems.push(`Required tag ${required}= is missing.`);
|
||||||
|
if (tags.get("v") !== "1")
|
||||||
|
problems.push("Only DKIM-Signature v=1 is supported.");
|
||||||
|
if (!/^[A-Za-z0-9._-]{1,63}$/u.test(selector))
|
||||||
|
problems.push("Selector is empty or invalid.");
|
||||||
|
if (
|
||||||
|
!/^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9-]{2,63}$/u.test(
|
||||||
|
domain,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
problems.push("Signing domain is empty or invalid.");
|
||||||
|
if (!signedHeaders.includes("from"))
|
||||||
|
problems.push("Signed header list omits From.");
|
||||||
|
if (!["rsa-sha256", "ed25519-sha256"].includes(algorithm))
|
||||||
|
problems.push(
|
||||||
|
`Unsupported or obsolete signing algorithm ${algorithm || "(missing)"}.`,
|
||||||
|
);
|
||||||
|
let bodyLength: number | undefined,
|
||||||
|
timestamp: number | undefined,
|
||||||
|
expires: number | undefined;
|
||||||
|
try {
|
||||||
|
bodyLength = positiveInteger(tags.get("l"), "DKIM l=");
|
||||||
|
timestamp = positiveInteger(tags.get("t"), "DKIM t=");
|
||||||
|
expires = positiveInteger(tags.get("x"), "DKIM x=");
|
||||||
|
} catch (reason) {
|
||||||
|
problems.push(
|
||||||
|
reason instanceof Error ? reason.message : "Invalid numeric tag.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
domain,
|
||||||
|
selector,
|
||||||
|
queryName: selector && domain ? `${selector}._domainkey.${domain}` : "",
|
||||||
|
algorithm,
|
||||||
|
headerCanonicalization,
|
||||||
|
bodyCanonicalization,
|
||||||
|
signedHeaders,
|
||||||
|
bodyLength,
|
||||||
|
timestamp,
|
||||||
|
expires,
|
||||||
|
supported: problems.length === 0,
|
||||||
|
problems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inspectDkimSignatures(
|
||||||
|
message: ParsedMessage,
|
||||||
|
): DkimSignatureInspection[] {
|
||||||
|
return signatureFields(message).map(inspectionFor);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareDkimVerification(
|
||||||
|
message: ParsedMessage,
|
||||||
|
signatureIndex = 0,
|
||||||
|
): Promise<PreparedDkimVerification> {
|
||||||
|
const fields = parseRawHeaders(message.headerSource);
|
||||||
|
const signatures = fields.filter(
|
||||||
|
(field) => field.lowerName === "dkim-signature",
|
||||||
|
);
|
||||||
|
const signatureField = signatures[signatureIndex];
|
||||||
|
if (!signatureField)
|
||||||
|
throw new RangeError(
|
||||||
|
`DKIM signature ${signatureIndex + 1} does not exist.`,
|
||||||
|
);
|
||||||
|
const inspection = inspectionFor(signatureField, signatureIndex);
|
||||||
|
if (!inspection.supported) throw new TypeError(inspection.problems.join(" "));
|
||||||
|
const tags = tagList(signatureField.value, "DKIM-Signature");
|
||||||
|
const [, bodyMode] = canonicalizations(tags.get("c"));
|
||||||
|
const canonical = binaryBytes(
|
||||||
|
canonicalBody(message.raw.slice(message.bodyStart), bodyMode),
|
||||||
|
);
|
||||||
|
const requestedLength = inspection.bodyLength ?? canonical.byteLength;
|
||||||
|
if (requestedLength > canonical.byteLength)
|
||||||
|
throw new RangeError(
|
||||||
|
`DKIM l=${requestedLength} exceeds the ${canonical.byteLength}-byte canonical body.`,
|
||||||
|
);
|
||||||
|
const bodyBytes = canonical.slice(0, requestedLength);
|
||||||
|
const bodyDigest = new Uint8Array(
|
||||||
|
await crypto.subtle.digest("SHA-256", bodyBytes),
|
||||||
|
);
|
||||||
|
const computedBodyHash = bytesToBase64(bodyDigest);
|
||||||
|
|
||||||
|
const selected: string[] = [];
|
||||||
|
const usage = new Map<string, number>();
|
||||||
|
for (const name of inspection.signedHeaders) {
|
||||||
|
const alreadyUsed = usage.get(name) ?? 0;
|
||||||
|
let seen = 0,
|
||||||
|
found: RawHeader | undefined;
|
||||||
|
for (let index = fields.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (fields[index]!.lowerName !== name) continue;
|
||||||
|
if (seen === alreadyUsed) {
|
||||||
|
found = fields[index];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
seen += 1;
|
||||||
|
}
|
||||||
|
usage.set(name, alreadyUsed + 1);
|
||||||
|
if (found)
|
||||||
|
selected.push(
|
||||||
|
canonicalHeader(
|
||||||
|
found.raw,
|
||||||
|
inspection.headerCanonicalization as Canonicalization,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
selected.push(
|
||||||
|
canonicalHeader(
|
||||||
|
withoutSignatureValue(signatureField.raw),
|
||||||
|
inspection.headerCanonicalization as Canonicalization,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
inspection,
|
||||||
|
bodyBytes,
|
||||||
|
headerBytes: binaryBytes(selected.join("")),
|
||||||
|
computedBodyHash,
|
||||||
|
claimedBodyHash: (tags.get("bh") ?? "").replace(/[ \t\r\n]/gu, ""),
|
||||||
|
signatureBytes: base64ToBytes(
|
||||||
|
(tags.get("b") ?? "").replace(/[ \t\r\n]/gu, ""),
|
||||||
|
{ maxOutputBytes: 1024 * 1024 },
|
||||||
|
),
|
||||||
|
unsignedBodyBytes: canonical.byteLength - requestedLength,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function domainOfIdentity(identity: string): string | undefined {
|
||||||
|
const at = identity.lastIndexOf("@");
|
||||||
|
return at >= 0 ? identity.slice(at + 1).toLowerCase() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyDkimSignature(
|
||||||
|
message: ParsedMessage,
|
||||||
|
signatureIndex: number,
|
||||||
|
dnsTxtRecord: string,
|
||||||
|
now = Date.now(),
|
||||||
|
): Promise<DkimVerificationResult> {
|
||||||
|
let prepared: PreparedDkimVerification;
|
||||||
|
try {
|
||||||
|
prepared = await prepareDkimVerification(message, signatureIndex);
|
||||||
|
} catch (reason) {
|
||||||
|
const inspection =
|
||||||
|
inspectDkimSignatures(message)[signatureIndex] ??
|
||||||
|
inspectionFor(
|
||||||
|
{
|
||||||
|
name: "DKIM-Signature",
|
||||||
|
lowerName: "dkim-signature",
|
||||||
|
raw: "",
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
signatureIndex,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...inspection,
|
||||||
|
bodyHash: "error",
|
||||||
|
signature: "error",
|
||||||
|
status: "permerror",
|
||||||
|
unsignedBodyBytes: 0,
|
||||||
|
details: [
|
||||||
|
reason instanceof Error ? reason.message : "Preparation failed.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { inspection } = prepared;
|
||||||
|
const details: string[] = [];
|
||||||
|
const result = (
|
||||||
|
overrides: Partial<DkimVerificationResult>,
|
||||||
|
): DkimVerificationResult => ({
|
||||||
|
...inspection,
|
||||||
|
bodyHash: "pass",
|
||||||
|
signature: "not-checked",
|
||||||
|
status: "fail",
|
||||||
|
computedBodyHash: prepared.computedBodyHash,
|
||||||
|
unsignedBodyBytes: prepared.unsignedBodyBytes,
|
||||||
|
details,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
if (prepared.computedBodyHash !== prepared.claimedBodyHash) {
|
||||||
|
details.push("Canonicalized body SHA-256 does not match bh=.");
|
||||||
|
return result({
|
||||||
|
bodyHash: "fail",
|
||||||
|
signature: "not-checked",
|
||||||
|
status: "fail",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (prepared.unsignedBodyBytes)
|
||||||
|
details.push(
|
||||||
|
`The l= tag leaves ${prepared.unsignedBodyBytes.toLocaleString()} canonical body bytes unsigned.`,
|
||||||
|
);
|
||||||
|
let keyTags: Map<string, string>;
|
||||||
|
try {
|
||||||
|
const record = dnsTxtRecord
|
||||||
|
.trim()
|
||||||
|
.replace(/^\([^)]*\)$/u, (value) => value.slice(1, -1))
|
||||||
|
.replace(/^"|"$/gu, "")
|
||||||
|
.replace(/"\s*"/gu, "");
|
||||||
|
keyTags = tagList(record, "DKIM key record");
|
||||||
|
if (keyTags.has("v") && keyTags.get("v") !== "DKIM1")
|
||||||
|
throw new SyntaxError("Key record v= is not DKIM1.");
|
||||||
|
const services = (keyTags.get("s") ?? "*").toLowerCase().split(":");
|
||||||
|
if (!services.includes("*") && !services.includes("email"))
|
||||||
|
throw new TypeError("Key record is not authorized for email service.");
|
||||||
|
const hashes = keyTags.get("h")?.toLowerCase().split(":");
|
||||||
|
if (hashes && !hashes.includes("sha256"))
|
||||||
|
throw new TypeError("Key record does not allow SHA-256.");
|
||||||
|
if (!keyTags.has("p")) throw new SyntaxError("Key record has no p= tag.");
|
||||||
|
if (!(keyTags.get("p") ?? "").replace(/\s/gu, ""))
|
||||||
|
throw new TypeError("DKIM public key is revoked (empty p=). ");
|
||||||
|
const expectedKeyType = inspection.algorithm.startsWith("ed25519")
|
||||||
|
? "ed25519"
|
||||||
|
: "rsa";
|
||||||
|
if ((keyTags.get("k") ?? "rsa").toLowerCase() !== expectedKeyType)
|
||||||
|
throw new TypeError(`Key type does not match ${inspection.algorithm}.`);
|
||||||
|
const signatureTags = tagList(
|
||||||
|
signatureFields(message)[signatureIndex]!.value,
|
||||||
|
"DKIM-Signature",
|
||||||
|
);
|
||||||
|
const identityDomain = domainOfIdentity(
|
||||||
|
signatureTags.get("i") ?? `@${inspection.domain}`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!identityDomain ||
|
||||||
|
(identityDomain !== inspection.domain.toLowerCase() &&
|
||||||
|
!identityDomain.endsWith(`.${inspection.domain.toLowerCase()}`))
|
||||||
|
)
|
||||||
|
throw new TypeError("AUID i= is not within the signing domain d=.");
|
||||||
|
if (
|
||||||
|
(keyTags.get("t") ?? "").toLowerCase().split(":").includes("s") &&
|
||||||
|
identityDomain !== inspection.domain.toLowerCase()
|
||||||
|
)
|
||||||
|
throw new TypeError("Strict key flag t=s requires an exact AUID domain.");
|
||||||
|
} catch (reason) {
|
||||||
|
details.push(
|
||||||
|
reason instanceof Error ? reason.message : "Key record is invalid.",
|
||||||
|
);
|
||||||
|
return result({ signature: "error", status: "permerror" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const publicBytes = base64ToBytes(
|
||||||
|
(keyTags.get("p") ?? "").replace(/\s/gu, ""),
|
||||||
|
{ maxOutputBytes: 64 * 1024 },
|
||||||
|
);
|
||||||
|
let verified: boolean;
|
||||||
|
if (inspection.algorithm === "rsa-sha256") {
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
"spki",
|
||||||
|
publicBytes,
|
||||||
|
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["verify"],
|
||||||
|
);
|
||||||
|
const modulusLength = (key.algorithm as RsaHashedKeyAlgorithm)
|
||||||
|
.modulusLength;
|
||||||
|
if (modulusLength < 1024)
|
||||||
|
throw new TypeError(
|
||||||
|
`RSA key is only ${modulusLength} bits; 1,024 are required.`,
|
||||||
|
);
|
||||||
|
verified = await crypto.subtle.verify(
|
||||||
|
"RSASSA-PKCS1-v1_5",
|
||||||
|
key,
|
||||||
|
prepared.signatureBytes,
|
||||||
|
prepared.headerBytes,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (publicBytes.byteLength !== 32)
|
||||||
|
throw new TypeError("Ed25519 DKIM public key must contain 32 bytes.");
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
publicBytes,
|
||||||
|
{ name: "Ed25519" },
|
||||||
|
false,
|
||||||
|
["verify"],
|
||||||
|
);
|
||||||
|
const headerHash = await crypto.subtle.digest(
|
||||||
|
"SHA-256",
|
||||||
|
prepared.headerBytes,
|
||||||
|
);
|
||||||
|
verified = await crypto.subtle.verify(
|
||||||
|
"Ed25519",
|
||||||
|
key,
|
||||||
|
prepared.signatureBytes,
|
||||||
|
headerHash,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!verified) {
|
||||||
|
details.push("Cryptographic header signature did not verify.");
|
||||||
|
return result({ signature: "fail", status: "fail" });
|
||||||
|
}
|
||||||
|
} catch (reason) {
|
||||||
|
details.push(
|
||||||
|
reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: "Cryptographic verification failed.",
|
||||||
|
);
|
||||||
|
return result({ signature: "error", status: "permerror" });
|
||||||
|
}
|
||||||
|
if (inspection.expires !== undefined && now / 1_000 > inspection.expires) {
|
||||||
|
details.push("Signature x= time has expired.");
|
||||||
|
return result({ signature: "pass", status: "expired" });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
inspection.timestamp !== undefined &&
|
||||||
|
inspection.timestamp > now / 1_000 + 300
|
||||||
|
)
|
||||||
|
details.push("Signature t= time is more than five minutes in the future.");
|
||||||
|
details.push(
|
||||||
|
"Body hash and cryptographic header signature verify against the pasted key.",
|
||||||
|
);
|
||||||
|
return result({ signature: "pass", status: "pass" });
|
||||||
|
}
|
||||||
+117
-2
@@ -1,5 +1,6 @@
|
|||||||
import { stableStringify } from "@add-ideas/toolbox-helpers";
|
import { 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { headerValues, parseMessage, walkParts } from "./mime";
|
||||||
|
import type { ParsedMessage } from "./types";
|
||||||
|
|
||||||
|
export interface MailboxEntry {
|
||||||
|
index: number;
|
||||||
|
envelope: string;
|
||||||
|
message: ParsedMessage;
|
||||||
|
messageId?: string;
|
||||||
|
parentIndex?: number;
|
||||||
|
depth: number;
|
||||||
|
subject: string;
|
||||||
|
from: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedMailbox {
|
||||||
|
entries: MailboxEntry[];
|
||||||
|
bytes: number;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MailboxSearchOptions {
|
||||||
|
includeBodies?: boolean;
|
||||||
|
maxBodyCharactersPerMessage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MBOX_LIMITS = {
|
||||||
|
bytes: 64 * 1024 * 1024,
|
||||||
|
messages: 2_000,
|
||||||
|
threadDepth: 100,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function inputBytes(input: ArrayBuffer | Uint8Array): Uint8Array<ArrayBuffer> {
|
||||||
|
if (input instanceof ArrayBuffer) return new Uint8Array(input.slice(0));
|
||||||
|
const copy = new Uint8Array(input.byteLength);
|
||||||
|
copy.set(input);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function binaryString(bytes: Uint8Array): string {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (let offset = 0; offset < bytes.length; offset += 32_768)
|
||||||
|
chunks.push(
|
||||||
|
String.fromCharCode(...bytes.subarray(offset, offset + 32_768)),
|
||||||
|
);
|
||||||
|
return chunks.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesFromBinary(value: string): Uint8Array<ArrayBuffer> {
|
||||||
|
const output = new Uint8Array(value.length);
|
||||||
|
for (let index = 0; index < value.length; index += 1)
|
||||||
|
output[index] = value.charCodeAt(index) & 0xff;
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageIds(value: string): string[] {
|
||||||
|
const bracketed = value.match(/<[^<>\s]+>/gu);
|
||||||
|
if (bracketed) return bracketed.map((item) => item.toLowerCase());
|
||||||
|
return value
|
||||||
|
.split(/\s+/u)
|
||||||
|
.map((item) => item.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unescapeMboxrd(source: string): string {
|
||||||
|
return source.replace(
|
||||||
|
/^(>+)From /gmu,
|
||||||
|
(_whole, quote: string) => `${quote.slice(1)}From `,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMbox(input: ArrayBuffer | Uint8Array): ParsedMailbox {
|
||||||
|
const bytes = inputBytes(input);
|
||||||
|
if (bytes.byteLength > MBOX_LIMITS.bytes)
|
||||||
|
throw new RangeError("Mailbox exceeds the 64 MiB local limit.");
|
||||||
|
const source = binaryString(bytes);
|
||||||
|
const delimiter = /^From [^\r\n]*(?:\r\n|\n|\r)/gmu;
|
||||||
|
const boundaries: Array<{
|
||||||
|
start: number;
|
||||||
|
bodyStart: number;
|
||||||
|
envelope: string;
|
||||||
|
}> = [];
|
||||||
|
for (const match of source.matchAll(delimiter)) {
|
||||||
|
boundaries.push({
|
||||||
|
start: match.index,
|
||||||
|
bodyStart: match.index + match[0].length,
|
||||||
|
envelope: match[0].replace(/[\r\n]+$/u, ""),
|
||||||
|
});
|
||||||
|
if (boundaries.length > MBOX_LIMITS.messages)
|
||||||
|
throw new RangeError(
|
||||||
|
`Mailbox exceeds ${MBOX_LIMITS.messages.toLocaleString()} messages.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!boundaries.length)
|
||||||
|
throw new SyntaxError("No mbox envelope separator line was found.");
|
||||||
|
const warnings: string[] = [];
|
||||||
|
if (boundaries[0]!.start > 0 && source.slice(0, boundaries[0]!.start).trim())
|
||||||
|
warnings.push(
|
||||||
|
"Bytes before the first mbox envelope separator were ignored.",
|
||||||
|
);
|
||||||
|
const entries: MailboxEntry[] = boundaries.map((boundary, index) => {
|
||||||
|
const end = boundaries[index + 1]?.start ?? source.length;
|
||||||
|
let entity = source.slice(boundary.bodyStart, end);
|
||||||
|
entity = entity.replace(/(?:\r\n|\n|\r)$/u, "");
|
||||||
|
const message = parseMessage(bytesFromBinary(unescapeMboxrd(entity)));
|
||||||
|
const headers = message.root.headers;
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
envelope: boundary.envelope,
|
||||||
|
message,
|
||||||
|
messageId: messageIds(headerValues(headers, "message-id")[0] ?? "")[0],
|
||||||
|
depth: 0,
|
||||||
|
subject: headerValues(headers, "subject")[0] ?? "(no subject)",
|
||||||
|
from: headerValues(headers, "from")[0] ?? "(unknown sender)",
|
||||||
|
date: headerValues(headers, "date")[0] ?? "(no date)",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const byId = new Map<string, number>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const headers = entry.message.root.headers;
|
||||||
|
const candidates = [
|
||||||
|
...messageIds(headerValues(headers, "in-reply-to")[0] ?? ""),
|
||||||
|
...messageIds(headerValues(headers, "references")[0] ?? "").reverse(),
|
||||||
|
];
|
||||||
|
const parent = candidates
|
||||||
|
.map((id) => byId.get(id))
|
||||||
|
.find(
|
||||||
|
(index): index is number => index !== undefined && index < entry.index,
|
||||||
|
);
|
||||||
|
if (parent !== undefined) {
|
||||||
|
entry.parentIndex = parent;
|
||||||
|
entry.depth = Math.min(
|
||||||
|
entries[parent]!.depth + 1,
|
||||||
|
MBOX_LIMITS.threadDepth,
|
||||||
|
);
|
||||||
|
if (entry.depth === MBOX_LIMITS.threadDepth)
|
||||||
|
warnings.push(
|
||||||
|
`Thread depth was capped at ${MBOX_LIMITS.threadDepth} for message ${entry.index + 1}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.messageId) byId.set(entry.messageId, entry.index);
|
||||||
|
}
|
||||||
|
return { entries, bytes: bytes.byteLength, warnings: [...new Set(warnings)] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchMailbox(
|
||||||
|
mailbox: ParsedMailbox,
|
||||||
|
query: string,
|
||||||
|
options: MailboxSearchOptions = {},
|
||||||
|
): MailboxEntry[] {
|
||||||
|
const terms = query
|
||||||
|
.normalize("NFKC")
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.split(/\s+/u)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 20);
|
||||||
|
if (!terms.length) return mailbox.entries;
|
||||||
|
const includeBodies = options.includeBodies ?? true;
|
||||||
|
const bodyLimit = Math.min(
|
||||||
|
256 * 1024,
|
||||||
|
Math.max(0, options.maxBodyCharactersPerMessage ?? 64 * 1024),
|
||||||
|
);
|
||||||
|
return mailbox.entries.filter((entry) => {
|
||||||
|
const headers = entry.message.root.headers
|
||||||
|
.map((header) => `${header.name} ${header.value}`)
|
||||||
|
.join("\n");
|
||||||
|
const filenames = walkParts(entry.message.root)
|
||||||
|
.map((part) => part.filename ?? "")
|
||||||
|
.join("\n");
|
||||||
|
const bodies = includeBodies
|
||||||
|
? walkParts(entry.message.root)
|
||||||
|
.map((part) => part.text ?? "")
|
||||||
|
.join("\n")
|
||||||
|
.slice(0, bodyLimit)
|
||||||
|
: "";
|
||||||
|
const haystack = `${entry.envelope}\n${headers}\n${filenames}\n${bodies}`
|
||||||
|
.normalize("NFKC")
|
||||||
|
.toLocaleLowerCase();
|
||||||
|
return terms.every((term) => haystack.includes(term));
|
||||||
|
});
|
||||||
|
}
|
||||||
+209
-37
@@ -1,5 +1,4 @@
|
|||||||
import {
|
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
@@ -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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = "0.1.0";
|
export const APP_VERSION = "0.2.0";
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ test("integrates help, dark theme, PWA identity and hardened headers", async ({
|
|||||||
const manifest = await request.get("/deep/nested/mail/toolbox-app.json");
|
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 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("keeps the primary workspace inside a narrow viewport", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/deep/nested/mail/");
|
||||||
|
await expect(page.locator("main").first()).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator("main .loading, main .workbench-loading"),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
const widths = await page.evaluate(() => ({
|
||||||
|
content: document.documentElement.scrollWidth,
|
||||||
|
viewport: document.documentElement.clientWidth,
|
||||||
|
}));
|
||||||
|
expect(widths.viewport).toBeLessThanOrEqual(430);
|
||||||
|
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { deepRedactMessage } from "../../src/core/export";
|
||||||
|
import { parseMessage } from "../../src/core/mime";
|
||||||
|
|
||||||
|
describe("deep redaction", () => {
|
||||||
|
it("redacts nested headers, text bodies, and attachment payloads", () => {
|
||||||
|
const message = parseMessage(`From: root@example.test
|
||||||
|
Received: private-root
|
||||||
|
Content-Type: multipart/mixed; boundary=x
|
||||||
|
|
||||||
|
--x
|
||||||
|
Content-Type: message/rfc822
|
||||||
|
|
||||||
|
From: nested@example.test
|
||||||
|
Received: private-nested
|
||||||
|
Content-Type: text/plain
|
||||||
|
|
||||||
|
Nested secret
|
||||||
|
--x
|
||||||
|
Content-Type: application/octet-stream; name="secret.bin"
|
||||||
|
Content-Disposition: attachment; filename="secret.bin"
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
|
||||||
|
AQIDBA==
|
||||||
|
--x--
|
||||||
|
`);
|
||||||
|
const result = deepRedactMessage(message, {
|
||||||
|
redactTextBodies: true,
|
||||||
|
removeAttachmentPayloads: true,
|
||||||
|
});
|
||||||
|
expect(result.output).not.toMatch(
|
||||||
|
/private-root|private-nested|Nested secret|AQIDBA/iu,
|
||||||
|
);
|
||||||
|
expect(result.output).toContain(
|
||||||
|
"W0F0dGFjaG1lbnQgcGF5bG9hZCByZW1vdmVkIGxvY2FsbHkuXQ",
|
||||||
|
);
|
||||||
|
expect(result.removedHeaders).toHaveLength(2);
|
||||||
|
expect(result.redactedTextParts).toEqual(["1.1"]);
|
||||||
|
expect(result.removedAttachments).toMatchObject([
|
||||||
|
{ part: "2", filename: "secret.bin", bytes: 4 },
|
||||||
|
]);
|
||||||
|
expect(JSON.parse(result.report)).toMatchObject({
|
||||||
|
operation: "mail-deep-redaction",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { bytesToBase64 } from "@add-ideas/toolbox-helpers";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
inspectDkimSignatures,
|
||||||
|
prepareDkimVerification,
|
||||||
|
verifyDkimSignature,
|
||||||
|
} from "../../src/core/dkim";
|
||||||
|
import { parseMessage } from "../../src/core/mime";
|
||||||
|
|
||||||
|
async function signedMessage() {
|
||||||
|
const keyPair = await crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: "RSASSA-PKCS1-v1_5",
|
||||||
|
modulusLength: 1024,
|
||||||
|
publicExponent: new Uint8Array([1, 0, 1]),
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
["sign", "verify"],
|
||||||
|
);
|
||||||
|
const body = "Hello DKIM!\r\n";
|
||||||
|
const bodyHash = bytesToBase64(
|
||||||
|
new Uint8Array(
|
||||||
|
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(body)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const unsigned = [
|
||||||
|
"From: Ada <ada@example.test>",
|
||||||
|
"Subject: Local verification",
|
||||||
|
`DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.test; s=mail; h=from:subject; bh=${bodyHash}; b=`,
|
||||||
|
"",
|
||||||
|
body,
|
||||||
|
].join("\r\n");
|
||||||
|
const prepared = await prepareDkimVerification(parseMessage(unsigned));
|
||||||
|
const signature = bytesToBase64(
|
||||||
|
new Uint8Array(
|
||||||
|
await crypto.subtle.sign(
|
||||||
|
"RSASSA-PKCS1-v1_5",
|
||||||
|
keyPair.privateKey,
|
||||||
|
prepared.headerBytes,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const publicKey = bytesToBase64(
|
||||||
|
new Uint8Array(await crypto.subtle.exportKey("spki", keyPair.publicKey)),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
source: unsigned.replace(/; b=(?=\r\n)/u, `; b=${signature}`),
|
||||||
|
keyRecord: `v=DKIM1; k=rsa; h=sha256; s=email; p=${publicKey}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DKIM laboratory", () => {
|
||||||
|
it("inspects signatures without network access", () => {
|
||||||
|
const message = parseMessage(
|
||||||
|
"From: a@example.test\r\nDKIM-Signature: v=1; a=rsa-sha256; d=example.test; s=mail; c=relaxed/relaxed; h=from; bh=YQ==; b=Yg==\r\n\r\na",
|
||||||
|
);
|
||||||
|
expect(inspectDkimSignatures(message)[0]).toMatchObject({
|
||||||
|
queryName: "mail._domainkey.example.test",
|
||||||
|
algorithm: "rsa-sha256",
|
||||||
|
headerCanonicalization: "relaxed",
|
||||||
|
bodyCanonicalization: "relaxed",
|
||||||
|
supported: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies body and header signatures against a pasted key record", async () => {
|
||||||
|
const fixture = await signedMessage();
|
||||||
|
const result = await verifyDkimSignature(
|
||||||
|
parseMessage(fixture.source),
|
||||||
|
0,
|
||||||
|
fixture.keyRecord,
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
bodyHash: "pass",
|
||||||
|
signature: "pass",
|
||||||
|
status: "pass",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a changed body before checking the signature", async () => {
|
||||||
|
const fixture = await signedMessage();
|
||||||
|
const changed = fixture.source.replace("Hello DKIM!", "Hello altered!");
|
||||||
|
const result = await verifyDkimSignature(
|
||||||
|
parseMessage(changed),
|
||||||
|
0,
|
||||||
|
fixture.keyRecord,
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
bodyHash: "fail",
|
||||||
|
signature: "not-checked",
|
||||||
|
status: "fail",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed for malformed key records and unsupported signatures", async () => {
|
||||||
|
const fixture = await signedMessage();
|
||||||
|
expect(
|
||||||
|
await verifyDkimSignature(parseMessage(fixture.source), 0, "v=DKIM1; p="),
|
||||||
|
).toMatchObject({ signature: "error", status: "permerror" });
|
||||||
|
const obsolete = parseMessage(
|
||||||
|
fixture.source.replace("a=rsa-sha256", "a=rsa-sha1"),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await verifyDkimSignature(obsolete, 0, fixture.keyRecord),
|
||||||
|
).toMatchObject({
|
||||||
|
status: "permerror",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseMbox, searchMailbox } from "../../src/core/mbox";
|
||||||
|
|
||||||
|
describe("mbox import and threading", () => {
|
||||||
|
it("splits mboxrd messages, unescapes body From lines, and threads replies", () => {
|
||||||
|
const source = `From sender@example.test Tue Sep 1 10:00:00 2026
|
||||||
|
From: Sender <sender@example.test>
|
||||||
|
Date: Tue, 01 Sep 2026 10:00:00 +0000
|
||||||
|
Message-ID: <root@example.test>
|
||||||
|
Subject: Root
|
||||||
|
|
||||||
|
First body
|
||||||
|
>From escaped body line
|
||||||
|
From reply@example.test Tue Sep 1 10:01:00 2026
|
||||||
|
From: Reply <reply@example.test>
|
||||||
|
Date: Tue, 01 Sep 2026 10:01:00 +0000
|
||||||
|
Message-ID: <reply@example.test>
|
||||||
|
In-Reply-To: <root@example.test>
|
||||||
|
References: <root@example.test>
|
||||||
|
Subject: Re: Root
|
||||||
|
|
||||||
|
Reply body
|
||||||
|
`;
|
||||||
|
const mailbox = parseMbox(new TextEncoder().encode(source));
|
||||||
|
expect(mailbox.entries).toHaveLength(2);
|
||||||
|
expect(mailbox.entries[0]?.message.bodySource).toContain(
|
||||||
|
"From escaped body line",
|
||||||
|
);
|
||||||
|
expect(mailbox.entries[1]).toMatchObject({
|
||||||
|
parentIndex: 0,
|
||||||
|
depth: 1,
|
||||||
|
subject: "Re: Root",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
searchMailbox(mailbox, "escaped body").map((entry) => entry.index),
|
||||||
|
).toEqual([0]);
|
||||||
|
expect(
|
||||||
|
searchMailbox(mailbox, "reply root", { includeBodies: false }),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires an envelope separator", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseMbox(new TextEncoder().encode("From: a@example.test\n\nbody")),
|
||||||
|
).toThrow(/envelope separator/iu);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -53,6 +53,33 @@ describe("MIME parser", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves arbitrary source octets and exact multipart byte ranges", () => {
|
||||||
|
const prefix = new TextEncoder().encode(
|
||||||
|
"From: a@example.test\r\nContent-Type: multipart/mixed; boundary=x\r\n\r\n--x\r\nContent-Type: application/octet-stream\r\n\r\n",
|
||||||
|
);
|
||||||
|
const suffix = new TextEncoder().encode("\r\n--x--\r\n");
|
||||||
|
const bytes = new Uint8Array(prefix.length + 4 + suffix.length);
|
||||||
|
bytes.set(prefix);
|
||||||
|
bytes.set([0x80, 0x81, 0xfe, 0xff], prefix.length);
|
||||||
|
bytes.set(suffix, prefix.length + 4);
|
||||||
|
const parsed = parseMessage(bytes);
|
||||||
|
expect([...parsed.root.children[0]!.bytes]).toEqual([
|
||||||
|
0x80, 0x81, 0xfe, 0xff,
|
||||||
|
]);
|
||||||
|
expect([...parsed.rawBytes]).toEqual([...bytes]);
|
||||||
|
const range = parsed.root.children[0]!.sourceRange!;
|
||||||
|
expect([...parsed.rawBytes.slice(range.bodyStart, range.end)]).toEqual([
|
||||||
|
0x80, 0x81, 0xfe, 0xff,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assembles RFC 2231 parameter continuations", () => {
|
||||||
|
const parsed = parseMessage(
|
||||||
|
"From: a@example.test\r\nContent-Type: application/octet-stream\r\nContent-Disposition: attachment; filename*0*=UTF-8''long%20; filename*1*=name%E2%9C%93.bin\r\n\r\ndata",
|
||||||
|
);
|
||||||
|
expect(attachments(parsed.root)[0]?.filename).toBe("long name✓.bin");
|
||||||
|
});
|
||||||
|
|
||||||
it("parses nested message/rfc822 entities", () => {
|
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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user