From ebf88be32d1e8fa30664c65e289a9844540d3e37 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 9 Sep 2026 18:49:45 +0900 Subject: [PATCH 1/4] Compose MIME without a transport connection Add @upyo/mime so applications can archive messages or hand composed bytes to SMTP and JMAP raw delivery without configuring an SMTP connection. Share the serializer and DKIM implementation with SMTP while preserving its envelope, delivery-status, size checks, and replay-error behavior. Keep unsigned attachments lazy and provide independent, cancellable readers. Support buffered and replay-checked streaming DKIM with portable hashing, and correct quoted-printable and MIME header line handling. Verify native runtimes and workerd without Node compatibility, with independent MIME parsing and signature checks. Closes https://github.com/dahlia/upyo/issues/68 Assisted-by: Codex:gpt-6-astra Assisted-by: Claude Code:claude-fable-5-1 --- .github/workflows/main.yaml | 13 +- AGENTS.md | 1 + CHANGES.md | 18 +- README.md | 3 + changes.d/mime/mime-composition.md | 10 + changes.d/smtp/mime-line-encoding.md | 9 + changes.d/smtp/smtputf8.md | 3 +- deno.lock | 530 +++++++++- docs/.vitepress/config.mts | 3 + docs/messages/compose.md | 3 + docs/messages/mime.md | 143 +++ docs/package.json | 3 +- docs/transports/jmap.md | 3 + docs/transports/smtp.md | 3 + mise.toml | 13 +- packages/jmap/mise.toml | 4 +- packages/jmap/package.json | 3 +- .../jmap/src/raw-message.integration.test.ts | 72 ++ packages/mime/README.md | 26 + packages/mime/deno.json | 19 + packages/mime/edge/runner.mjs | 71 ++ packages/mime/edge/worker.ts | 107 ++ packages/mime/mise.toml | 48 + packages/mime/package.json | 80 ++ packages/mime/src/bytes.ts | 12 + packages/mime/src/cancellation.test.ts | 169 ++++ packages/mime/src/compose.test.ts | 293 ++++++ .../{smtp => mime}/src/dkim/body-hash.test.ts | 0 packages/{smtp => mime}/src/dkim/body-hash.ts | 7 +- .../src/dkim/canonicalize.test.ts | 0 .../{smtp => mime}/src/dkim/canonicalize.ts | 10 +- packages/{smtp => mime}/src/dkim/index.ts | 4 +- packages/{smtp => mime}/src/dkim/sign.test.ts | 0 packages/{smtp => mime}/src/dkim/sign.ts | 4 +- packages/{smtp => mime}/src/dkim/types.ts | 18 +- packages/mime/src/index.ts | 90 ++ packages/mime/src/internal.ts | 16 + packages/mime/src/message.ts | 740 ++++++++++++++ .../{smtp => mime}/src/mime-stream.test.ts | 6 +- packages/{smtp => mime}/src/mime-stream.ts | 12 +- packages/mime/src/stream.ts | 142 +++ .../mime/src/test-utils/dkim-test-keys.ts | 92 ++ packages/mime/src/test-utils/verify-dkim.ts | 53 + packages/mime/tsdown.config.ts | 8 + packages/smtp/mise.toml | 1 + packages/smtp/package.json | 3 + packages/smtp/src/config.ts | 4 +- packages/smtp/src/index.ts | 2 +- packages/smtp/src/message-converter.ts | 830 +--------------- packages/smtp/src/message-stream.test.ts | 4 +- packages/smtp/src/message-stream.ts | 115 +-- packages/smtp/src/mime-regression.test.ts | 48 + .../smtp/src/raw-message.integration.test.ts | 138 +++ packages/smtp/src/smtp-connection.ts | 1 + .../src/smtp-transport.dkim.mailpit.test.ts | 2 +- packages/smtp/src/smtp-transport.dkim.test.ts | 2 +- packages/smtp/src/smtp-transport.ts | 2 +- pnpm-lock.yaml | 929 ++++++++++++++---- pnpm-workspace.yaml | 4 + 59 files changed, 3828 insertions(+), 1121 deletions(-) create mode 100644 changes.d/mime/mime-composition.md create mode 100644 changes.d/smtp/mime-line-encoding.md create mode 100644 docs/messages/mime.md create mode 100644 packages/mime/README.md create mode 100644 packages/mime/deno.json create mode 100644 packages/mime/edge/runner.mjs create mode 100644 packages/mime/edge/worker.ts create mode 100644 packages/mime/mise.toml create mode 100644 packages/mime/package.json create mode 100644 packages/mime/src/bytes.ts create mode 100644 packages/mime/src/cancellation.test.ts create mode 100644 packages/mime/src/compose.test.ts rename packages/{smtp => mime}/src/dkim/body-hash.test.ts (100%) rename packages/{smtp => mime}/src/dkim/body-hash.ts (93%) rename packages/{smtp => mime}/src/dkim/canonicalize.test.ts (100%) rename packages/{smtp => mime}/src/dkim/canonicalize.ts (98%) rename packages/{smtp => mime}/src/dkim/index.ts (88%) rename packages/{smtp => mime}/src/dkim/sign.test.ts (100%) rename packages/{smtp => mime}/src/dkim/sign.ts (99%) rename packages/{smtp => mime}/src/dkim/types.ts (96%) create mode 100644 packages/mime/src/index.ts create mode 100644 packages/mime/src/internal.ts create mode 100644 packages/mime/src/message.ts rename packages/{smtp => mime}/src/mime-stream.test.ts (93%) rename packages/{smtp => mime}/src/mime-stream.ts (85%) create mode 100644 packages/mime/src/stream.ts create mode 100644 packages/mime/src/test-utils/dkim-test-keys.ts create mode 100644 packages/mime/src/test-utils/verify-dkim.ts create mode 100644 packages/mime/tsdown.config.ts create mode 100644 packages/smtp/src/mime-regression.test.ts diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index f5c2bc0..3dcdb83 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -6,6 +6,17 @@ concurrency: cancel-in-progress: true jobs: + test-edge: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: jdx/mise-action@v4 + with: + experimental: true + cache: true + cache_key_prefix: mise-v4 + - run: mise run test:edge + test-node: runs-on: ubuntu-latest env: @@ -172,7 +183,7 @@ jobs: publish: if: github.event_name == 'push' - needs: [test-node, test-deno, test-bun, check] + needs: [test-node, test-deno, test-bun, test-edge, check] runs-on: ubuntu-latest permissions: contents: read diff --git a/AGENTS.md b/AGENTS.md index e12bee4..af5e5d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ functions. It's structured as a monorepo with multiple packages: *Repository*: - *@upyo/core*: Shared types and interfaces for email messages + - *@upyo/mime*: Portable MIME composition and DKIM signing - *@upyo/smtp*: SMTP transport implementation - *@upyo/lettermint*: Lettermint transport implementation - *@upyo/logtape*: LogTape observability transport diff --git a/CHANGES.md b/CHANGES.md index 00beac8..987473b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -229,6 +229,17 @@ To be released. [Mailtrap]: https://mailtrap.io/ [#32]: https://github.com/dahlia/upyo/pull/32 +### @upyo/mime + + - Added *@upyo/mime* with `composeMessage()` for composing replayable MIME + bytes without a transport connection, with optional DKIM signing. Save + the bytes as an *.eml* file or pass the result directly to SMTP or JMAP + `sendRaw()`. The package supports Node.js, Deno, Bun, and edge runtimes + without Node.js compatibility. [[#68], [#74]] + +[#68]: https://github.com/dahlia/upyo/issues/68 +[#74]: https://github.com/dahlia/upyo/pull/74 + ### @upyo/opentelemetry - Fixed the `email.content.type` span attribute and the `content_type` metric @@ -341,7 +352,8 @@ To be released. custom OAuth2 token provider, so cancelled operations release their connections promptly. - Added automatic SMTPUTF8 delivery for internationalized sender, recipient, - and reply-to addresses. Servers must advertise `SMTPUTF8` and `8BITMIME`; + and reply-to addresses, as well as internationalized nested MIME and DKIM + headers. Servers must advertise `SMTPUTF8` and `8BITMIME`; unsupported sends fail without starting a mail transaction. [[#45], [#50]] - Added incremental SMTP attachment encoding and backpressured DATA writes. Set `dkim.bodyMode` to `"streaming"` for bounded attachment memory with two @@ -402,6 +414,10 @@ To be released. 465 uses implicit TLS; all other ports start with plaintext and upgrade with STARTTLS when advertised. Set `secure: true` explicitly to use implicit TLS on a nonstandard port. [[#53], [#55]] + - Fixed quoted-printable encoding of CRLF, isolated line endings, trailing + spaces, and long lines so messages preserve their text and respect MIME + line limits. Inline Content-ID headers now reject values that cannot fit + the RFC 5322 line limit. [[#68], [#74]] [#42]: https://github.com/dahlia/upyo/issues/42 [#43]: https://github.com/dahlia/upyo/issues/43 diff --git a/README.md b/README.md index 53eeaa0..f7d232d 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ sending messages. The following is a list of the available packages: | Package | JSR | npm | Description | | ----------------------------------------------- | ------------------------------ | ------------------------------ | -------------------------------------------------- | | [@upyo/core](/packages/core/) | [JSR][jsr:@upyo/core] | [npm][npm:@upyo/core] | Shared types and interfaces for email messages | +| [@upyo/mime](/packages/mime/) | [JSR][jsr:@upyo/mime] | [npm][npm:@upyo/mime] | Portable MIME composition and DKIM signing | | [@upyo/smtp](/packages/smtp/) | [JSR][jsr:@upyo/smtp] | [npm][npm:@upyo/smtp] | SMTP transport | | [@upyo/jmap](/packages/jmap/) | [JSR][jsr:@upyo/jmap] | [npm][npm:@upyo/jmap] | [JMAP] transport (RFC 8620/8621) | | [@upyo/lettermint](/packages/lettermint/) | [JSR][jsr:@upyo/lettermint] | [npm][npm:@upyo/lettermint] | [Lettermint] transport | @@ -84,6 +85,8 @@ sending messages. The following is a list of the available packages: [jsr:@upyo/core]: https://jsr.io/@upyo/core [npm:@upyo/core]: https://www.npmjs.com/package/@upyo/core +[jsr:@upyo/mime]: https://jsr.io/@upyo/mime +[npm:@upyo/mime]: https://www.npmjs.com/package/@upyo/mime [jsr:@upyo/smtp]: https://jsr.io/@upyo/smtp [npm:@upyo/smtp]: https://www.npmjs.com/package/@upyo/smtp [jsr:@upyo/jmap]: https://jsr.io/@upyo/jmap diff --git a/changes.d/mime/mime-composition.md b/changes.d/mime/mime-composition.md new file mode 100644 index 0000000..8f5e1a6 --- /dev/null +++ b/changes.d/mime/mime-composition.md @@ -0,0 +1,10 @@ +--- +links: + '#68': https://github.com/dahlia/upyo/issues/68 + '#74': https://github.com/dahlia/upyo/pull/74 +--- + - Added *@upyo/mime* with `composeMessage()` for composing replayable MIME + bytes without a transport connection, with optional DKIM signing. Save + the bytes as an *.eml* file or pass the result directly to SMTP or JMAP + `sendRaw()`. The package supports Node.js, Deno, Bun, and edge runtimes + without Node.js compatibility. [[#68], [#74]] diff --git a/changes.d/smtp/mime-line-encoding.md b/changes.d/smtp/mime-line-encoding.md new file mode 100644 index 0000000..d9197be --- /dev/null +++ b/changes.d/smtp/mime-line-encoding.md @@ -0,0 +1,9 @@ +--- +links: + '#68': https://github.com/dahlia/upyo/issues/68 + '#74': https://github.com/dahlia/upyo/pull/74 +--- + - Fixed quoted-printable encoding of CRLF, isolated line endings, trailing + spaces, and long lines so messages preserve their text and respect MIME + line limits. Inline Content-ID headers now reject values that cannot fit + the RFC 5322 line limit. [[#68], [#74]] diff --git a/changes.d/smtp/smtputf8.md b/changes.d/smtp/smtputf8.md index e456de8..d8278b1 100644 --- a/changes.d/smtp/smtputf8.md +++ b/changes.d/smtp/smtputf8.md @@ -4,5 +4,6 @@ links: '#50': https://github.com/dahlia/upyo/pull/50 --- - Added automatic SMTPUTF8 delivery for internationalized sender, recipient, - and reply-to addresses. Servers must advertise `SMTPUTF8` and `8BITMIME`; + and reply-to addresses, as well as internationalized nested MIME and DKIM + headers. Servers must advertise `SMTPUTF8` and `8BITMIME`; unsupported sends fail without starting a mail transaction. [[#45], [#50]] diff --git a/deno.lock b/deno.lock index 89a1830..be2dcee 100644 --- a/deno.lock +++ b/deno.lock @@ -6,13 +6,17 @@ "jsr:@std/fs@^1.0.19": "1.0.19", "jsr:@std/internal@^1.0.9": "1.0.9", "jsr:@std/path@^1.1.1": "1.1.1", + "npm:@noble/hashes@^1.8.0": "1.8.0", "npm:@opentelemetry/api@^1.9.0": "1.9.0", "npm:@opentelemetry/context-async-hooks@^1.25.1": "1.30.1_@opentelemetry+api@1.9.0", "npm:@opentelemetry/resources@^1.25.1": "1.30.1_@opentelemetry+api@1.9.0", "npm:@opentelemetry/sdk-metrics@^1.25.1": "1.30.1_@opentelemetry+api@1.9.0", "npm:@opentelemetry/sdk-trace-base@^1.25.1": "1.30.1_@opentelemetry+api@1.9.0", - "npm:@opentelemetry/semantic-conventions@^1.25.1": "1.34.0", + "npm:@opentelemetry/semantic-conventions@^1.25.1": "1.28.0", "npm:@types/node@*": "22.15.15", + "npm:esbuild@~0.28.2": "0.28.2", + "npm:miniflare@4.20260730.0": "4.20260730.0", + "npm:postal-mime@3": "3.0.0", "npm:tsdown@0.12.9": "0.12.9_rolldown@1.0.0-beta.24", "npm:tsdown@~0.12.7": "0.12.9_rolldown@1.0.0-beta.24" }, @@ -50,7 +54,7 @@ "@babel/parser", "@babel/types", "@jridgewell/gen-mapping", - "@jridgewell/trace-mapping", + "@jridgewell/trace-mapping@0.3.29", "jsesc" ] }, @@ -74,6 +78,37 @@ "@babel/helper-validator-identifier" ] }, + "@cloudflare/workerd-darwin-64@1.20260730.1": { + "integrity": "sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@cloudflare/workerd-darwin-arm64@1.20260730.1": { + "integrity": "sha512-SBHKntPkKvNPgaCrTe99xC1CAl8ygJDzlYfK0LbuJ1muKadIw35WnhO0wu894fKBtllsVQdNzDLee+cm0ppLSQ==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@cloudflare/workerd-linux-64@1.20260730.1": { + "integrity": "sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@cloudflare/workerd-linux-arm64@1.20260730.1": { + "integrity": "sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@cloudflare/workerd-windows-64@1.20260730.1": { + "integrity": "sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@cspotcode/source-map-support@0.8.1": { + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dependencies": [ + "@jridgewell/trace-mapping@0.3.9" + ] + }, "@emnapi/core@1.4.4": { "integrity": "sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==", "dependencies": [ @@ -81,8 +116,8 @@ "tslib" ] }, - "@emnapi/runtime@1.4.4": { - "integrity": "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==", + "@emnapi/runtime@1.11.3": { + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dependencies": [ "tslib" ] @@ -93,11 +128,309 @@ "tslib" ] }, + "@esbuild/aix-ppc64@0.28.2": { + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@esbuild/android-arm64@0.28.2": { + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm@0.28.2": { + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-x64@0.28.2": { + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/darwin-arm64@0.28.2": { + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-x64@0.28.2": { + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-arm64@0.28.2": { + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-x64@0.28.2": { + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/linux-arm64@0.28.2": { + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm@0.28.2": { + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-ia32@0.28.2": { + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-loong64@0.28.2": { + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-mips64el@0.28.2": { + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-ppc64@0.28.2": { + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-riscv64@0.28.2": { + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-s390x@0.28.2": { + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-x64@0.28.2": { + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-arm64@0.28.2": { + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, + "@esbuild/netbsd-x64@0.28.2": { + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-arm64@0.28.2": { + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, + "@esbuild/openbsd-x64@0.28.2": { + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/openharmony-arm64@0.28.2": { + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@esbuild/sunos-x64@0.28.2": { + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/win32-arm64@0.28.2": { + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-ia32@0.28.2": { + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-x64@0.28.2": { + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@img/colour@1.1.0": { + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==" + }, + "@img/sharp-darwin-arm64@0.35.2": { + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "optionalDependencies": [ + "@img/sharp-libvips-darwin-arm64" + ], + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@img/sharp-darwin-x64@0.35.2": { + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "optionalDependencies": [ + "@img/sharp-libvips-darwin-x64" + ], + "os": ["darwin"], + "cpu": ["x64"] + }, + "@img/sharp-freebsd-wasm32@0.35.2": { + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dependencies": [ + "@img/sharp-wasm32" + ], + "os": ["freebsd"] + }, + "@img/sharp-libvips-darwin-arm64@1.3.1": { + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@img/sharp-libvips-darwin-x64@1.3.1": { + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@img/sharp-libvips-linux-arm64@1.3.1": { + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-libvips-linux-arm@1.3.1": { + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@img/sharp-libvips-linux-ppc64@1.3.1": { + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@img/sharp-libvips-linux-riscv64@1.3.1": { + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@img/sharp-libvips-linux-s390x@1.3.1": { + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@img/sharp-libvips-linux-x64@1.3.1": { + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-libvips-linuxmusl-arm64@1.3.1": { + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-libvips-linuxmusl-x64@1.3.1": { + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-linux-arm64@0.35.2": { + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-arm64" + ], + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-linux-arm@0.35.2": { + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-arm" + ], + "os": ["linux"], + "cpu": ["arm"] + }, + "@img/sharp-linux-ppc64@0.35.2": { + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-ppc64" + ], + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@img/sharp-linux-riscv64@0.35.2": { + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-riscv64" + ], + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@img/sharp-linux-s390x@0.35.2": { + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-s390x" + ], + "os": ["linux"], + "cpu": ["s390x"] + }, + "@img/sharp-linux-x64@0.35.2": { + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-x64" + ], + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-linuxmusl-arm64@0.35.2": { + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "optionalDependencies": [ + "@img/sharp-libvips-linuxmusl-arm64" + ], + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-linuxmusl-x64@0.35.2": { + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "optionalDependencies": [ + "@img/sharp-libvips-linuxmusl-x64" + ], + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-wasm32@0.35.2": { + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dependencies": [ + "@emnapi/runtime" + ] + }, + "@img/sharp-webcontainers-wasm32@0.35.2": { + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "dependencies": [ + "@img/sharp-wasm32" + ], + "cpu": ["wasm32"] + }, + "@img/sharp-win32-arm64@0.35.2": { + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@img/sharp-win32-ia32@0.35.2": { + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@img/sharp-win32-x64@0.35.2": { + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "os": ["win32"], + "cpu": ["x64"] + }, "@jridgewell/gen-mapping@0.3.12": { "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", "dependencies": [ "@jridgewell/sourcemap-codec", - "@jridgewell/trace-mapping" + "@jridgewell/trace-mapping@0.3.29" ] }, "@jridgewell/resolve-uri@3.1.2": { @@ -113,6 +446,13 @@ "@jridgewell/sourcemap-codec" ] }, + "@jridgewell/trace-mapping@0.3.9": { + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dependencies": [ + "@jridgewell/resolve-uri", + "@jridgewell/sourcemap-codec" + ] + }, "@napi-rs/wasm-runtime@0.2.11": { "integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==", "dependencies": [ @@ -121,6 +461,9 @@ "@tybys/wasm-util" ] }, + "@noble/hashes@1.8.0": { + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" + }, "@opentelemetry/api@1.9.0": { "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==" }, @@ -134,7 +477,7 @@ "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", "dependencies": [ "@opentelemetry/api", - "@opentelemetry/semantic-conventions@1.28.0" + "@opentelemetry/semantic-conventions" ] }, "@opentelemetry/resources@1.30.1_@opentelemetry+api@1.9.0": { @@ -142,7 +485,7 @@ "dependencies": [ "@opentelemetry/api", "@opentelemetry/core", - "@opentelemetry/semantic-conventions@1.28.0" + "@opentelemetry/semantic-conventions" ] }, "@opentelemetry/sdk-metrics@1.30.1_@opentelemetry+api@1.9.0": { @@ -159,21 +502,35 @@ "@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/resources", - "@opentelemetry/semantic-conventions@1.28.0" + "@opentelemetry/semantic-conventions" ] }, "@opentelemetry/semantic-conventions@1.28.0": { "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==" }, - "@opentelemetry/semantic-conventions@1.34.0": { - "integrity": "sha512-aKcOkyrorBGlajjRdVoJWHTxfxO1vCNHLJVlSDaRHDIdjU+pX8IYQPvPDkYiujKLbRnWU+1TBwEt0QRgSm4SGA==" - }, "@oxc-project/runtime@0.75.1": { "integrity": "sha512-UH07DRi7xXqAsJ/sFbJJg0liIXnapB6P5uADXIiF1s6WQjZzcTIkKHca0s522QVxmijPxVX5ijCYxSr7eSq5CQ==" }, "@oxc-project/types@0.75.1": { "integrity": "sha512-7ZJy+51qWpZRvynaQUezeYfjCtaSdiXIWFUZIlOuTSfDXpXqnSl/m1IUPLx6XrOy6s0SFv3CLE14vcZy63bz7g==" }, + "@poppinss/colors@4.1.6": { + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dependencies": [ + "kleur" + ] + }, + "@poppinss/dumper@0.6.5": { + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dependencies": [ + "@poppinss/colors", + "@sindresorhus/is", + "supports-color" + ] + }, + "@poppinss/exception@1.2.3": { + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==" + }, "@quansync/fs@0.1.3": { "integrity": "sha512-G0OnZbMWEs5LhDyqy2UL17vGhSVHkQIfVojMtEWVenvj0V5S84VBgy86kJIuNsGDp2p7sTKlpSIpBUWdC35OKg==", "dependencies": [ @@ -245,6 +602,12 @@ "@rolldown/pluginutils@1.0.0-beta.24": { "integrity": "sha512-NMiim/enJlffMP16IanVj1ajFNEg8SaMEYyxyYfJoEyt5EiFT3HUH/T2GRdeStNWp+/kg5U8DiJqnQBgLQ8uCw==" }, + "@sindresorhus/is@7.2.0": { + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==" + }, + "@speed-highlight/core@1.2.24": { + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==" + }, "@tybys/wasm-util@0.9.0": { "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", "dependencies": [ @@ -279,6 +642,9 @@ "readdirp" ] }, + "cookie@1.1.1": { + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==" + }, "debug@4.4.1": { "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dependencies": [ @@ -288,6 +654,9 @@ "defu@6.1.4": { "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" }, + "detect-libc@2.1.2": { + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" + }, "diff@8.0.2": { "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==" }, @@ -297,6 +666,42 @@ "empathic@2.0.0": { "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==" }, + "error-stack-parser-es@1.0.5": { + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==" + }, + "esbuild@0.28.2": { + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "optionalDependencies": [ + "@esbuild/aix-ppc64", + "@esbuild/android-arm", + "@esbuild/android-arm64", + "@esbuild/android-x64", + "@esbuild/darwin-arm64", + "@esbuild/darwin-x64", + "@esbuild/freebsd-arm64", + "@esbuild/freebsd-x64", + "@esbuild/linux-arm", + "@esbuild/linux-arm64", + "@esbuild/linux-ia32", + "@esbuild/linux-loong64", + "@esbuild/linux-mips64el", + "@esbuild/linux-ppc64", + "@esbuild/linux-riscv64", + "@esbuild/linux-s390x", + "@esbuild/linux-x64", + "@esbuild/netbsd-arm64", + "@esbuild/netbsd-x64", + "@esbuild/openbsd-arm64", + "@esbuild/openbsd-x64", + "@esbuild/openharmony-arm64", + "@esbuild/sunos-x64", + "@esbuild/win32-arm64", + "@esbuild/win32-ia32", + "@esbuild/win32-x64" + ], + "scripts": true, + "bin": true + }, "fdir@6.4.6_picomatch@4.0.2": { "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", "dependencies": [ @@ -323,6 +728,21 @@ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "bin": true }, + "kleur@4.1.5": { + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==" + }, + "miniflare@4.20260730.0": { + "integrity": "sha512-1Z9SB9r/o//80UA02Re3QhtcecSHAyAjf5EcKBfQVlQrCg7Miy79hl2PvtkwFLIaJ5rcrOPdDcRr577okwZPsg==", + "dependencies": [ + "@cspotcode/source-map-support", + "sharp", + "undici", + "workerd", + "ws", + "youch" + ], + "bin": true + }, "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, @@ -332,6 +752,9 @@ "picomatch@4.0.2": { "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==" }, + "postal-mime@3.0.0": { + "integrity": "sha512-Z4a9ar2Bv3YpK3IXag+Yda30k7bMZfpRuUGyqtHnZ2pjHG8Bl62EhZIk4n1dzv00gfzP9g+94e9kd8+XmjVWLA==" + }, "quansync@0.2.10": { "integrity": "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==" }, @@ -379,10 +802,48 @@ ], "bin": true }, - "semver@7.7.2": { - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "semver@7.8.5": { + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "bin": true }, + "sharp@0.35.2": { + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dependencies": [ + "@img/colour", + "detect-libc", + "semver" + ], + "optionalDependencies": [ + "@img/sharp-darwin-arm64", + "@img/sharp-darwin-x64", + "@img/sharp-freebsd-wasm32", + "@img/sharp-libvips-darwin-arm64", + "@img/sharp-libvips-darwin-x64", + "@img/sharp-libvips-linux-arm", + "@img/sharp-libvips-linux-arm64", + "@img/sharp-libvips-linux-ppc64", + "@img/sharp-libvips-linux-riscv64", + "@img/sharp-libvips-linux-s390x", + "@img/sharp-libvips-linux-x64", + "@img/sharp-libvips-linuxmusl-arm64", + "@img/sharp-libvips-linuxmusl-x64", + "@img/sharp-linux-arm", + "@img/sharp-linux-arm64", + "@img/sharp-linux-ppc64", + "@img/sharp-linux-riscv64", + "@img/sharp-linux-s390x", + "@img/sharp-linux-x64", + "@img/sharp-linuxmusl-arm64", + "@img/sharp-linuxmusl-x64", + "@img/sharp-webcontainers-wasm32", + "@img/sharp-win32-arm64", + "@img/sharp-win32-ia32", + "@img/sharp-win32-x64" + ] + }, + "supports-color@10.2.2": { + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==" + }, "tinyexec@1.0.1": { "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==" }, @@ -426,6 +887,41 @@ }, "undici-types@6.21.0": { "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "undici@7.28.0": { + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==" + }, + "workerd@1.20260730.1": { + "integrity": "sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA==", + "optionalDependencies": [ + "@cloudflare/workerd-darwin-64", + "@cloudflare/workerd-darwin-arm64", + "@cloudflare/workerd-linux-64", + "@cloudflare/workerd-linux-arm64", + "@cloudflare/workerd-windows-64" + ], + "scripts": true, + "bin": true + }, + "ws@8.21.0": { + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==" + }, + "youch-core@0.3.3": { + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dependencies": [ + "@poppinss/exception", + "error-stack-parser-es" + ] + }, + "youch@4.1.0-beta.10": { + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dependencies": [ + "@poppinss/colors", + "@poppinss/dumper", + "@speed-highlight/core", + "cookie", + "youch-core" + ] } }, "workspace": { @@ -441,6 +937,14 @@ "jsr:@logtape/testing@^2.2.4" ] }, + "packages/mime": { + "dependencies": [ + "npm:@noble/hashes@^1.8.0", + "npm:esbuild@~0.28.2", + "npm:miniflare@4.20260730.0", + "npm:postal-mime@3" + ] + }, "packages/opentelemetry": { "dependencies": [ "npm:@opentelemetry/api@^1.9.0", diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 19db038..c5e5ca0 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -12,6 +12,7 @@ import llmstxt from "vitepress-plugin-llms"; const packages: readonly string[] = [ "core", + "mime", "jmap", "lettermint", "logtape", @@ -70,6 +71,7 @@ const NAV = [ text: "Messages", items: [ { text: "Composing messages", link: "/messages/compose" }, + { text: "Composing MIME", link: "/messages/mime" }, { text: "Attachments", link: "/messages/attachments" }, { text: "Calendar invitations", link: "/messages/calendar" }, ], @@ -99,6 +101,7 @@ const NAV = [ text: "References", items: [ { text: "@upyo/core", link: "https://jsr.io/@upyo/core/doc" }, + { text: "@upyo/mime", link: "https://jsr.io/@upyo/mime/doc" }, { text: "@upyo/smtp", link: "https://jsr.io/@upyo/smtp/doc" }, { text: "@upyo/jmap", link: "https://jsr.io/@upyo/jmap/doc" }, { text: "@upyo/lettermint", link: "https://jsr.io/@upyo/lettermint/doc" }, diff --git a/docs/messages/compose.md b/docs/messages/compose.md index c516332..5735463 100644 --- a/docs/messages/compose.md +++ b/docs/messages/compose.md @@ -12,6 +12,9 @@ provides the `createMessage()` function from the *@upyo/core* package, which accepts various input formats and automatically handles validation and type conversion for you. +To serialize a message without sending it, use the +[MIME composition API](./mime.md). + Basic message creation ---------------------- diff --git a/docs/messages/mime.md b/docs/messages/mime.md new file mode 100644 index 0000000..6d486ae --- /dev/null +++ b/docs/messages/mime.md @@ -0,0 +1,143 @@ +Composing MIME +============== + +*This API is available since Upyo 0.6.0.* + +`composeMessage()` from *@upyo/mime* turns a structured message into replayable +MIME bytes without opening a transport connection. Use it to save an *.eml* +file, inspect a message before delivery, or supply the same serialized message +to a raw-message transport. It works in Node.js, Deno, Bun, and edge runtimes +with Web Crypto, including Cloudflare Workers without Node.js compatibility. + +~~~~ typescript twoslash +import { createMessage, readAttachmentContent } from "@upyo/core"; +import { composeMessage } from "@upyo/mime"; + +const message = createMessage({ + from: "sender@example.com", + to: "recipient@example.com", + bcc: "archive@example.com", + subject: "A copy for your records", + content: { text: "Hello!", html: "

Hello!

" }, +}); +const composed = await composeMessage(message); +const eml = await readAttachmentContent(composed.content); +// Save eml with your runtime's file or object-storage API. +~~~~ + +The result implements `RawMessage`. Its envelope contains the sender and all +To, Cc, and Bcc recipients; the MIME has no Bcc header. The generated date, +message identifier, and multipart boundaries stay the same on every read. +Typed identity and threading fields follow the same precedence as +[structured messages](./compose.md). + +The bytes use CRLF and include the final line ending. They contain neither SMTP +dot-stuffing nor the DATA terminator. Collecting them as shown above uses memory +proportional to the complete message. For large messages, consume the chunks: + +~~~~ typescript twoslash +import type { ComposedMessage } from "@upyo/mime"; +declare const composed: ComposedMessage; +declare function writeChunk(bytes: Uint8Array): Promise; +// ---cut-before--- +for await (const chunk of composed.content()) { + await writeChunk(chunk); +} +~~~~ + +Only treat a saved message as complete after iteration finishes successfully. +An attachment failure, cancellation, or replay error can occur after some bytes +have been written. + + +Sending composed bytes +---------------------- + +Both [SMTP](../transports/smtp.md) and [JMAP](../transports/jmap.md) accept the +result directly through `sendRaw()`: + +~~~~ typescript twoslash +import type { ComposedMessage } from "@upyo/mime"; +import type { SmtpTransport } from "@upyo/smtp"; +import type { JmapTransport } from "@upyo/jmap"; +declare const composed: ComposedMessage; +declare const smtp: SmtpTransport; +declare const jmap: JmapTransport; +// ---cut-before--- +await smtp.sendRaw(composed); +// Or, with a JMAP transport: +await jmap.sendRaw(composed); +~~~~ + +`encoding` is `"7bit"` or `"utf8"`, based on all MIME headers, including nested +parts and DKIM signatures. Internationalized envelope addresses have their own +transport requirements. The result has a factory source and an explicit +encoding, so raw SMTP delivery does not perform a size-analysis pass or announce +a known `SIZE` before DATA. Server size limits still apply while sending. To +request the existing analysis pass, pass `{ ...composed, encoding: undefined }` +to `sendRaw()`, which reads the source an extra time. Alternatively, collect +bytes first and replace `content` with that byte array. + +To change delivery addresses without changing the MIME, pass a copy with a new +`envelope`. SMTP transport DKIM settings do not sign raw messages; use the +composition option below. A JMAP server may modify imported MIME during +submission, so its recipients are not guaranteed to receive identical bytes or +an intact original DKIM signature. + + +Signing and attachment lifetime +------------------------------- + +Pass `dkim` to sign during composition. It accepts the same signature settings +as the [SMTP DKIM configuration](../transports/smtp.md#dkim-signing). + +~~~~ typescript twoslash +import type { Message } from "@upyo/core"; +declare const message: Message; +declare const privateKey: string; +// ---cut-before--- +import { composeMessage } from "@upyo/mime"; + +const composed = await composeMessage(message, { + dkim: { + bodyMode: "streaming", + signatures: [{ + signingDomain: "example.com", + selector: "mail", + privateKey, + }], + }, +}); +~~~~ + +Without signing, composition neither awaits promised attachment bytes nor opens +attachment factories. Each content reader reads the attachments when it reaches +them. Keep byte arrays immutable, and make factories return fresh, independent +readers with identical bytes. Metadata and signing options are copied during +composition; attachment bytes remain caller-owned. + +The default DKIM body mode, `"buffered"`, reads the body once before composition +resolves and retains it for subsequent readers. `"streaming"` hashes it once +before resolving, then reads it again for every content reader. Multiple +signatures share those passes. A changed streaming body throws +`MimeAttachmentReplayError`, a `RawMessageValidationError` whose `field` is +`"content"`. Raw transports report it as a nonretryable raw-message validation +failure. + +Signing failures throw by default. `onSigningFailure: "send-unsigned"` logs a +warning and continues; signatures completed before a later signing failure +remain on the message. Attachment-read failures and cancellation still throw. + + +Cancellation +------------ + +`composeMessage(message, { signal })` uses the signal during preparation and +for every future reader. Calling `composed.content(readerSignal)` additionally +cancels that reader alone. Readers can run concurrently; cancelling one does +not cancel the others. Cancellation preserves the signal's reason. + +Stopping a `for await` loop closes the active attachment reader. Factories +should honor their signal during acquisition and reading so they can release +resources promptly. No reader stays open after composition itself finishes, +and the composed result does not need disposal. diff --git a/docs/package.json b/docs/package.json index a5ec460..6d780b7 100644 --- a/docs/package.json +++ b/docs/package.json @@ -30,6 +30,7 @@ "typescript": "catalog:", "vitepress": "^2.0.0-alpha.17", "vitepress-plugin-group-icons": "^1.7.5", - "vitepress-plugin-llms": "^1.13.2" + "vitepress-plugin-llms": "^1.13.2", + "@upyo/mime": "workspace:" } } diff --git a/docs/transports/jmap.md b/docs/transports/jmap.md index a7300ca..dc24fa7 100644 --- a/docs/transports/jmap.md +++ b/docs/transports/jmap.md @@ -452,6 +452,9 @@ JMAP implementations include: Sending raw MIME ---------------- +Use [MIME composition](../messages/mime.md) to create raw bytes from an Upyo +message without opening a transport connection. + `JmapTransport` implements the optional `RawTransport` interface. It uploads serialized MIME, imports the uploaded blob into Drafts, and submits that Email with an explicit delivery envelope: diff --git a/docs/transports/smtp.md b/docs/transports/smtp.md index 5cd209e..043b29d 100644 --- a/docs/transports/smtp.md +++ b/docs/transports/smtp.md @@ -1072,6 +1072,9 @@ const testTransport = new SmtpTransport({ Sending raw MIME ---------------- +Use [MIME composition](../messages/mime.md) to create raw bytes from an Upyo +message without opening a transport connection. + `SmtpTransport` implements `RawTransport` for already serialized messages, including signed or encrypted MIME. Provide delivery addresses separately: diff --git a/mise.toml b/mise.toml index 1110840..3a1ebb4 100644 --- a/mise.toml +++ b/mise.toml @@ -107,7 +107,7 @@ run = ["deno fmt", "hongdown --write", "mise fmt"] [tasks.build] description = "Build all npm packages" depends = ["deps"] -run = "mise run --no-deps '//packages/...:build'" +run = "mise run '//packages/...:build'" [tasks."test:deno"] description = "Run Deno tests for all packages" @@ -208,7 +208,12 @@ run = "mise run --jobs 1 '//packages/...:test:bun'" [tasks.test] description = "Run tests across Deno, Node.js, and Bun" -run = ["mise run test:deno", "mise run test:node", "mise run test:bun"] +run = [ + "mise run test:deno", + "mise run test:node", + "mise run test:bun", + "mise run test:edge", +] [tasks."docs:dev"] description = "Start the documentation dev server" @@ -277,3 +282,7 @@ if ($env.usage_tag? | default "false" | into bool) { pnpm publish --recursive --no-git-checks --access public --force --tag dev } ''' + +[tasks."test:edge"] +description = "Test MIME composition in workerd" +run = "mise run //packages/mime:test:edge" diff --git a/packages/jmap/mise.toml b/packages/jmap/mise.toml index 87353e9..8740b09 100644 --- a/packages/jmap/mise.toml +++ b/packages/jmap/mise.toml @@ -45,12 +45,12 @@ deno ...$args [tasks."test:node"] description = "Run @upyo/jmap tests with Node.js" -depends = ["build"] +depends = ["build", "//packages/mime:build"] run = "node --experimental-transform-types --test" [tasks."test:bun"] description = "Run @upyo/jmap tests with Bun" -depends = ["build"] +depends = ["build", "//packages/mime:build"] run = "bun test --timeout=30000" [tasks.test] diff --git a/packages/jmap/package.json b/packages/jmap/package.json index 520832b..6b18dc9 100644 --- a/packages/jmap/package.json +++ b/packages/jmap/package.json @@ -58,7 +58,8 @@ "devDependencies": { "jmap-rfc-types": "^0.1.2", "tsdown": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "@upyo/mime": "workspace:*" }, "scripts": { "prepack": "mise run --no-deps :build", diff --git a/packages/jmap/src/raw-message.integration.test.ts b/packages/jmap/src/raw-message.integration.test.ts index fecb107..7eddec8 100644 --- a/packages/jmap/src/raw-message.integration.test.ts +++ b/packages/jmap/src/raw-message.integration.test.ts @@ -679,3 +679,75 @@ test("JMAP raw keeps an authentication rejection before upload EOF", async () => await context.close(); } }); + +import { composeMessage } from "@upyo/mime"; +import { createMessage, readAttachmentContent } from "@upyo/core"; + +test("JMAP uploads composed MIME unchanged", async () => { + const context = await setup(); + try { + const composed = await composeMessage( + createMessage({ + from: "sender@example.com", + to: "recipient@example.com", + bcc: "blind@example.com", + subject: "Composed", + content: { text: "First\r\nSecond", html: "Second" }, + }), + ); + const bytes = await readAttachmentContent(composed.content); + assert.ok((await context.transport.sendRaw(composed)).successful); + assert.deepEqual(context.uploads, [Buffer.from(bytes)]); + const submission = context.calls.find((call) => + call.name === "EmailSubmission/set" + ); + assert.ok(JSON.stringify(submission).includes("blind@example.com")); + assert.ok(!Buffer.from(bytes).toString().includes("Bcc:")); + } finally { + await context.close(); + } +}); + +import { TEST_DKIM_PRIVATE_KEY } from "../../mime/src/test-utils/dkim-test-keys.ts"; + +test("JMAP classifies composed replay failures without retrying upload", async () => { + const context = await setup(); + try { + let reads = 0; + const composed = await composeMessage( + createMessage({ + from: "sender@example.com", + to: "recipient@example.com", + subject: "Changed attachment", + content: { text: "Hello" }, + attachments: { + filename: "a", + contentId: "a", + contentType: "text/plain", + inline: false, + content: async function* () { + yield new Uint8Array([reads++]); + }, + }, + }), + { + dkim: { + bodyMode: "streaming", + signatures: [{ + signingDomain: "example.com", + selector: "test", + privateKey: TEST_DKIM_PRIVATE_KEY, + }], + }, + }, + ); + const receipt = await context.transport.sendRaw(composed); + assert.ok(!receipt.successful); + assert.equal(receipt.errors?.[0].code, "jmap.raw_message_invalid"); + assert.ok(!receipt.errors?.[0].retryable); + assert.equal(reads, 2); + assert.ok(!context.calls.some((call) => call.name === "Email/import")); + } finally { + await context.close(); + } +}); diff --git a/packages/mime/README.md b/packages/mime/README.md new file mode 100644 index 0000000..eaf3700 --- /dev/null +++ b/packages/mime/README.md @@ -0,0 +1,26 @@ +@upyo/mime +========== + +Portable MIME composition and DKIM signing for [Upyo]. Compose a structured +message without a transport connection, then save its bytes or pass it to +`sendRaw()` on an SMTP or JMAP transport. + +~~~~ typescript +import { createMessage, readAttachmentContent } from "@upyo/core"; +import { composeMessage } from "@upyo/mime"; + +const composed = await composeMessage(createMessage({ + from: "sender@example.com", + to: "recipient@example.com", + subject: "Hello", + content: { text: "Hello!" }, +})); +const eml = await readAttachmentContent(composed.content); +~~~~ + +See the [MIME composition guide] for streaming, signing, cancellation, and +attachment lifetime. Runtime code uses web APIs and works without Node.js +compatibility in edge environments. + +[Upyo]: https://upyo.org/ +[MIME composition guide]: https://upyo.org/messages/mime diff --git a/packages/mime/deno.json b/packages/mime/deno.json new file mode 100644 index 0000000..b164484 --- /dev/null +++ b/packages/mime/deno.json @@ -0,0 +1,19 @@ +{ + "name": "@upyo/mime", + "version": "0.6.0", + "license": "MIT", + "exports": { + ".": "./src/index.ts", + "./internal": "./src/internal.ts" + }, + "imports": { + "@noble/hashes/sha2": "npm:@noble/hashes@^1.8.0/sha2", + "postal-mime": "npm:postal-mime@^3.0.0", + "esbuild": "npm:esbuild@^0.28.2", + "miniflare": "npm:miniflare@4.20260730.0" + }, + "exclude": [ + "dist/", + "edge/" + ] +} diff --git a/packages/mime/edge/runner.mjs b/packages/mime/edge/runner.mjs new file mode 100644 index 0000000..98edc7a --- /dev/null +++ b/packages/mime/edge/runner.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { build } from "esbuild"; +import { Miniflare } from "miniflare"; +import { composeMessage, MimeAttachmentReplayError } from "../dist/index.js"; +import * as internal from "../dist/internal.js"; +import { verifyDkim } from "../src/test-utils/verify-dkim.ts"; +import { + TEST_DKIM_ED25519_PUBLIC_KEY, + TEST_DKIM_PUBLIC_KEY, +} from "../src/test-utils/dkim-test-keys.ts"; + +assert.equal(typeof composeMessage, "function"); +assert.equal(MimeAttachmentReplayError, internal.MimeAttachmentReplayError); +const require = createRequire(import.meta.url); +assert.equal( + require("@upyo/mime").MimeAttachmentReplayError, + require("@upyo/mime/internal").MimeAttachmentReplayError, +); +assert.equal(typeof require("@upyo/mime").composeMessage, "function"); +const bundle = await build({ + entryPoints: [new URL("worker.ts", import.meta.url).pathname], + bundle: true, + write: false, + format: "esm", + platform: "browser", + target: "es2022", + metafile: true, + plugins: [{ + name: "reject-node", + setup(builder) { + builder.onResolve({ filter: /^(node:|buffer$|crypto$)/ }, (args) => { + throw new TypeError(`Node import in edge bundle: ${args.path}`); + }); + }, + }], +}); +assert.ok( + !Object.keys(bundle.metafile.inputs).some((path) => + /cryptoNode|node-polyfill/.test(path) + ), +); +const worker = new Miniflare({ + modules: true, + script: bundle.outputFiles[0].text, + compatibilityDate: "2026-07-30", + compatibilityFlags: [], +}); +try { + const response = await worker.dispatchFetch("https://mime.test/"); + assert.equal(response.status, 200, await response.clone().text()); + const result = await response.json(); + assert.ok(result.size > 2 * 1024 * 1024); + assert.ok(result.maxChunk <= 65536); + assert.ok(result.cancelled); + assert.ok(result.unique); + assert.equal(result.signatures.length, 4); + for (const signed of result.signatures) { + verifyDkim( + signed.wire, + signed.algorithm === "rsa-sha256" + ? TEST_DKIM_PUBLIC_KEY + : TEST_DKIM_ED25519_PUBLIC_KEY, + ); + } + console.log( + "workerd without Node compatibility: composition, streaming, cancellation, RSA and Ed25519 passed.", + ); +} finally { + await worker.dispose(); +} diff --git a/packages/mime/edge/worker.ts b/packages/mime/edge/worker.ts new file mode 100644 index 0000000..d054f5a --- /dev/null +++ b/packages/mime/edge/worker.ts @@ -0,0 +1,107 @@ +import { composeMessage } from "../dist/index.js"; +import { createMessage, readAttachmentContent } from "@upyo/core"; +import { + TEST_DKIM_ED25519_PRIVATE_KEY, + TEST_DKIM_PRIVATE_KEY, +} from "../src/test-utils/dkim-test-keys.ts"; + +export default { + async fetch(): Promise { + if ( + typeof globalThis.Buffer !== "undefined" || + typeof globalThis.process !== "undefined" + ) { + throw new TypeError("Unexpected Node compatibility globals."); + } + const message = createMessage({ + from: "sender@example.com", + to: "recipient@example.com", + bcc: "hidden@example.com", + subject: "Edge 한글", + content: { text: "First\r\nSecond", html: "한글" }, + calendar: { + method: "REQUEST", + content: + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Upyo//EN\r\nMETHOD:REQUEST\r\nEND:VCALENDAR\r\n", + }, + attachments: [ + { + filename: "blob.txt", + contentId: "blob", + inline: false, + contentType: "text/plain", + content: new Blob(["blob content"]), + }, + { + filename: "large.bin", + contentId: "large", + inline: false, + contentType: "application/octet-stream", + content: async function* () { + for (let i = 0; i < 64; i++) yield new Uint8Array(32768).fill(i); + }, + }, + ], + }); + const raw = await composeMessage(message); + let size = 0; + let maxChunk = 0; + for await (const chunk of raw.content()) { + size += chunk.length; + maxChunk = Math.max(maxChunk, chunk.length); + } + const controller = new AbortController(); + const iterator = raw.content(controller.signal)[Symbol.asyncIterator](); + await iterator.next(); + controller.abort("stop"); + let cancelled = false; + try { + await iterator.next(); + } catch (error) { + cancelled = error === "stop"; + } + const first = new TextDecoder().decode( + (await raw.content()[Symbol.asyncIterator]().next()).value, + ); + const other = await composeMessage(message); + const second = new TextDecoder().decode( + (await other.content()[Symbol.asyncIterator]().next()).value, + ); + const signatures: { algorithm: string; bodyMode: string; wire: string }[] = + []; + for (const algorithm of ["rsa-sha256", "ed25519-sha256"] as const) { + for (const bodyMode of ["buffered", "streaming"] as const) { + const signed = await composeMessage({ + ...message, + attachments: message.attachments.slice(0, 1), + }, { + dkim: { + bodyMode, + signatures: [{ + algorithm, + signingDomain: "example.com", + selector: "edge", + privateKey: algorithm === "rsa-sha256" + ? TEST_DKIM_PRIVATE_KEY + : TEST_DKIM_ED25519_PRIVATE_KEY, + }], + }, + }); + signatures.push({ + algorithm, + bodyMode, + wire: new TextDecoder().decode( + await readAttachmentContent(signed.content), + ), + }); + } + } + return Response.json({ + size, + maxChunk, + cancelled, + unique: first !== second, + signatures, + }); + }, +}; diff --git a/packages/mime/mise.toml b/packages/mime/mise.toml new file mode 100644 index 0000000..94cb710 --- /dev/null +++ b/packages/mime/mise.toml @@ -0,0 +1,48 @@ +[tasks.build] +depends = ["//packages/core:build"] +description = "Build @upyo/mime" +run = "pnpm exec tsdown" +sources = ["deno.json", "package.json", "tsdown.config.ts", "src/**/*.ts", "../../pnpm-lock.yaml", "../../pnpm-workspace.yaml"] +outputs = ["dist/**/*"] + +[tasks."test:deno"] +description = "Run @upyo/mime tests with Deno" +usage = ''' +flag "--coverage" help="Collect coverage data" +flag "--junit-path " help="Write JUnit XML test output" +''' +run = ''' +#!/usr/bin/env nu + +let args = ["test"] +let args = if ($env.usage_coverage? | default "false" | into bool) { + $args | append "--coverage=../../coverage" +} else { + $args +} +let args = if ($env.usage_junit_path? | default "" | is-not-empty) { + $args | append $"--junit-path=($env.usage_junit_path)" +} else { + $args +} +deno ...$args +''' + +[tasks."test:node"] +description = "Run @upyo/mime tests with Node.js" +depends = ["build"] +run = "node --experimental-transform-types --test" + +[tasks."test:bun"] +description = "Run @upyo/mime tests with Bun" +depends = ["build"] +run = "bun test" + +[tasks.test] +description = "Run @upyo/mime tests across all runtimes" +depends = ["test:deno", "test:node", "test:bun"] + +[tasks."test:edge"] +description = "Test built MIME exports in workerd without Node compatibility" +depends = ["build"] +run = "node --experimental-transform-types edge/runner.mjs" diff --git a/packages/mime/package.json b/packages/mime/package.json new file mode 100644 index 0000000..78c906a --- /dev/null +++ b/packages/mime/package.json @@ -0,0 +1,80 @@ +{ + "name": "@upyo/mime", + "version": "0.6.0", + "description": "Portable MIME composition and DKIM signing for Upyo", + "keywords": [ + "email", + "mail", + "sendmail", + "smtp" + ], + "license": "MIT", + "author": { + "name": "Hong Minhee", + "email": "hong@minhee.org", + "url": "https://hongminhee.org/" + }, + "homepage": "https://upyo.org/", + "repository": { + "type": "git", + "url": "git+https://github.com/dahlia/upyo.git", + "directory": "packages/mime/" + }, + "bugs": { + "url": "https://github.com/dahlia/upyo/issues" + }, + "funding": [ + "https://github.com/sponsors/dahlia" + ], + "engines": { + "node": ">=20.0.0", + "bun": ">=1.2.0", + "deno": ">=2.3.0" + }, + "files": [ + "dist/", + "package.json", + "README.md" + ], + "type": "module", + "module": "./dist/index.js", + "main": "./dist/index.cjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": { + "import": "./dist/index.d.ts", + "require": "./dist/index.d.cts" + }, + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json", + "./internal": { + "types": { + "import": "./dist/internal.d.ts", + "require": "./dist/internal.d.cts" + }, + "import": "./dist/internal.js", + "require": "./dist/internal.cjs" + } + }, + "sideEffects": false, + "devDependencies": { + "tsdown": "catalog:", + "typescript": "catalog:", + "postal-mime": "catalog:", + "esbuild": "catalog:", + "miniflare": "catalog:" + }, + "scripts": { + "prepack": "mise run --no-deps :build", + "prepublish": "mise run --no-deps :build" + }, + "peerDependencies": { + "@upyo/core": "workspace:*" + }, + "dependencies": { + "@noble/hashes": "catalog:" + } +} diff --git a/packages/mime/src/bytes.ts b/packages/mime/src/bytes.ts new file mode 100644 index 0000000..472b15c --- /dev/null +++ b/packages/mime/src/bytes.ts @@ -0,0 +1,12 @@ +/** @internal */ +export function utf8(value: string): Uint8Array { + return new TextEncoder().encode(value); +} +/** @internal */ +export function base64(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 8192) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 8192)); + } + return btoa(binary); +} diff --git a/packages/mime/src/cancellation.test.ts b/packages/mime/src/cancellation.test.ts new file mode 100644 index 0000000..f361427 --- /dev/null +++ b/packages/mime/src/cancellation.test.ts @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + type AttachmentContent, + createMessage, + readAttachmentContent, +} from "@upyo/core"; +import { composeMessage } from "./index.ts"; +import { TEST_DKIM_PRIVATE_KEY } from "./test-utils/dkim-test-keys.ts"; + +const message = (content?: AttachmentContent) => + createMessage({ + from: "from@example.com", + to: "to@example.com", + subject: "Test", + content: { text: "x".repeat(200000) }, + attachments: content == null ? [] : [{ + filename: "a", + contentType: "application/octet-stream", + contentId: "a", + inline: false, + content, + }], + }); +const dkim = { + signatures: [{ + signingDomain: "example.com", + selector: "test", + privateKey: TEST_DKIM_PRIVATE_KEY, + }], +}; + +for (const signing of [false, true]) { + test(`abort before and after headers, signed=${signing}`, async () => { + const lifetime = new AbortController(); + const reason = new TypeError("Stopped."); + const raw = await composeMessage(message(), { + signal: lifetime.signal, + dkim: signing ? dkim : undefined, + }); + const iterator = raw.content()[Symbol.asyncIterator](); + assert.ok(!(await iterator.next()).done); + lifetime.abort(reason); + await assert.rejects(iterator.next(), (error) => error === reason); + await assert.rejects( + readAttachmentContent(raw.content), + (error) => error === reason, + ); + await assert.rejects( + composeMessage(message(), { signal: lifetime.signal }), + (error) => error === reason, + ); + }); +} + +test("reader cancellation leaves another reader usable", async () => { + const raw = await composeMessage(message()); + const controller = new AbortController(); + const iterator = raw.content(controller.signal)[Symbol.asyncIterator](); + await iterator.next(); + controller.abort(); + await assert.rejects(iterator.next(), { name: "AbortError" }); + assert.ok((await readAttachmentContent(raw.content)).length > 200000); +}); + +for (const exit of ["break", "throw", "source-error", "abort"] as const) { + test(`attachment cleanup after ${exit}`, async () => { + let returns = 0; + let sourceSignal: AbortSignal | undefined; + let resolveOpened!: () => void; + const opened = new Promise((resolve) => { + resolveOpened = resolve; + }); + const error = new TypeError("Stopped."); + let pulls = 0; + const raw = await composeMessage( + message((signal): AsyncIterable => { + sourceSignal = signal; + resolveOpened(); + const iterator: AsyncIterableIterator = { + [Symbol.asyncIterator]() { + return this; + }, + next() { + pulls++; + if (pulls === 1) { + return Promise.resolve({ + done: false as const, + value: new Uint8Array([1, 2, 3]), + }); + } + if (exit === "source-error") return Promise.reject(error); + return new Promise>(() => {}); + }, + return() { + returns++; + return Promise.resolve({ done: true as const, value: undefined }); + }, + }; + return iterator; + }), + ); + assert.equal(pulls, 0); + const controller = new AbortController(); + const consuming = (async () => { + for await (const _chunk of raw.content(controller.signal)) { + if (pulls > 0 && exit === "break") break; + if (pulls > 0 && exit === "throw") throw error; + } + })(); + await opened; + if (exit === "abort") controller.abort(error); + if (exit === "break") await consuming; + else await assert.rejects(consuming, (actual) => actual === error); + assert.equal(returns, 1); + assert.ok(sourceSignal?.aborted); + assert.ok(pulls <= 2); + }); +} + +for (const phase of ["unsigned", "prehash", "replay"] as const) { + test(`cancel pending acquisition in ${phase} and close late reader`, async () => { + let acquired!: (reader: AsyncIterable) => void; + let opened!: () => void; + const opening = new Promise((resolve) => { + opened = resolve; + }); + let calls = 0; + let returns = 0; + const controller = new AbortController(); + const reason = new TypeError("Cancelled."); + const content: AttachmentContent = () => { + if (phase === "replay" && calls++ === 0) { + return (async function* () { + yield new Uint8Array([1]); + })(); + } + opened(); + return new Promise>((resolve) => { + acquired = resolve; + }); + }; + const composition = composeMessage(message(content), { + signal: controller.signal, + dkim: phase === "unsigned" + ? undefined + : { ...dkim, bodyMode: "streaming" }, + }); + const work = phase === "prehash" + ? composition + : composition.then((raw) => readAttachmentContent(raw.content)); + await opening; + controller.abort(reason); + await assert.rejects(work, (actual) => actual === reason); + acquired({ + [Symbol.asyncIterator]() { + return { + next: () => Promise.resolve({ done: true, value: undefined }), + return: () => { + returns++; + return Promise.resolve({ done: true, value: undefined }); + }, + }; + }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(returns, 1); + }); +} diff --git a/packages/mime/src/compose.test.ts b/packages/mime/src/compose.test.ts new file mode 100644 index 0000000..2bd1c04 --- /dev/null +++ b/packages/mime/src/compose.test.ts @@ -0,0 +1,293 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createMessage, readAttachmentContent } from "@upyo/core"; +import { composeMessage } from "./index.ts"; + +test("compose without opening attachments; replay stable MIME and Bcc envelope", async () => { + let reads = 0; + const message = createMessage({ + from: "from@example.com", + to: "to@example.com", + bcc: "blind@example.com", + subject: "Hello", + content: { text: "first\r\nsecond" }, + attachments: { + filename: "a", + contentType: "text/plain", + inline: false, + contentId: "a", + content: async function* () { + reads++; + yield new TextEncoder().encode("hello"); + }, + }, + }); + const raw = await composeMessage(message); + assert.equal(reads, 0); + assert.deepEqual(raw.envelope.to, ["to@example.com", "blind@example.com"]); + const [a, b] = await Promise.all([ + readAttachmentContent(raw.content), + readAttachmentContent(raw.content), + ]); + assert.deepEqual(a, b); + assert.equal(reads, 2); + assert.ok(!new TextDecoder().decode(a).includes("Bcc:")); + assert.ok(new TextDecoder().decode(a).endsWith("\r\n")); + assert.equal(raw.encoding, "7bit"); +}); + +import PostalMime from "postal-mime"; +import { RawMessageValidationError } from "@upyo/core"; +import { + createRawMessagePlan, + iterateRawMessage, +} from "@upyo/core/raw-message"; +import { type DkimConfig, MimeAttachmentReplayError } from "./index.ts"; +import { + TEST_DKIM_ED25519_PRIVATE_KEY, + TEST_DKIM_ED25519_PUBLIC_KEY, + TEST_DKIM_PRIVATE_KEY, + TEST_DKIM_PUBLIC_KEY, +} from "./test-utils/dkim-test-keys.ts"; +import { verifyDkim } from "./test-utils/verify-dkim.ts"; + +const message = () => + createMessage({ + from: "from@example.com", + to: "to@example.com", + subject: "안녕하세요", + content: { text: "Hello" }, + }); +const signature = { + signingDomain: "example.com", + selector: "test", + privateKey: TEST_DKIM_PRIVATE_KEY, +}; +const attachment = { + filename: "파일.bin", + contentType: "application/octet-stream" as const, + contentId: "inline", + inline: true, +}; +const collect = async ( + raw: Awaited>, + signal?: AbortSignal, +) => new TextDecoder().decode(await readAttachmentContent(raw.content, signal)); + +for (const kind of ["bytes", "promise", "blob", "factory"] as const) { + test(`independent parser decodes Unicode, alternatives and ${kind} attachments`, async () => { + const bytes = Uint8Array.from({ length: 65539 }, (_, i) => i % 251); + const source = kind === "bytes" + ? bytes + : kind === "promise" + ? Promise.resolve(bytes) + : kind === "blob" + ? new Blob([bytes]) + : async function* () { + const reused = new Uint8Array(13); + for (let offset = 0; offset < bytes.length; offset += reused.length) { + const slice = bytes.subarray(offset, offset + reused.length); + reused.set(slice); + yield reused.subarray(0, slice.length); + } + }; + const raw = await composeMessage({ + ...message(), + content: { text: "한글\r\nNext \nend\r", html: "한글" }, + attachments: [{ ...attachment, content: source }], + }); + const wire = await collect(raw); + const parsed = await PostalMime.parse(wire); + assert.equal(parsed.subject, "안녕하세요"); + assert.equal(parsed.html?.trim(), "한글"); + assert.ok(parsed.text?.startsWith("한글\nNext \nend")); + assert.equal(parsed.attachments[0].filename, "파일.bin"); + assert.equal(parsed.attachments[0].contentId, ""); + assert.deepEqual( + new Uint8Array(parsed.attachments[0].content as ArrayBuffer), + bytes, + ); + assert.ok(wire.includes("multipart/alternative")); + for await (const chunk of iterateRawMessage(createRawMessagePlan(raw))) { + assert.ok(chunk.length <= 32768); + } + }); +} + +for (const method of ["REQUEST", "REPLY", "CANCEL"] as const) { + test(`calendar ${method} preserves calendar CRLF`, async () => { + const calendar = + `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Upyo//EN\r\nMETHOD:${method}\r\nEND:VCALENDAR\r\n`; + const raw = await composeMessage({ + ...message(), + calendar: { method, content: calendar }, + }); + const wire = await collect(raw); + assert.ok(wire.includes(`text/calendar; charset=utf-8; method=${method}`)); + assert.ok(wire.indexOf("text/plain") < wire.indexOf("text/calendar")); + const parsed = await PostalMime.parse(wire); + assert.equal( + new TextDecoder().decode(parsed.attachments[0].content as ArrayBuffer), + calendar.replace(/\r\n/g, "\n"), + ); + const payload = wire.split( + `method=${method}\r\nContent-Transfer-Encoding: base64\r\n\r\n`, + )[1].split("\r\n--")[0]; + assert.equal(atob(payload.replace(/\s/g, "")), calendar); + }); +} + +for (const bodyMode of ["buffered", "streaming"] as const) { + for (const algorithm of ["rsa-sha256", "ed25519-sha256"] as const) { + for ( + const canonicalization of [ + "simple/simple", + "simple/relaxed", + "relaxed/simple", + "relaxed/relaxed", + ] as const + ) { + test(`verify composed ${algorithm} ${canonicalization} ${bodyMode}`, async () => { + let reads = 0; + const raw = await composeMessage({ + ...message(), + attachments: [{ + ...attachment, + content: async function* () { + reads++; + yield new Uint8Array([1, 2, 3]); + }, + }], + }, { + dkim: { + bodyMode, + signatures: [{ + ...signature, + algorithm, + canonicalization, + privateKey: algorithm === "rsa-sha256" + ? TEST_DKIM_PRIVATE_KEY + : TEST_DKIM_ED25519_PRIVATE_KEY, + }], + }, + }); + assert.equal(reads, 1); + const wire = await collect(raw); + verifyDkim( + wire, + algorithm === "rsa-sha256" + ? TEST_DKIM_PUBLIC_KEY + : TEST_DKIM_ED25519_PUBLIC_KEY, + ); + assert.equal(await collect(raw), wire); + assert.equal(reads, bodyMode === "buffered" ? 1 : 3); + }); + } + } +} + +test("buffered signed readers cannot mutate retained chunks", async () => { + const raw = await composeMessage(message(), { + dkim: { signatures: [signature] }, + }); + const expected = await collect(raw); + for await (const chunk of raw.content()) chunk.fill(0); + assert.equal(await collect(raw), expected); +}); + +test("streaming replay mismatch is a raw validation error", async () => { + let reads = 0; + const raw = await composeMessage({ + ...message(), + attachments: [{ + ...attachment, + content: async function* () { + yield new Uint8Array([reads++]); + }, + }], + }, { dkim: { bodyMode: "streaming", signatures: [signature] } }); + await assert.rejects( + collect(raw), + (error) => + error instanceof MimeAttachmentReplayError && + error instanceof RawMessageValidationError && error.field === "content", + ); +}); + +test("unsigned compose does not await promised bytes and observes later rejection", async () => { + let reject!: (error: Error) => void; + const error = new TypeError("Attachment failed."); + const content = new Promise((_, r) => { + reject = r; + }); + const raw = await composeMessage({ + ...message(), + attachments: [{ ...attachment, content }], + }); + reject(error); + await new Promise((resolve) => setTimeout(resolve, 0)); + await assert.rejects(collect(raw), (actual) => actual === error); +}); + +test("snapshots metadata and signing configuration before awaiting bytes", async () => { + let resolve!: (bytes: Uint8Array) => void; + const headers = new Headers({ "X-Test": "before" }); + const date = new Date("2026-09-01T00:00:00Z"); + const signatures = [{ ...signature, headerFields: ["from", "subject"] }]; + const dkim: DkimConfig = { signatures }; + const composing = composeMessage({ + ...message(), + headers, + date, + attachments: [{ + ...attachment, + content: new Promise((r) => { + resolve = r; + }), + }], + }, { dkim }); + headers.set("X-Test", "after"); + date.setUTCFullYear(2000); + signatures[0].selector = "changed"; + signatures[0].headerFields.push("date"); + resolve(new Uint8Array()); + const wire = await collect(await composing); + assert.ok(wire.includes("x-test: before")); + assert.ok(wire.includes("2026")); + assert.ok(wire.includes("s=test;")); + assert.ok(wire.includes("h=from:subject;")); +}); + +test("nested and signed Unicode headers determine MIME encoding", async () => { + assert.equal( + (await composeMessage({ + ...message(), + attachments: [{ + ...attachment, + contentId: "한글", + content: new Uint8Array(), + }], + })).encoding, + "utf8", + ); + assert.equal( + (await composeMessage(message(), { + dkim: { signatures: [{ ...signature, signingDomain: "한글.example" }] }, + })).encoding, + "utf8", + ); + await assert.rejects( + composeMessage(message(), { + dkim: { signatures: [{ ...signature, signingDomain: "x".repeat(1000) }] }, + }), + RangeError, + ); +}); + +test("invalid derived envelope uses core raw validation", async () => { + await assert.rejects( + composeMessage({ ...message(), recipients: [] }), + (error) => + error instanceof RawMessageValidationError && error.field === "envelope", + ); +}); diff --git a/packages/smtp/src/dkim/body-hash.test.ts b/packages/mime/src/dkim/body-hash.test.ts similarity index 100% rename from packages/smtp/src/dkim/body-hash.test.ts rename to packages/mime/src/dkim/body-hash.test.ts diff --git a/packages/smtp/src/dkim/body-hash.ts b/packages/mime/src/dkim/body-hash.ts similarity index 93% rename from packages/smtp/src/dkim/body-hash.ts rename to packages/mime/src/dkim/body-hash.ts index dd0ef29..a09ff0e 100644 --- a/packages/smtp/src/dkim/body-hash.ts +++ b/packages/mime/src/dkim/body-hash.ts @@ -1,8 +1,9 @@ -import { createHash } from "node:crypto"; +import { sha256 } from "@noble/hashes/sha2"; +import { base64 } from "../bytes.ts"; /** Incremental RFC 6376 body hash with bounded whitespace and line state. */ export class BodyHasher { - private readonly hash = createHash("sha256"); + private readonly hash = sha256.create(); private readonly buffer = new Uint8Array(65536); private used = 0; private cr = false; @@ -88,6 +89,6 @@ export class BodyHasher { this.emit(10); } this.hash.update(this.buffer.subarray(0, this.used)); - return this.hash.digest("base64"); + return base64(this.hash.digest()); } } diff --git a/packages/smtp/src/dkim/canonicalize.test.ts b/packages/mime/src/dkim/canonicalize.test.ts similarity index 100% rename from packages/smtp/src/dkim/canonicalize.test.ts rename to packages/mime/src/dkim/canonicalize.test.ts diff --git a/packages/smtp/src/dkim/canonicalize.ts b/packages/mime/src/dkim/canonicalize.ts similarity index 98% rename from packages/smtp/src/dkim/canonicalize.ts rename to packages/mime/src/dkim/canonicalize.ts index 295a6bb..daef38b 100644 --- a/packages/smtp/src/dkim/canonicalize.ts +++ b/packages/mime/src/dkim/canonicalize.ts @@ -2,7 +2,7 @@ * DKIM Canonicalization algorithms per RFC 6376 Section 3.4. * * @see https://www.rfc-editor.org/rfc/rfc6376#section-3.4 - * @since 0.4.0 + * @since 0.6.0 */ /** @@ -16,7 +16,7 @@ * @param value - The header field value * @returns The canonicalized header line (name:value) * @see RFC 6376 Section 3.4.1 - * @since 0.4.0 + * @since 0.6.0 */ export function canonicalizeHeaderSimple(name: string, value: string): string { return `${name}:${value}`; @@ -35,7 +35,7 @@ export function canonicalizeHeaderSimple(name: string, value: string): string { * @param value - The header field value * @returns The canonicalized header line (name:value) * @see RFC 6376 Section 3.4.2 - * @since 0.4.0 + * @since 0.6.0 */ export function canonicalizeHeaderRelaxed( name: string, @@ -65,7 +65,7 @@ export function canonicalizeHeaderRelaxed( * @param body - The message body * @returns The canonicalized body * @see RFC 6376 Section 3.4.3 - * @since 0.4.0 + * @since 0.6.0 */ export function canonicalizeBodySimple(body: string): string { if (body === "") { @@ -95,7 +95,7 @@ export function canonicalizeBodySimple(body: string): string { * @param body - The message body * @returns The canonicalized body * @see RFC 6376 Section 3.4.4 - * @since 0.4.0 + * @since 0.6.0 */ export function canonicalizeBodyRelaxed(body: string): string { if (body === "") { diff --git a/packages/smtp/src/dkim/index.ts b/packages/mime/src/dkim/index.ts similarity index 88% rename from packages/smtp/src/dkim/index.ts rename to packages/mime/src/dkim/index.ts index 7659d49..75e6180 100644 --- a/packages/smtp/src/dkim/index.ts +++ b/packages/mime/src/dkim/index.ts @@ -1,11 +1,11 @@ /** * DKIM (DomainKeys Identified Mail) signing module. * - * Provides DKIM signing functionality for the SMTP transport + * Provides DKIM signing functionality for composed messages * following RFC 6376. * * @see https://www.rfc-editor.org/rfc/rfc6376 - * @since 0.4.0 + * @since 0.6.0 */ export { diff --git a/packages/smtp/src/dkim/sign.test.ts b/packages/mime/src/dkim/sign.test.ts similarity index 100% rename from packages/smtp/src/dkim/sign.test.ts rename to packages/mime/src/dkim/sign.test.ts diff --git a/packages/smtp/src/dkim/sign.ts b/packages/mime/src/dkim/sign.ts similarity index 99% rename from packages/smtp/src/dkim/sign.ts rename to packages/mime/src/dkim/sign.ts index 9316187..a812f76 100644 --- a/packages/smtp/src/dkim/sign.ts +++ b/packages/mime/src/dkim/sign.ts @@ -4,7 +4,7 @@ * Uses Web Crypto API for cross-runtime compatibility. * * @see https://www.rfc-editor.org/rfc/rfc6376 - * @since 0.4.0 + * @since 0.6.0 */ import { @@ -29,7 +29,7 @@ import { * @param signal Optional cancellation signal. * @returns The DKIM-Signature header result * @throws Error if signing fails (e.g., invalid private key) - * @since 0.4.0 + * @since 0.6.0 */ export async function signMessage( rawMessage: string, diff --git a/packages/smtp/src/dkim/types.ts b/packages/mime/src/dkim/types.ts similarity index 96% rename from packages/smtp/src/dkim/types.ts rename to packages/mime/src/dkim/types.ts index 3414e7b..d409a00 100644 --- a/packages/smtp/src/dkim/types.ts +++ b/packages/mime/src/dkim/types.ts @@ -4,7 +4,7 @@ * - `rsa-sha256`: RSA with SHA-256, most widely used (RFC 6376) * - `ed25519-sha256`: Ed25519 with SHA-256, shorter keys (RFC 8463) * - * @since 0.4.0 + * @since 0.6.0 */ export type DkimAlgorithm = "rsa-sha256" | "ed25519-sha256"; @@ -17,7 +17,7 @@ export type DkimAlgorithm = "rsa-sha256" | "ed25519-sha256"; * - `relaxed`: Normalizes whitespace and case * * @see RFC 6376 Section 3.4 - * @since 0.4.0 + * @since 0.6.0 */ export type DkimCanonicalization = | "relaxed/relaxed" @@ -32,7 +32,7 @@ export type DkimCanonicalization = * to the email. Multiple signatures can be used for different domains * or selectors. * - * @since 0.4.0 + * @since 0.6.0 */ export interface DkimSignature { /** @@ -84,7 +84,7 @@ export interface DkimSignature { * - `throw`: Throw an error and abort sending (default) * - `send-unsigned`: Log a warning and send the email without DKIM signature * - * @since 0.4.0 + * @since 0.6.0 */ export type DkimSigningFailureAction = "throw" | "send-unsigned"; @@ -97,7 +97,7 @@ export type DkimBodyMode = "buffered" | "streaming"; /** * Configuration for DKIM signing in SMTP transport. * - * @since 0.4.0 + * @since 0.6.0 */ export interface DkimConfig { /** @@ -137,7 +137,7 @@ export function validateDkimBodyMode(config?: DkimConfig): void { /** * Result of DKIM signing operation. * - * @since 0.4.0 + * @since 0.6.0 */ export interface DkimSignResult { /** @@ -154,7 +154,7 @@ export interface DkimSignResult { /** * Default header fields to sign if not specified. * - * @since 0.4.0 + * @since 0.6.0 */ export const DEFAULT_SIGNED_HEADERS: readonly string[] = [ "from", @@ -166,13 +166,13 @@ export const DEFAULT_SIGNED_HEADERS: readonly string[] = [ /** * Default DKIM algorithm. * - * @since 0.4.0 + * @since 0.6.0 */ export const DEFAULT_ALGORITHM: DkimAlgorithm = "rsa-sha256"; /** * Default canonicalization method. * - * @since 0.4.0 + * @since 0.6.0 */ export const DEFAULT_CANONICALIZATION: DkimCanonicalization = "relaxed/relaxed"; diff --git a/packages/mime/src/index.ts b/packages/mime/src/index.ts new file mode 100644 index 0000000..7f15973 --- /dev/null +++ b/packages/mime/src/index.ts @@ -0,0 +1,90 @@ +/** Portable MIME composition and DKIM signing. @module */ +import type { Message, RawMessage } from "@upyo/core"; +import { createRawMessagePlan } from "@upyo/core/raw-message"; +import { combineSignals } from "@upyo/core/abort-signal"; +import type { DkimConfig } from "./dkim/types.ts"; +import { prepareMimeMessage } from "./message.ts"; +import { prepareMimeStream } from "./stream.ts"; + +export type { + DkimAlgorithm, + DkimBodyMode, + DkimCanonicalization, + DkimConfig, + DkimSignature, + DkimSigningFailureAction, +} from "./dkim/types.ts"; +export { MimeAttachmentReplayError } from "./stream.ts"; + +/** Composition and lifetime options. @since 0.6.0 */ +export interface ComposeMessageOptions { + /** Optional DKIM signing; buffered body mode is the default. */ + readonly dkim?: DkimConfig; + /** Cancels preparation and every future content reader. */ + readonly signal?: AbortSignal; +} + +/** Stable, replayable MIME bytes with a separate delivery envelope. @since 0.6.0 */ +export interface ComposedMessage extends RawMessage { + /** MIME encoding requirement, independent of internationalized envelope addresses. */ + readonly encoding: "7bit" | "utf8"; + /** Opens an independent reader. The signal cancels only this reader. */ + readonly content: (signal?: AbortSignal) => AsyncIterable; +} + +/** + * Composes complete MIME bytes without SMTP framing or a Bcc header. + * Unsigned messages read attachments lazily. DKIM reads the body during + * preparation; streaming mode reads it again and verifies every replay. + * Metadata is snapshotted before asynchronous work. Keep attachment bytes + * immutable and provide independent, identical factory readers. + * @param message The structured message to serialize. + * @param options Signing and cancellation options. + * @returns Replayable bytes accepted by raw-message transports. + * @throws {RawMessageValidationError} If the derived envelope is invalid. + * @throws {TypeError} If message metadata or signing configuration is invalid. + * @throws {RangeError} If a MIME header exceeds the hard line-length limit. + * @throws {Error} If signing, attachment reading, or cancellation fails. + * Attachment errors can also occur during later content reads, after partial + * output. Streaming DKIM changes throw {@link MimeAttachmentReplayError}. + * @since 0.6.0 + */ +export async function composeMessage( + message: Message, + options: ComposeMessageOptions = {}, +): Promise { + const { signal } = options; + // The neutral plan observes promised sources even when envelope validation fails. + const plan = prepareMimeMessage(message, options.dkim); + const { envelope } = createRawMessagePlan({ + envelope: { + from: message.sender.address, + to: [ + ...message.recipients, + ...message.ccRecipients, + ...message.bccRecipients, + ] + .map((recipient) => recipient.address), + }, + content: new Uint8Array(), + }); + const stream = await prepareMimeStream(plan, { signal }); + signal?.throwIfAborted(); + return { + envelope, + encoding: stream.encoding, + async *content(readerSignal) { + const owned = new AbortController(); + const lifetime = combineSignals(owned.signal, signal); + const reader = combineSignals(lifetime.signal, readerSignal); + try { + reader.signal.throwIfAborted(); + yield* stream.read(reader.signal); + } finally { + owned.abort(); + reader.cleanup(); + lifetime.cleanup(); + } + }, + }; +} diff --git a/packages/mime/src/internal.ts b/packages/mime/src/internal.ts new file mode 100644 index 0000000..1309180 --- /dev/null +++ b/packages/mime/src/internal.ts @@ -0,0 +1,16 @@ +/** + * Shared implementation for Upyo transports. This surface remains additively + * compatible within a minor release line; applications should use the root API. + * @module + * @internal + */ +export { type PreparedMimeMessage, prepareMimeMessage } from "./message.ts"; +export { + MimeAttachmentReplayError, + type MimeStream, + prepareMimeStream, + type PrepareMimeStreamOptions, +} from "./stream.ts"; +export type { DkimConfig } from "./dkim/types.ts"; +export { validateDkimBodyMode } from "./dkim/types.ts"; +export { signMessage } from "./dkim/sign.ts"; diff --git a/packages/mime/src/message.ts b/packages/mime/src/message.ts new file mode 100644 index 0000000..0698524 --- /dev/null +++ b/packages/mime/src/message.ts @@ -0,0 +1,740 @@ +import { + type Address, + type AttachmentContent, + type Message, + readAttachmentContent, +} from "@upyo/core"; +import { + formatMessageId, + generateMessageId, + resolveThreadingHeaders, +} from "@upyo/core/message-id"; +import { resolveCalendarContent } from "@upyo/core/calendar"; +import { type DkimConfig, validateDkimBodyMode } from "./dkim/types.ts"; +import { attachmentEncodedSize, encodeAttachment } from "./mime-stream.ts"; +import { base64, utf8 } from "./bytes.ts"; +/** A per-attempt MIME plan with stable headers and replayable body bytes. */ +export interface PreparedMimeMessage { + readonly encoding: "7bit" | "utf8"; + readonly dkim?: DkimConfig; + readonly headers: string; + /** Includes the final transport CRLF, but not SMTP transparency or terminator. */ + body(signal?: AbortSignal, progress?: () => void): AsyncIterable; + /** Unknown factory sizes do not cause a speculative read. */ + size( + signal?: AbortSignal, + checkSize?: (size: number) => void, + ): Promise; +} + +/** + * Freezes message metadata without reading attachment content. + * @param message Message whose metadata will be frozen. + * @param dkimConfig Optional signing configuration. + * @returns A deterministic MIME plan for one send attempt. + * @throws {RangeError} If a header cannot fit the RFC 5322 line limit. + * @throws {TypeError} If the message carries an invalid message identifier or + * date, or a `Date` or `Message-ID` header containing a carriage return or line + * feed. + */ +export function prepareMimeMessage( + message: Message, + dkimConfig?: DkimConfig, +): PreparedMimeMessage { + // Observe every promise before validation can throw or an earlier reader stalls. + for (const attachment of message.attachments) { + if (attachment.content instanceof Promise) { + attachment.content.catch(() => {}); + } + } + const dkim = dkimConfig == null ? undefined : { + ...dkimConfig, + signatures: dkimConfig.signatures.map((signature) => ({ + ...signature, + headerFields: signature.headerFields == null + ? undefined + : [...signature.headerFields], + })), + }; + if (dkim != null) validateDkimBodyMode(dkim); + const parts = buildMimeParts(message); + const first = parts[0]; + if (typeof first !== "string") throw new TypeError("Missing MIME headers."); + const separator = first.indexOf("\r\n\r\n") + 4; + const headers = first.slice(0, separator); + parts[0] = first.slice(separator); + + const encoding = + [headers, ...parts.filter((part) => typeof part === "string")].some( + hasNonAscii, + ) + ? "utf8" + : "7bit"; + return { + encoding, + dkim, + headers, + async *body(signal, progress) { + for (const part of parts) { + signal?.throwIfAborted(); + if (typeof part === "string") { + const bytes = utf8(part); + for (let offset = 0; offset < bytes.length; offset += 65536) { + signal?.throwIfAborted(); + yield bytes.subarray(offset, offset + 65536); + } + } else { + yield* encodeAttachment(part.content, signal, progress); + } + } + }, + async size(signal, checkSize) { + let size = utf8(headers).length; + let unknown = false; + for (const part of parts) { + signal?.throwIfAborted(); + if (typeof part === "string") size += utf8(part).length; + else { + if (part.content instanceof Promise) { + part.content = await readAttachmentContent(part.content, signal); + } + if (typeof part.content === "function") unknown = true; + else {size += attachmentEncodedSize( + part.content instanceof Uint8Array + ? part.content.byteLength + : part.content.size, + );} + } + if (!Number.isSafeInteger(size)) { + throw new RangeError("Message size exceeds the safe integer range."); + } + } + checkSize?.(size); + return unknown ? undefined : size; + }, + }; +} + +type MimePart = string | { content: AttachmentContent }; + +function buildMimeParts(message: Message): MimePart[] { + const lines: MimePart[] = []; + const boundary = generateBoundary(); + const hasAttachments = message.attachments.length > 0; + const alternatives = buildAlternatives(message); + const isMultipart = hasAttachments || alternatives.length > 1; + + // Standard headers + lines.push(foldHeader("From", encodeAddress(message.sender))); + lines.push( + foldHeader("To", message.recipients.map(encodeAddress).join(", ")), + ); + + if (message.ccRecipients.length > 0) { + lines.push( + foldHeader( + "Cc", + message.ccRecipients.map(encodeAddress).join(", "), + ), + ); + } + + if (message.replyRecipients.length > 0) { + lines.push( + foldHeader( + "Reply-To", + message.replyRecipients.map(encodeAddress).join(", "), + ), + ); + } + + lines.push(foldHeader("Subject", encodeHeaderValue(message.subject, true))); + + // The identity and threading fields all carry structured values, so they are + // written verbatim rather than RFC 2047 encoded. + lines.push(foldHeader("Date", resolveDate(message))); + lines.push(foldHeader("Message-ID", resolveMessageId(message))); + const threading = resolveThreadingHeaders(message); + for (const name of ["In-Reply-To", "References"]) { + // A field the message does not own falls back to a custom header, one it + // owns but left empty writes nothing at all. + const owned = threading.get(name); + const value = owned === undefined ? overridden(message, name) : owned; + if (value != null) lines.push(foldHeader(name, value)); + } + + // Names already written above, plus Cc and Reply-To even when the message + // carries no such recipients: a custom header there would list addresses the + // envelope never receives. The identity and threading names are here + // unconditionally too, even when nothing was written: each of them has had + // its one chance to be emitted above, from the typed field or from the very + // custom header this loop would otherwise write again. + const composed = new Set([ + "from", + "to", + "cc", + "reply-to", + "subject", + "date", + "message-id", + "in-reply-to", + "references", + ]); + + // Priority header + if (message.priority !== "normal") { + const priorityValue = message.priority === "high" ? "1" : "5"; + lines.push(`X-Priority: ${priorityValue}`); + lines.push( + `X-MSMail-Priority: ${message.priority === "high" ? "High" : "Low"}`, + ); + composed.add("x-priority"); + composed.add("x-msmail-priority"); + } + + // Custom headers + for (const [key, value] of message.headers) { + const name = key.toLowerCase(); + if (composed.has(name) || reservedHeaders.has(name)) continue; + lines.push(foldHeader(key, encodeHeaderValue(value))); + } + + // MIME headers + lines.push("MIME-Version: 1.0"); + + if (isMultipart) { + lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`); + lines.push(""); + lines.push("This is a multi-part message in MIME format."); + lines.push(""); + + // Content part + lines.push(`--${boundary}`); + + if (alternatives.length > 1) { + const contentBoundary = generateBoundary(); + lines.push( + `Content-Type: multipart/alternative; boundary="${contentBoundary}"`, + ); + lines.push(""); + + for (const alternative of alternatives) { + lines.push(`--${contentBoundary}`); + lines.push(`Content-Type: ${alternative.contentType}`); + lines.push(`Content-Transfer-Encoding: ${alternative.encoding}`); + lines.push(""); + lines.push(alternative.body); + lines.push(""); + } + + lines.push(`--${contentBoundary}--`); + } else { + lines.push(`Content-Type: ${alternatives[0].contentType}`); + lines.push(`Content-Transfer-Encoding: ${alternatives[0].encoding}`); + lines.push(""); + lines.push(alternatives[0].body); + } + + // Attachments + for (const attachment of message.attachments) { + lines.push(""); + lines.push(`--${boundary}`); + lines.push( + foldHeader( + "Content-Type", + `${attachment.contentType}; ${ + encodeMimeParameter("name", attachment.filename) + }`, + ), + ); + lines.push("Content-Transfer-Encoding: base64"); + + if (attachment.inline) { + lines.push( + foldHeader( + "Content-Disposition", + `inline; ${encodeMimeParameter("filename", attachment.filename)}`, + ), + ); + lines.push(foldHeader("Content-ID", `<${attachment.contentId}>`)); + } else { + lines.push( + foldHeader( + "Content-Disposition", + `attachment; ${ + encodeMimeParameter("filename", attachment.filename) + }`, + ), + ); + } + + lines.push(""); + lines.push({ content: attachment.content }); + } + + lines.push(""); + lines.push(`--${boundary}--`); + } else { + // Single part message + lines.push(`Content-Type: ${alternatives[0].contentType}`); + lines.push(`Content-Transfer-Encoding: ${alternatives[0].encoding}`); + lines.push(""); + lines.push(alternatives[0].body); + } + + const parts: MimePart[] = []; + let text = ""; + for (const line of lines) { + if (typeof line === "string") text += line; + else { + parts.push(text, line); + text = ""; + } + text += "\r\n"; + } + parts.push(text); + return parts; +} + +/** + * One body alternative, already encoded and ready to be written into a part. + */ +interface MimeAlternative { + readonly contentType: string; + readonly encoding: "quoted-printable" | "base64"; + readonly body: string; +} + +/** + * Collects the body alternatives of a message, least preferred first. + * + * RFC 2046 §5.1.4 orders a `multipart/alternative` by increasing richness, so a + * client picks the last one it understands. Plain text comes first, then HTML, + * then the calendar object: a client that schedules should act on the + * invitation rather than render the prose describing it. + * + * The presence of a body is tested with `in` rather than truthiness, so an + * empty string a caller supplied deliberately still produces its part. + * + * @param message The message being composed. + * @returns At least one alternative, in the order they are written. + * @throws {TypeError} If the calendar content is not valid. + */ +function buildAlternatives(message: Message): MimeAlternative[] { + const alternatives: MimeAlternative[] = []; + + if ("text" in message.content && message.content.text !== undefined) { + alternatives.push({ + contentType: "text/plain; charset=utf-8", + encoding: "quoted-printable", + body: encodeQuotedPrintable(message.content.text), + }); + } + + if ("html" in message.content) { + alternatives.push({ + contentType: "text/html; charset=utf-8", + encoding: "quoted-printable", + body: encodeQuotedPrintable(message.content.html), + }); + } + + if (message.calendar != null) { + // Re-validated here rather than trusted from the message: `Message` is a + // structural interface, and the method is written into a `Content-Type` + // parameter, the same reason `formatMessageId()` checks again below. + const calendar = resolveCalendarContent(message.calendar); + alternatives.push({ + // The parameter repeats the object's own METHOD property, which RFC 6047 + // §2.4 requires. Base64 keeps the CRLF structure RFC 5545 §3.1 makes + // normative intact, which quoted-printable would not. + contentType: `text/calendar; charset=utf-8; method=${calendar.method}`, + encoding: "base64", + body: encodeBase64Body(calendar.content), + }); + } + + if (alternatives.length < 1) { + alternatives.push({ + contentType: "text/plain; charset=utf-8", + encoding: "quoted-printable", + body: "", + }); + } + + return alternatives; +} + +/** + * Encodes a body as Base64, wrapped at the 76 columns RFC 2045 §6.8 allows. + * + * The result carries no trailing CRLF, because MIME assembly appends one to + * every line it writes. + * + * @param text The body to encode. + * @returns The wrapped Base64 payload. + */ +function encodeBase64Body(text: string): string { + const encoded = base64(utf8(text)); + const lines: string[] = []; + for (let offset = 0; offset < encoded.length; offset += 76) { + lines.push(encoded.slice(offset, offset + 76)); + } + return lines.join("\r\n"); +} + +/** + * Reads a header field that overrides a generated default. + * + * Unlike custom headers, the value is written verbatim rather than RFC 2047 + * encoded, so it cannot rely on `encodeHeaderValue()` to neutralize control + * characters. `ImmutableHeaders` is a structural type, so a `Message` may + * carry an adapter that never rejected CR or LF the way a platform `Headers` + * does. + * + * @param message The message whose headers are read. + * @param name The header field name. + * @returns The supplied value, or `undefined` when the message has none. + * @throws {TypeError} If the value contains a carriage return or line feed, + * which would inject additional header fields into the message. + */ +function overridden(message: Message, name: string): string | undefined { + const value = message.headers.get(name); + if (value == null) return undefined; + if (/[\r\n]/.test(value)) { + throw new TypeError( + `Header field ${name} must not contain a carriage return or line feed.`, + ); + } + return value; +} + +/** + * Header fields the composer owns but writes after the custom headers, or + * deliberately omits. A custom header with one of these names is dropped + * rather than appended: the structured `Message` fields are authoritative, + * RFC 5322 §3.6 allows at most one of each, and a duplicate placed before the + * composer's own field makes parsers that take the first occurrence read the + * wrong value. + */ +const reservedHeaders: ReadonlySet = new Set([ + // Bcc recipients travel in the SMTP envelope only, so a Bcc header would + // disclose them to every recipient. + "bcc", + "content-transfer-encoding", + "content-type", + "mime-version", +]); + +/** + * Tells whether a value contains a character outside ASCII, which a message + * can only carry with the SMTPUTF8 extension (RFC 6531). + * + * @param value The value to check. + * @returns Whether the value contains a character outside ASCII. + */ +function hasNonAscii(value: string): boolean { + for (const character of value) { + if ((character.codePointAt(0) ?? 0) > 0x7f) return true; + } + return false; +} + +function generateBoundary(): string { + return `boundary-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +/** + * Resolves the origination date: the typed field, then a custom header, then + * the time of conversion. + * + * @param message The message being composed. + * @returns The `Date` field value. + * @throws {TypeError} If the message carries an invalid date, or a custom + * `Date` header containing a carriage return or line feed. + */ +function resolveDate(message: Message): string { + return message.date === undefined + ? overridden(message, "Date") ?? formatDate(new Date()) + : formatDate(message.date); +} + +/** + * Resolves the message identifier: the typed field, then a custom header, then + * one generated within the sender's domain. + * + * @param message The message being composed. + * @returns The `Message-ID` field value, enclosed in angle brackets. + * @throws {TypeError} If the message carries an invalid identifier, or a custom + * `Message-ID` header containing a carriage return or line feed. + */ +function resolveMessageId(message: Message): string { + if (message.messageId !== undefined) { + return formatMessageId(message.messageId); + } + const custom = overridden(message, "Message-ID"); + if (custom != null) return custom; + return formatMessageId(generateSenderMessageId(message.sender.address)); +} + +/** + * Generates an identifier rooted in the sender's domain. + * + * `parseAddress()` validates a domain with `URL`, which accepts spellings the + * identifier grammar does not, such as the trailing dot of a fully qualified + * name or a host carrying a port. A sender written that way used to be + * delivered, so an unusable domain falls back to `localhost` rather than + * failing the send. Uniqueness holds either way: the left half is a UUID. + * + * @param address The sender's address. + * @returns A bare message identifier. + */ +function generateSenderMessageId(address: string): string { + const domain = domainOf(address).replace(/\.$/, ""); + try { + return generateMessageId(domain); + } catch { + return generateMessageId("localhost"); + } +} + +/** + * Extracts the domain of an address. + * + * The separator is the first at sign outside a quoted local part, since a + * quoted one may contain at signs of its own, as in `"a@b"@example.com`. + * + * @param address The address to read. + * @returns The domain, or the whole address when it carries no separator. + */ +function domainOf(address: string): string { + let quoted = false; + for (let index = 0; index < address.length; index++) { + const character = address[index]; + if (quoted && character === "\\") index++; + else if (character === '"') quoted = !quoted; + else if (!quoted && character === "@") return address.slice(index + 1); + } + return address; +} + +const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +const monthNames = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +/** + * Formats a date as an RFC 5322 §3.3 `date-time` in UTC. + * + * `Date.toUTCString()` is close but ends in the obsolete `GMT` zone rather than + * the numeric `+0000` the current grammar asks for. + * + * @param date The date to format. + * @returns The formatted date. + * @throws {TypeError} If the date is invalid, or earlier than the grammar can + * express. + */ +function formatDate(date: Date): string { + const time = date instanceof Date ? date.getTime() : Number.NaN; + const year = Number.isNaN(time) ? Number.NaN : date.getUTCFullYear(); + if (Number.isNaN(time) || year < 1900) { + throw new TypeError(`Invalid date: ${JSON.stringify(date)}`); + } + const pad = (value: number) => value.toString().padStart(2, "0"); + return `${dayNames[date.getUTCDay()]}, ${pad(date.getUTCDate())} ` + + `${monthNames[date.getUTCMonth()]} ${year.toString().padStart(4, "0")} ` + + `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:` + + `${pad(date.getUTCSeconds())} +0000`; +} + +function encodeAddress(address: Address): string { + if (address.name == null) { + // No display name, just return the email address + return address.address; + } + + // Encode only the display name part, leave email address as-is + const encodedDisplayName = encodeHeaderValue(address.name, true); + return `${encodedDisplayName} <${address.address}>`; +} + +function encodeHeaderValue( + value: string, + encodeLongAsciiWords = false, +): string { + // RFC 2047 encoding for non-ASCII characters in headers + const hasLongWord = value.split(/\s+/).some((word) => word.length > 60); + if ( + !/^[\x20-\x7E]*$/.test(value) || + (encodeLongAsciiWords && hasLongWord) + ) { + const encodeWord = (text: string): string => { + const utf8Bytes = new TextEncoder().encode(text); + const encoded = base64(utf8Bytes); + return `=?UTF-8?B?${encoded}?=`; + }; + const maxEncodedLength = 75; + const encodedWord = encodeWord(value); + + if (encodedWord.length <= maxEncodedLength) { + return encodedWord; + } + + const words: string[] = []; + let currentText = ""; + + for (const character of value) { + const candidate = currentText + character; + if (encodeWord(candidate).length <= maxEncodedLength) { + currentText = candidate; + } else { + if (currentText.length > 0) { + words.push(encodeWord(currentText)); + } + currentText = character; + } + } + + if (currentText.length > 0) { + words.push(encodeWord(currentText)); + } + + return words.join(" "); + } + return value; +} + +function encodeMimeParameter(name: string, value: string): string { + const escapedValue = value.replace(/[\\"]/g, "\\$&"); + const quotedParameter = `${name}="${escapedValue}"`; + if (/^[\x20-\x7E]*$/.test(value) && quotedParameter.length <= 60) { + return quotedParameter; + } + + const encodedBytes = Array.from( + new TextEncoder().encode(value), + (byte) => { + const character = String.fromCharCode(byte); + return /^[A-Za-z0-9!#$&+.^_`|~-]$/.test(character) + ? character + : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`; + }, + ); + const segments: string[] = []; + let segment = ""; + + for (const encodedByte of encodedBytes) { + if (segment.length + encodedByte.length > 45) { + segments.push(segment); + segment = ""; + } + segment += encodedByte; + } + if (segment.length > 0 || segments.length === 0) segments.push(segment); + + return segments.map((part, index) => + `${name}*${index}*=${index === 0 ? "UTF-8''" : ""}${part}` + ).join("; "); +} + +function foldHeader(name: string, value: string): string { + const recommendedLineLength = 78; + const lines: string[] = []; + let prefix = `${name}: `; + let remaining = value; + + while (prefix.length + remaining.length > recommendedLineLength) { + const availableLength = recommendedLineLength - prefix.length; + let breakIndex = -1; + + for ( + let index = Math.min(availableLength, remaining.length - 1); + index >= 0; + index-- + ) { + if (remaining[index] === " " || remaining[index] === "\t") { + breakIndex = index; + break; + } + } + + if (breakIndex < 0) { + for ( + let index = Math.max(availableLength + 1, 0); + index < remaining.length; + index++ + ) { + if (remaining[index] === " " || remaining[index] === "\t") { + breakIndex = index; + break; + } + } + } + + if (breakIndex < 0) break; + + let whitespaceEnd = breakIndex + 1; + while ( + whitespaceEnd < remaining.length && + (remaining[whitespaceEnd] === " " || remaining[whitespaceEnd] === "\t") + ) { + whitespaceEnd++; + } + + if (whitespaceEnd === remaining.length) break; + + lines.push(prefix + remaining.slice(0, breakIndex)); + prefix = remaining.slice(breakIndex, whitespaceEnd); + remaining = remaining.slice(whitespaceEnd); + } + + lines.push(prefix + remaining); + if (lines.some((line) => utf8(line).length > 998)) { + throw new RangeError( + `Header field ${name} contains a token too long to fold.`, + ); + } + return lines.join("\r\n"); +} + +function encodeQuotedPrintable(text: string): string { + const bytes = utf8(text); + let result = ""; + let column = 0; + for (let i = 0; i < bytes.length; i++) { + const byte = bytes[i]; + if (byte === 13 && bytes[i + 1] === 10) { + result += "\r\n"; + column = 0; + i++; + continue; + } + const escaped = byte < 32 || byte > 126 || byte === 61 || + (byte === 32 && + (i + 1 === bytes.length || bytes[i + 1] === 13 || + bytes[i + 1] === 10)) || + (byte === 46 && column === 0); + const encoded = escaped + ? `=${byte.toString(16).toUpperCase().padStart(2, "0")}` + : String.fromCharCode(byte); + if (column + encoded.length > 75) { + result += "=\r\n"; + column = 0; + } + result += encoded; + column += encoded.length; + } + return result; +} diff --git a/packages/smtp/src/mime-stream.test.ts b/packages/mime/src/mime-stream.test.ts similarity index 93% rename from packages/smtp/src/mime-stream.test.ts rename to packages/mime/src/mime-stream.test.ts index e7a853a..9c90eb0 100644 --- a/packages/smtp/src/mime-stream.test.ts +++ b/packages/mime/src/mime-stream.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { Buffer } from "node:buffer"; import { createMessage } from "@upyo/core"; -import { prepareMessage } from "./message-converter.ts"; +import { prepareMimeMessage } from "./message.ts"; for (const length of [0, 1, 2, 3, 56, 57, 58, 114, 65537, 256 * 1024]) { test(`streams and sizes ${length} attachment bytes`, async () => { @@ -21,7 +21,7 @@ for (const length of [0, 1, 2, 3, 56, 57, 58, 114, 65537, 256 * 1024]) { content: bytes, }, }); - const plan = prepareMessage(message); + const plan = prepareMimeMessage(message); const expectedSize = await plan.size(); const chunks: Uint8Array[] = []; for await (const chunk of plan.body()) { @@ -39,7 +39,7 @@ for (const length of [0, 1, 2, 3, 56, 57, 58, 114, 65537, 256 * 1024]) { payload, Buffer.from(bytes).toString("base64").replace(/(.{76})(?=.)/g, "$1\r\n"), ); - const streamed = prepareMessage({ + const streamed = prepareMimeMessage({ ...message, attachments: [{ ...message.attachments[0], diff --git a/packages/smtp/src/mime-stream.ts b/packages/mime/src/mime-stream.ts similarity index 85% rename from packages/smtp/src/mime-stream.ts rename to packages/mime/src/mime-stream.ts index e36d502..58c0bc4 100644 --- a/packages/smtp/src/mime-stream.ts +++ b/packages/mime/src/mime-stream.ts @@ -1,5 +1,5 @@ import { type AttachmentContent, iterateAttachmentContent } from "@upyo/core"; -import { Buffer } from "node:buffer"; +import { base64, utf8 } from "./bytes.ts"; /** Encodes base64 without retaining producer-owned carry bytes. */ export async function* encodeAttachment( @@ -23,7 +23,7 @@ export async function* encodeAttachment( offset += take; column += take; } - return Buffer.from(parts.join("")); + return utf8(parts.join("")); } let processed = 0; for await (const chunk of iterateAttachmentContent(content, signal)) { @@ -34,7 +34,7 @@ export async function* encodeAttachment( carry[carried++] = chunk[offset++]; } if (carried === 3) { - yield wrap(Buffer.from(carry).toString("base64")); + yield wrap(base64(carry)); carried = 0; } } @@ -45,9 +45,7 @@ export async function* encodeAttachment( Math.floor((chunk.length - offset) / 3) * 3, ); yield wrap( - Buffer.from(chunk.buffer, chunk.byteOffset + offset, length).toString( - "base64", - ), + base64(chunk.subarray(offset, offset + length)), ); offset += length; processed += length; @@ -59,7 +57,7 @@ export async function* encodeAttachment( while (offset < chunk.length) carry[carried++] = chunk[offset++]; } if (carried > 0) { - yield wrap(Buffer.from(carry.subarray(0, carried)).toString("base64")); + yield wrap(base64(carry.subarray(0, carried))); } } diff --git a/packages/mime/src/stream.ts b/packages/mime/src/stream.ts new file mode 100644 index 0000000..39be3f0 --- /dev/null +++ b/packages/mime/src/stream.ts @@ -0,0 +1,142 @@ +import { sha256 } from "@noble/hashes/sha2"; +import { RawMessageValidationError } from "@upyo/core"; +import { base64, utf8 } from "./bytes.ts"; +import { BodyHasher } from "./dkim/body-hash.ts"; +import { signWithBodyHash } from "./dkim/sign.ts"; +import type { PreparedMimeMessage } from "./message.ts"; + +/** + * A replayable attachment changed between DKIM body reads. + * @since 0.6.0 + */ +export class MimeAttachmentReplayError extends RawMessageValidationError { + /** Creates a replay validation failure. */ + constructor() { + super("Attachment content changed between DKIM body reads.", "content"); + this.name = "MimeAttachmentReplayError"; + } +} + +/** The final bytes and known size of an SMTP DATA transaction. */ +export interface MimeStream { + readonly encoding: "7bit" | "utf8"; + readonly size: number | undefined; + read(signal?: AbortSignal, progress?: () => void): AsyncIterable; +} + +/** Options for preparing a shared MIME stream. @internal */ +export interface PrepareMimeStreamOptions { + readonly knownSize?: number; + readonly checkSize?: (size: number) => void; + readonly progress?: () => void; + readonly signal?: AbortSignal; +} + +/** Prepares signatures without speculative unsigned attachment reads. @internal */ +export async function prepareMimeStream( + plan: PreparedMimeMessage, + options: PrepareMimeStreamOptions = {}, +): Promise { + const { knownSize, signal } = options; + signal?.throwIfAborted(); + const progress = options.progress ?? (() => {}); + const checkSize = (size: number) => { + if (!Number.isSafeInteger(size)) { + throw new RangeError("Message size exceeds the safe integer range."); + } + options.checkSize?.(size); + }; + if (knownSize != null) checkSize(knownSize); + const signatures = plan.dkim?.signatures ?? []; + if (signatures.length === 0) { + return { + size: knownSize, + encoding: plan.encoding, + async *read(signal, progress) { + signal?.throwIfAborted(); + yield utf8(plan.headers); + yield* plan.body(signal, progress); + }, + }; + } + const buffered = plan.dkim?.bodyMode !== "streaming"; + const chunks: Uint8Array[] = []; + const hashes = new Map<"simple" | "relaxed", BodyHasher>(); + for (const sig of signatures) { + const mode = sig.canonicalization?.endsWith("/simple") + ? "simple" + : "relaxed"; + if (!hashes.has(mode)) hashes.set(mode, new BodyHasher(mode)); + } + const rawHash = sha256.create(); + let length = 0; + const headerLength = utf8(plan.headers).length; + for await (const chunk of plan.body(signal, progress)) { + length += chunk.length; + checkSize(headerLength + length); + if (chunk.length > 0) progress(); + if (buffered) chunks.push(chunk.slice()); + rawHash.update(chunk); + for (const hash of hashes.values()) hash.update(chunk); + } + const expectedDigest = base64(rawHash.digest()); + const bodyHashes = new Map( + Array.from(hashes, ([mode, hash]) => [mode, hash.digest()]), + ); + let headers = plan.headers; + try { + for (const sig of signatures) { + const mode = sig.canonicalization?.endsWith("/simple") + ? "simple" + : "relaxed"; + const result = await signWithBodyHash( + headers, + sig, + bodyHashes.get(mode)!, + signal, + ); + headers = `${result.headerName}: ${result.signature}\r\n${headers}`; + } + } catch (error) { + signal?.throwIfAborted(); + if (plan.dkim?.onSigningFailure !== "send-unsigned") throw error; + console.warn("DKIM signing failed, sending unsigned:", error); + } + signal?.throwIfAborted(); + if (headers.split("\r\n").some((line) => utf8(line).length > 998)) { + throw new RangeError("Signed MIME header exceeds the RFC 5322 line limit."); + } + const size = utf8(headers).length + length; + checkSize(size); + return { + size, + encoding: plan.encoding === "utf8" || /[\u0080-\uffff]/.test(headers) + ? "utf8" + : "7bit", + async *read(signal, progress) { + signal?.throwIfAborted(); + yield utf8(headers); + if (buffered) { + for (const chunk of chunks) { + signal?.throwIfAborted(); + yield chunk.slice(); + } + return; + } + const replayHash = sha256.create(); + let replayLength = 0; + for await (const chunk of plan.body(signal, progress)) { + replayLength += chunk.length; + if (replayLength > length) throw new MimeAttachmentReplayError(); + replayHash.update(chunk); + yield chunk; + } + if ( + replayLength !== length || + base64(replayHash.digest()) !== expectedDigest + ) { + throw new MimeAttachmentReplayError(); + } + }, + }; +} diff --git a/packages/mime/src/test-utils/dkim-test-keys.ts b/packages/mime/src/test-utils/dkim-test-keys.ts new file mode 100644 index 0000000..0c85276 --- /dev/null +++ b/packages/mime/src/test-utils/dkim-test-keys.ts @@ -0,0 +1,92 @@ +/** + * Test key pairs for DKIM signing tests. + * + * WARNING: These keys are for testing purposes only. + * Never use these keys in production environments. + */ + +// ============================================================================= +// RSA Keys (2048-bit) +// ============================================================================= + +/** + * Test RSA private key in PKCS#8 PEM format (2048-bit). + * For testing DKIM signing functionality only. + */ +export const TEST_DKIM_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCqNgoqaoK/hCH3 +BT9TeS2qxBjX18I1oBDEM2LuwI615Nt0QPicVYzW9pYeISfGgcBE4gDhszGvN+6g +GAO/sdmzXlqPLupDeuV++RyjVSFlFJphN+MYVedaFeIzmY+5ryxtYl4BJBOKNdxp +cgte1dgRLEjT5VRCoUxgFgGYZa6PL5zglnEHiU6Girt+7Xl3hbUMM2p1BvkNoUlu +D4BCgHeeJwoF+eNBMiXpJRhqH3HPJosYNgZSpnsqEzeqRSjWk8aV+UwMfbFA8gXG +DMuyyGSxmNrPKg+gM2SEc4UPJccLe0AzKj3YSjKWfiSbmsAZcIPIXh7c+t/4Qi49 +Dzhck5DPAgMBAAECggEAAcMrjp9CZbK25BXYekgNu5KRroNfvhG1j6gLeez/VKsy +2IGnQBskqTfBaBVNuXmSOWu/VjR2b+OqtzOREcE/GDvbI1e0SLe9FNCtbTHTRIB5 +Jh/avul9txj1SzWO8ziCw5kuBv86SLhqR3uIKP4G15XBTKxe8CtesLs8D9cWnDD9 +2LK988qOBA/7zJoluLtKIGj3Iff5cjBFByCTKBKR9V97Z5GoUOB+dWtLFSnccFWY +54LapDGnXBavadhsXWbTO5uTr0ElL0/5imBXz4i7dp246JDYAaWCxgEbXm4SsW3O +Cez9Dx+uJy8IyHbAgGc3gTalsFrr8GXTkiqiZeffAQKBgQDuhzpYuJBwgBGUlWpZ +WmhhKHmwDE1D3h+HYy9ncVwC36WWWUmWe1xRr9DI8Y2Bw858soirJRwPc0e8C/ql +JbKG1XEBp2X6tmgt4oR5noCZrq+QaFJBl9jL8bME1PZ5DH3jttIH8MTLBTjv5Kzl +oxbwfKItvKwVlp5bHX7Sra8/zwKBgQC2rcKUkvpVQNjiFn+1pDaG6yJqjnCnLhN5 +DXLNVp8Y7oqtVSQTOkb3rPdQrVXMczjT5T0bdfq48kiO7yE9vwcrxKNQZKJsLcnQ +u8aG6biNHGD9KGmwrrvMt2ubbyOiv42AVwsnVmc/+jkaQ3W1hPOeidK6/QzgbqRu +cOb38YHfAQKBgQCeCd7wtaiNwWzkg3LpLOuHpCesKxpuYxeEvoTEBumtxbyStyn4 +mFd8j/7HhLP7TF7dY/UFYBsNaZYX0+AH18hHadfr/puk14KDFFgttIUETidoiJYn +e5Ja3hN8mhWL8mjenVzfgfkBgr5Mw7iCleI3CHzzzNQ/oYHeYNaMhCNfJQKBgE/c +AlZFMp6WbLnZsBbOJPAyVqdSgbj0EZs339oYZhDWJ1XDBLRLI78epDdmrz1jmZI4 +gtBAcUzszf9+Vn/RxObDXcnFVKQKGFHh5NYR0pYNs/C3/Aw7Nuo1vRsEKQX6y3cx +ljSqNxTm5JOwrgKejoneInuQKFLsy4FkZfQ6ZdYBAoGBAIWidb2aBSzwNvyqsLSE +ld/sIMSUZs55elni/PYIVoVsucwi64RAf2Yp9CqxM2dY2B/tOaU8ZM8Ih7UBzpwG +syOX7b1HHQ92Vc1Oq0qRkqh+FaAmCNfuitU+YvoRSXUkwoS4bAJgt7vUUZyVUBrY +k2/wS0OnfGTaP+Ycq4Mz5+9S +-----END PRIVATE KEY-----`; + +/** + * Test RSA public key in PEM format (2048-bit). + * Corresponds to TEST_DKIM_PRIVATE_KEY. + */ +export const TEST_DKIM_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqjYKKmqCv4Qh9wU/U3kt +qsQY19fCNaAQxDNi7sCOteTbdED4nFWM1vaWHiEnxoHAROIA4bMxrzfuoBgDv7HZ +s15ajy7qQ3rlfvkco1UhZRSaYTfjGFXnWhXiM5mPua8sbWJeASQTijXcaXILXtXY +ESxI0+VUQqFMYBYBmGWujy+c4JZxB4lOhoq7fu15d4W1DDNqdQb5DaFJbg+AQoB3 +nicKBfnjQTIl6SUYah9xzyaLGDYGUqZ7KhM3qkUo1pPGlflMDH2xQPIFxgzLsshk +sZjazyoPoDNkhHOFDyXHC3tAMyo92Eoyln4km5rAGXCDyF4e3Prf+EIuPQ84XJOQ +zwIDAQAB +-----END PUBLIC KEY-----`; + +/** + * Test domain for DKIM signing. + */ +export const TEST_DKIM_DOMAIN = "test.example.com"; + +/** + * Test selector for DKIM signing. + */ +export const TEST_DKIM_SELECTOR = "test2025"; + +// ============================================================================= +// Ed25519 Keys +// ============================================================================= + +/** + * Test Ed25519 private key in PKCS#8 PEM format. + * For testing DKIM signing functionality only. + */ +export const TEST_DKIM_ED25519_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIHrEIaq5WKCJ5bo54PO3KNnCw7lvWx9ZC1p0q2SRY/7O +-----END PRIVATE KEY-----`; + +/** + * Test Ed25519 public key in PEM format. + * Corresponds to TEST_DKIM_ED25519_PRIVATE_KEY. + */ +export const TEST_DKIM_ED25519_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAYFNLXjz1u/YHsmLd15UAoGdOquVBRCDEQxfi6PmoPkE= +-----END PUBLIC KEY-----`; + +/** + * Test selector for Ed25519 DKIM signing. + */ +export const TEST_DKIM_ED25519_SELECTOR = "ed25519-test2025"; diff --git a/packages/mime/src/test-utils/verify-dkim.ts b/packages/mime/src/test-utils/verify-dkim.ts new file mode 100644 index 0000000..f733096 --- /dev/null +++ b/packages/mime/src/test-utils/verify-dkim.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { createHash, verify } from "node:crypto"; + +/** Independent wire-level oracle; does not import production canonicalizers. */ +export function verifyDkim(raw: string, publicKey: string): void { + const split = raw.indexOf("\r\n\r\n"); + const fields = raw.slice(0, split).split(/\r\n(?![ \t])/); + const dkim = fields.find((field) => field.startsWith("DKIM-Signature:")); + assert.ok(dkim); + const tags = Object.fromEntries( + dkim.slice(15).replace(/\r\n/g, "").split(";").map((tag) => { + const equal = tag.indexOf("="); + return [tag.slice(0, equal).trim(), tag.slice(equal + 1).trim()]; + }), + ); + const [headerMode, bodyMode] = tags.c.split("/"); + let body = raw.slice(split + 4); + if (bodyMode === "relaxed") { + body = body.split("\r\n").map((line) => + line.replace(/[ \t]+/g, " ").replace(/ +$/, "") + ).join("\r\n"); + } + body = body.replace(/(?:\r\n)*$/, ""); + if (body.length > 0 || bodyMode === "simple") body += "\r\n"; + assert.equal(createHash("sha256").update(body).digest("base64"), tags.bh); + const remaining = fields.filter((field) => field !== dkim); + const selected: string[] = []; + for (const name of tags.h.split(":")) { + const index = remaining.findLastIndex((field) => + field.slice(0, field.indexOf(":")).toLowerCase() === name.toLowerCase() + ); + if (index >= 0) selected.push(remaining.splice(index, 1)[0]); + } + selected.push(dkim.replace(/\bb=[\s\S]*$/, "b=")); + const canonical = selected.map((field) => { + if (headerMode === "simple") return field; + const colon = field.indexOf(":"); + return field.slice(0, colon).toLowerCase() + ":" + + field.slice(colon + 1).replace(/\r\n/g, "").replace(/[ \t]+/g, " ") + .trim(); + }).join("\r\n"); + const input = new TextEncoder().encode(canonical); + assert.ok( + verify( + tags.a === "rsa-sha256" ? "RSA-SHA256" : null, + tags.a === "rsa-sha256" + ? input + : createHash("sha256").update(input).digest(), + publicKey, + Uint8Array.from(atob(tags.b.replace(/\s/g, "")), (c) => c.charCodeAt(0)), + ), + ); +} diff --git a/packages/mime/tsdown.config.ts b/packages/mime/tsdown.config.ts new file mode 100644 index 0000000..9ef3b68 --- /dev/null +++ b/packages/mime/tsdown.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsdown"; +export default defineConfig({ + entry: ["src/index.ts", "src/internal.ts"], + dts: true, + format: ["esm", "cjs"], + unbundle: true, + platform: "neutral", +}); diff --git a/packages/smtp/mise.toml b/packages/smtp/mise.toml index abe3702..bcdf1b2 100644 --- a/packages/smtp/mise.toml +++ b/packages/smtp/mise.toml @@ -2,6 +2,7 @@ _.file = { path = ".env", redact = true } [tasks.build] +depends = ["//packages/core:build", "//packages/mime:build"] description = "Build @upyo/smtp" run = "pnpm exec tsdown" sources = ["deno.json", "package.json", "tsdown.config.ts", "src/**/*.ts", "../../pnpm-lock.yaml", "../../pnpm-workspace.yaml"] diff --git a/packages/smtp/package.json b/packages/smtp/package.json index 19273d6..593d6b5 100644 --- a/packages/smtp/package.json +++ b/packages/smtp/package.json @@ -62,5 +62,8 @@ "scripts": { "prepack": "mise run --no-deps :build", "prepublish": "mise run --no-deps :build" + }, + "dependencies": { + "@upyo/mime": "workspace:*" } } diff --git a/packages/smtp/src/config.ts b/packages/smtp/src/config.ts index c0320e7..2e40a1e 100644 --- a/packages/smtp/src/config.ts +++ b/packages/smtp/src/config.ts @@ -1,5 +1,5 @@ -import type { DkimConfig } from "./dkim/index.ts"; -import { validateDkimBodyMode } from "./dkim/types.ts"; +import type { DkimConfig } from "@upyo/mime"; +import { validateDkimBodyMode } from "@upyo/mime/internal"; /** * Configuration interface for SMTP transport connection settings. diff --git a/packages/smtp/src/index.ts b/packages/smtp/src/index.ts index 5b1d508..2822ceb 100644 --- a/packages/smtp/src/index.ts +++ b/packages/smtp/src/index.ts @@ -41,6 +41,6 @@ export type { DkimConfig, DkimSignature, DkimSigningFailureAction, -} from "./dkim/index.ts"; +} from "@upyo/mime"; export { SmtpAttachmentReplayError } from "./message-stream.ts"; export { Smtp8BitMimeUnsupportedError } from "./raw-message.ts"; diff --git a/packages/smtp/src/message-converter.ts b/packages/smtp/src/message-converter.ts index bf424a3..e463c8b 100644 --- a/packages/smtp/src/message-converter.ts +++ b/packages/smtp/src/message-converter.ts @@ -1,825 +1,63 @@ +import { type Message, readAttachmentContent } from "@upyo/core"; import { - type Address, - type AttachmentContent, - type Message, - readAttachmentContent, -} from "@upyo/core"; -import { - formatMessageId, - generateMessageId, - resolveThreadingHeaders, -} from "@upyo/core/message-id"; -import { resolveCalendarContent } from "@upyo/core/calendar"; -import { Buffer } from "node:buffer"; + type DkimConfig, + type PreparedMimeMessage, + prepareMimeMessage, + prepareMimeStream, +} from "@upyo/mime/internal"; import type { ResolvedSmtpDsn } from "./delivery-status.ts"; -import { type DkimConfig, signMessage } from "./dkim/index.ts"; import { type ResolvedSmtpEnvelope, resolveSmtpEnvelope } from "./envelope.ts"; -import { attachmentEncodedSize, encodeAttachment } from "./mime-stream.ts"; export interface SmtpMessage { readonly envelope: SmtpEnvelope; readonly raw: string; - /** Whether the envelope or message headers require RFC 6531 SMTPUTF8. */ readonly requiresSmtpUtf8?: boolean; } - export interface SmtpEnvelope { readonly from: string | null; readonly to: readonly string[]; readonly dsn?: ResolvedSmtpDsn; } - -/** - * Converts a message to its SMTP envelope and wire representation. - * - * @param message The message to convert. - * @param dkimConfig Optional DKIM signing configuration. - * @param dsn Optional validated SMTP delivery status notification parameters. - * @param resolvedEnvelope The validated effective SMTP envelope. - * @param signal Optional cancellation signal. - * @returns The converted SMTP message. - * @throws {RangeError} If a header contains a token that cannot be folded - * within the RFC 5322 hard line-length limit. - * @throws {TypeError} If the message carries an invalid message identifier or - * date, or a `Date` or `Message-ID` header containing a carriage return or line - * feed. - */ -export async function convertMessage( - message: Message, - dkimConfig?: DkimConfig, - dsn?: ResolvedSmtpDsn, - resolvedEnvelope: ResolvedSmtpEnvelope = resolveSmtpEnvelope(message), - signal?: AbortSignal, -): Promise { - const plan = prepareMessage(message, dkimConfig, dsn, resolvedEnvelope); - const chunks: Uint8Array[] = []; - for await (const chunk of plan.body(signal)) chunks.push(chunk); - let raw = plan.headers + Buffer.concat(chunks).toString("utf8").slice(0, -2); - if (dkimConfig) { - try { - for (const sig of dkimConfig.signatures) { - const result = await signMessage(raw, sig, signal); - raw = `${result.headerName}: ${result.signature}\r\n${raw}`; - } - } catch (error) { - signal?.throwIfAborted(); - if (dkimConfig.onSigningFailure === "send-unsigned") { - console.warn("DKIM signing failed, sending unsigned:", error); - } else { - throw error; - } - } - } - return { - envelope: plan.envelope, - raw, - requiresSmtpUtf8: plan.requiresSmtpUtf8, - }; -} - -/** A per-attempt MIME plan with stable headers and replayable body bytes. */ -export interface PreparedSmtpMessage { +/** SMTP envelope and delivery options around a shared MIME plan. */ +export interface PreparedSmtpMessage extends PreparedMimeMessage { readonly envelope: SmtpEnvelope; readonly requiresSmtpUtf8: boolean; - readonly dkim?: DkimConfig; - readonly headers: string; - /** Includes the final transport CRLF, but not SMTP transparency or terminator. */ - body(signal?: AbortSignal, progress?: () => void): AsyncIterable; - /** Unknown factory sizes do not cause a speculative read. */ - size( - signal?: AbortSignal, - checkSize?: (size: number) => void, - ): Promise; } - -/** - * Freezes message metadata without reading attachment content. - * @param message Message whose metadata will be frozen. - * @param dkimConfig Optional signing configuration. - * @param dsn Validated delivery-status parameters. - * @param resolvedEnvelope Validated effective SMTP envelope. - * @returns A deterministic MIME plan for one send attempt. - * @throws {RangeError} If a header cannot fit the RFC 5322 line limit. - * @throws {TypeError} If the message carries an invalid message identifier or - * date, or a `Date` or `Message-ID` header containing a carriage return or line - * feed. - */ +/** Snapshots MIME metadata and preserves the effective SMTP envelope. */ export function prepareMessage( message: Message, dkimConfig?: DkimConfig, dsn?: ResolvedSmtpDsn, resolvedEnvelope: ResolvedSmtpEnvelope = resolveSmtpEnvelope(message), ): PreparedSmtpMessage { - const envelope: SmtpEnvelope = { - ...resolvedEnvelope, - dsn, - }; - const envelopeAddresses = [ - ...(envelope.from == null ? [] : [envelope.from]), - ...envelope.to, - ]; - - const parts = buildMimeParts(message); - const first = parts[0]; - if (typeof first !== "string") throw new TypeError("Missing MIME headers."); - const separator = first.indexOf("\r\n\r\n") + 4; - const headers = first.slice(0, separator); - parts[0] = first.slice(separator); - - // The header block is read rather than the message fields it came from. A - // field written verbatim, such as an address, an identifier, a date, or a - // structured header the message supplied, carries its own characters through, - // whereas a display name or a filename has already been encoded to ASCII by - // the time it lands here. The body is excluded: it travels as - // quoted-printable or Base64, and the MIME part headers are not in this - // slice. - const requiresSmtpUtf8 = [headers, ...envelopeAddresses].some(hasNonAscii); + const plan = prepareMimeMessage(message, dkimConfig); + const envelope = { ...resolvedEnvelope, dsn }; return { + ...plan, envelope, - requiresSmtpUtf8, - dkim: dkimConfig, - headers, - async *body(signal, progress) { - for (const part of parts) { - signal?.throwIfAborted(); - if (typeof part === "string") { - const bytes = Buffer.from(part); - for (let offset = 0; offset < bytes.length; offset += 65536) { - yield bytes.subarray(offset, offset + 65536); - } - } else { - yield* encodeAttachment(part.content, signal, progress); - } - } - }, - async size(signal, checkSize) { - let size = Buffer.byteLength(headers); - let unknown = false; - for (const part of parts) { - signal?.throwIfAborted(); - if (typeof part === "string") size += Buffer.byteLength(part); - else { - if (part.content instanceof Promise) { - part.content = await readAttachmentContent(part.content, signal); - } - if (typeof part.content === "function") unknown = true; - else {size += attachmentEncodedSize( - part.content instanceof Uint8Array - ? part.content.byteLength - : part.content.size, - );} - } - if (!Number.isSafeInteger(size)) { - throw new RangeError("Message size exceeds the safe integer range."); - } - } - checkSize?.(size); - return unknown ? undefined : size; - }, - }; -} - -type MimePart = string | { content: AttachmentContent }; - -function buildMimeParts(message: Message): MimePart[] { - const lines: MimePart[] = []; - const boundary = generateBoundary(); - const hasAttachments = message.attachments.length > 0; - const alternatives = buildAlternatives(message); - const isMultipart = hasAttachments || alternatives.length > 1; - - // Standard headers - lines.push(foldHeader("From", encodeAddress(message.sender))); - lines.push( - foldHeader("To", message.recipients.map(encodeAddress).join(", ")), - ); - - if (message.ccRecipients.length > 0) { - lines.push( - foldHeader( - "Cc", - message.ccRecipients.map(encodeAddress).join(", "), + requiresSmtpUtf8: plan.encoding === "utf8" || + [envelope.from ?? "", ...envelope.to].some((address) => + /[\u0080-\uffff]/.test(address) ), - ); - } - - if (message.replyRecipients.length > 0) { - lines.push( - foldHeader( - "Reply-To", - message.replyRecipients.map(encodeAddress).join(", "), - ), - ); - } - - lines.push(foldHeader("Subject", encodeHeaderValue(message.subject, true))); - - // The identity and threading fields all carry structured values, so they are - // written verbatim rather than RFC 2047 encoded. - lines.push(foldHeader("Date", resolveDate(message))); - lines.push(foldHeader("Message-ID", resolveMessageId(message))); - const threading = resolveThreadingHeaders(message); - for (const name of ["In-Reply-To", "References"]) { - // A field the message does not own falls back to a custom header, one it - // owns but left empty writes nothing at all. - const owned = threading.get(name); - const value = owned === undefined ? overridden(message, name) : owned; - if (value != null) lines.push(foldHeader(name, value)); - } - - // Names already written above, plus Cc and Reply-To even when the message - // carries no such recipients: a custom header there would list addresses the - // envelope never receives. The identity and threading names are here - // unconditionally too, even when nothing was written: each of them has had - // its one chance to be emitted above, from the typed field or from the very - // custom header this loop would otherwise write again. - const composed = new Set([ - "from", - "to", - "cc", - "reply-to", - "subject", - "date", - "message-id", - "in-reply-to", - "references", - ]); - - // Priority header - if (message.priority !== "normal") { - const priorityValue = message.priority === "high" ? "1" : "5"; - lines.push(`X-Priority: ${priorityValue}`); - lines.push( - `X-MSMail-Priority: ${message.priority === "high" ? "High" : "Low"}`, - ); - composed.add("x-priority"); - composed.add("x-msmail-priority"); - } - - // Custom headers - for (const [key, value] of message.headers) { - const name = key.toLowerCase(); - if (composed.has(name) || reservedHeaders.has(name)) continue; - lines.push(foldHeader(key, encodeHeaderValue(value))); - } - - // MIME headers - lines.push("MIME-Version: 1.0"); - - if (isMultipart) { - lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`); - lines.push(""); - lines.push("This is a multi-part message in MIME format."); - lines.push(""); - - // Content part - lines.push(`--${boundary}`); - - if (alternatives.length > 1) { - const contentBoundary = generateBoundary(); - lines.push( - `Content-Type: multipart/alternative; boundary="${contentBoundary}"`, - ); - lines.push(""); - - for (const alternative of alternatives) { - lines.push(`--${contentBoundary}`); - lines.push(`Content-Type: ${alternative.contentType}`); - lines.push(`Content-Transfer-Encoding: ${alternative.encoding}`); - lines.push(""); - lines.push(alternative.body); - lines.push(""); - } - - lines.push(`--${contentBoundary}--`); - } else { - lines.push(`Content-Type: ${alternatives[0].contentType}`); - lines.push(`Content-Transfer-Encoding: ${alternatives[0].encoding}`); - lines.push(""); - lines.push(alternatives[0].body); - } - - // Attachments - for (const attachment of message.attachments) { - lines.push(""); - lines.push(`--${boundary}`); - lines.push( - foldHeader( - "Content-Type", - `${attachment.contentType}; ${ - encodeMimeParameter("name", attachment.filename) - }`, - ), - ); - lines.push("Content-Transfer-Encoding: base64"); - - if (attachment.inline) { - lines.push( - foldHeader( - "Content-Disposition", - `inline; ${encodeMimeParameter("filename", attachment.filename)}`, - ), - ); - lines.push(`Content-ID: <${attachment.contentId}>`); - } else { - lines.push( - foldHeader( - "Content-Disposition", - `attachment; ${ - encodeMimeParameter("filename", attachment.filename) - }`, - ), - ); - } - - lines.push(""); - lines.push({ content: attachment.content }); - } - - lines.push(""); - lines.push(`--${boundary}--`); - } else { - // Single part message - lines.push(`Content-Type: ${alternatives[0].contentType}`); - lines.push(`Content-Transfer-Encoding: ${alternatives[0].encoding}`); - lines.push(""); - lines.push(alternatives[0].body); - } - - const parts: MimePart[] = []; - let text = ""; - for (const line of lines) { - if (typeof line === "string") text += line; - else { - parts.push(text, line); - text = ""; - } - text += "\r\n"; - } - parts.push(text); - return parts; -} - -/** - * One body alternative, already encoded and ready to be written into a part. - */ -interface MimeAlternative { - readonly contentType: string; - readonly encoding: "quoted-printable" | "base64"; - readonly body: string; -} - -/** - * Collects the body alternatives of a message, least preferred first. - * - * RFC 2046 §5.1.4 orders a `multipart/alternative` by increasing richness, so a - * client picks the last one it understands. Plain text comes first, then HTML, - * then the calendar object: a client that schedules should act on the - * invitation rather than render the prose describing it. - * - * The presence of a body is tested with `in` rather than truthiness, so an - * empty string a caller supplied deliberately still produces its part. - * - * @param message The message being composed. - * @returns At least one alternative, in the order they are written. - * @throws {TypeError} If the calendar content is not valid. - */ -function buildAlternatives(message: Message): MimeAlternative[] { - const alternatives: MimeAlternative[] = []; - - if ("text" in message.content && message.content.text !== undefined) { - alternatives.push({ - contentType: "text/plain; charset=utf-8", - encoding: "quoted-printable", - body: encodeQuotedPrintable(message.content.text), - }); - } - - if ("html" in message.content) { - alternatives.push({ - contentType: "text/html; charset=utf-8", - encoding: "quoted-printable", - body: encodeQuotedPrintable(message.content.html), - }); - } - - if (message.calendar != null) { - // Re-validated here rather than trusted from the message: `Message` is a - // structural interface, and the method is written into a `Content-Type` - // parameter, the same reason `formatMessageId()` checks again below. - const calendar = resolveCalendarContent(message.calendar); - alternatives.push({ - // The parameter repeats the object's own METHOD property, which RFC 6047 - // §2.4 requires. Base64 keeps the CRLF structure RFC 5545 §3.1 makes - // normative intact, which quoted-printable would not. - contentType: `text/calendar; charset=utf-8; method=${calendar.method}`, - encoding: "base64", - body: encodeBase64Body(calendar.content), - }); - } - - if (alternatives.length < 1) { - alternatives.push({ - contentType: "text/plain; charset=utf-8", - encoding: "quoted-printable", - body: "", - }); - } - - return alternatives; -} - -/** - * Encodes a body as Base64, wrapped at the 76 columns RFC 2045 §6.8 allows. - * - * The result carries no trailing CRLF, because MIME assembly appends one to - * every line it writes. - * - * @param text The body to encode. - * @returns The wrapped Base64 payload. - */ -function encodeBase64Body(text: string): string { - const encoded = Buffer.from(new TextEncoder().encode(text)).toString( - "base64", - ); - const lines: string[] = []; - for (let offset = 0; offset < encoded.length; offset += 76) { - lines.push(encoded.slice(offset, offset + 76)); - } - return lines.join("\r\n"); -} - -/** - * Reads a header field that overrides a generated default. - * - * Unlike custom headers, the value is written verbatim rather than RFC 2047 - * encoded, so it cannot rely on `encodeHeaderValue()` to neutralize control - * characters. `ImmutableHeaders` is a structural type, so a `Message` may - * carry an adapter that never rejected CR or LF the way a platform `Headers` - * does. - * - * @param message The message whose headers are read. - * @param name The header field name. - * @returns The supplied value, or `undefined` when the message has none. - * @throws {TypeError} If the value contains a carriage return or line feed, - * which would inject additional header fields into the message. - */ -function overridden(message: Message, name: string): string | undefined { - const value = message.headers.get(name); - if (value == null) return undefined; - if (/[\r\n]/.test(value)) { - throw new TypeError( - `Header field ${name} must not contain a carriage return or line feed.`, - ); - } - return value; -} - -/** - * Header fields the composer owns but writes after the custom headers, or - * deliberately omits. A custom header with one of these names is dropped - * rather than appended: the structured `Message` fields are authoritative, - * RFC 5322 §3.6 allows at most one of each, and a duplicate placed before the - * composer's own field makes parsers that take the first occurrence read the - * wrong value. - */ -const reservedHeaders: ReadonlySet = new Set([ - // Bcc recipients travel in the SMTP envelope only, so a Bcc header would - // disclose them to every recipient. - "bcc", - "content-transfer-encoding", - "content-type", - "mime-version", -]); - -/** - * Tells whether a value contains a character outside ASCII, which a message - * can only carry with the SMTPUTF8 extension (RFC 6531). - * - * @param value The value to check. - * @returns Whether the value contains a character outside ASCII. - */ -function hasNonAscii(value: string): boolean { - for (const character of value) { - if ((character.codePointAt(0) ?? 0) > 0x7f) return true; - } - return false; -} - -function generateBoundary(): string { - return `boundary-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; -} - -/** - * Resolves the origination date: the typed field, then a custom header, then - * the time of conversion. - * - * @param message The message being composed. - * @returns The `Date` field value. - * @throws {TypeError} If the message carries an invalid date, or a custom - * `Date` header containing a carriage return or line feed. - */ -function resolveDate(message: Message): string { - return message.date === undefined - ? overridden(message, "Date") ?? formatDate(new Date()) - : formatDate(message.date); -} - -/** - * Resolves the message identifier: the typed field, then a custom header, then - * one generated within the sender's domain. - * - * @param message The message being composed. - * @returns The `Message-ID` field value, enclosed in angle brackets. - * @throws {TypeError} If the message carries an invalid identifier, or a custom - * `Message-ID` header containing a carriage return or line feed. - */ -function resolveMessageId(message: Message): string { - if (message.messageId !== undefined) { - return formatMessageId(message.messageId); - } - const custom = overridden(message, "Message-ID"); - if (custom != null) return custom; - return formatMessageId(generateSenderMessageId(message.sender.address)); -} - -/** - * Generates an identifier rooted in the sender's domain. - * - * `parseAddress()` validates a domain with `URL`, which accepts spellings the - * identifier grammar does not, such as the trailing dot of a fully qualified - * name or a host carrying a port. A sender written that way used to be - * delivered, so an unusable domain falls back to `localhost` rather than - * failing the send. Uniqueness holds either way: the left half is a UUID. - * - * @param address The sender's address. - * @returns A bare message identifier. - */ -function generateSenderMessageId(address: string): string { - const domain = domainOf(address).replace(/\.$/, ""); - try { - return generateMessageId(domain); - } catch { - return generateMessageId("localhost"); - } -} - -/** - * Extracts the domain of an address. - * - * The separator is the first at sign outside a quoted local part, since a - * quoted one may contain at signs of its own, as in `"a@b"@example.com`. - * - * @param address The address to read. - * @returns The domain, or the whole address when it carries no separator. - */ -function domainOf(address: string): string { - let quoted = false; - for (let index = 0; index < address.length; index++) { - const character = address[index]; - if (quoted && character === "\\") index++; - else if (character === '"') quoted = !quoted; - else if (!quoted && character === "@") return address.slice(index + 1); - } - return address; -} - -const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - -const monthNames = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", -]; - -/** - * Formats a date as an RFC 5322 §3.3 `date-time` in UTC. - * - * `Date.toUTCString()` is close but ends in the obsolete `GMT` zone rather than - * the numeric `+0000` the current grammar asks for. - * - * @param date The date to format. - * @returns The formatted date. - * @throws {TypeError} If the date is invalid, or earlier than the grammar can - * express. - */ -function formatDate(date: Date): string { - const time = date instanceof Date ? date.getTime() : Number.NaN; - const year = Number.isNaN(time) ? Number.NaN : date.getUTCFullYear(); - if (Number.isNaN(time) || year < 1900) { - throw new TypeError(`Invalid date: ${JSON.stringify(date)}`); - } - const pad = (value: number) => value.toString().padStart(2, "0"); - return `${dayNames[date.getUTCDay()]}, ${pad(date.getUTCDate())} ` + - `${monthNames[date.getUTCMonth()]} ${year.toString().padStart(4, "0")} ` + - `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:` + - `${pad(date.getUTCSeconds())} +0000`; -} - -function encodeAddress(address: Address): string { - if (address.name == null) { - // No display name, just return the email address - return address.address; - } - - // Encode only the display name part, leave email address as-is - const encodedDisplayName = encodeHeaderValue(address.name, true); - return `${encodedDisplayName} <${address.address}>`; -} - -function encodeHeaderValue( - value: string, - encodeLongAsciiWords = false, -): string { - // RFC 2047 encoding for non-ASCII characters in headers - const hasLongWord = value.split(/\s+/).some((word) => word.length > 60); - if ( - !/^[\x20-\x7E]*$/.test(value) || - (encodeLongAsciiWords && hasLongWord) - ) { - const encodeWord = (text: string): string => { - const utf8Bytes = new TextEncoder().encode(text); - const base64 = Buffer.from(utf8Bytes).toString("base64"); - return `=?UTF-8?B?${base64}?=`; - }; - const maxEncodedLength = 75; - const encodedWord = encodeWord(value); - - if (encodedWord.length <= maxEncodedLength) { - return encodedWord; - } - - const words: string[] = []; - let currentText = ""; - - for (const character of value) { - const candidate = currentText + character; - if (encodeWord(candidate).length <= maxEncodedLength) { - currentText = candidate; - } else { - if (currentText.length > 0) { - words.push(encodeWord(currentText)); - } - currentText = character; - } - } - - if (currentText.length > 0) { - words.push(encodeWord(currentText)); - } - - return words.join(" "); - } - return value; + }; } - -function encodeMimeParameter(name: string, value: string): string { - const escapedValue = value.replace(/[\\"]/g, "\\$&"); - const quotedParameter = `${name}="${escapedValue}"`; - if (/^[\x20-\x7E]*$/.test(value) && quotedParameter.length <= 60) { - return quotedParameter; - } - - const encodedBytes = Array.from( - new TextEncoder().encode(value), - (byte) => { - const character = String.fromCharCode(byte); - return /^[A-Za-z0-9!#$&+.^_`|~-]$/.test(character) - ? character - : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`; - }, +/** Collects the shared stream using the historical internal trailing-CRLF convention. */ +export async function convertMessage( + message: Message, + dkimConfig?: DkimConfig, + dsn?: ResolvedSmtpDsn, + resolvedEnvelope: ResolvedSmtpEnvelope = resolveSmtpEnvelope(message), + signal?: AbortSignal, +): Promise { + const plan = prepareMessage(message, dkimConfig, dsn, resolvedEnvelope); + const stream = await prepareMimeStream(plan, { signal }); + const bytes = await readAttachmentContent( + (signal) => stream.read(signal), + signal, ); - const segments: string[] = []; - let segment = ""; - - for (const encodedByte of encodedBytes) { - if (segment.length + encodedByte.length > 45) { - segments.push(segment); - segment = ""; - } - segment += encodedByte; - } - if (segment.length > 0 || segments.length === 0) segments.push(segment); - - return segments.map((part, index) => - `${name}*${index}*=${index === 0 ? "UTF-8''" : ""}${part}` - ).join("; "); -} - -function foldHeader(name: string, value: string): string { - const recommendedLineLength = 78; - const lines: string[] = []; - let prefix = `${name}: `; - let remaining = value; - - while (prefix.length + remaining.length > recommendedLineLength) { - const availableLength = recommendedLineLength - prefix.length; - let breakIndex = -1; - - for ( - let index = Math.min(availableLength, remaining.length - 1); - index >= 0; - index-- - ) { - if (remaining[index] === " " || remaining[index] === "\t") { - breakIndex = index; - break; - } - } - - if (breakIndex < 0) { - for ( - let index = Math.max(availableLength + 1, 0); - index < remaining.length; - index++ - ) { - if (remaining[index] === " " || remaining[index] === "\t") { - breakIndex = index; - break; - } - } - } - - if (breakIndex < 0) break; - - let whitespaceEnd = breakIndex + 1; - while ( - whitespaceEnd < remaining.length && - (remaining[whitespaceEnd] === " " || remaining[whitespaceEnd] === "\t") - ) { - whitespaceEnd++; - } - - if (whitespaceEnd === remaining.length) break; - - lines.push(prefix + remaining.slice(0, breakIndex)); - prefix = remaining.slice(breakIndex, whitespaceEnd); - remaining = remaining.slice(whitespaceEnd); - } - - lines.push(prefix + remaining); - if (lines.some((line) => Buffer.byteLength(line, "utf8") > 998)) { - throw new RangeError( - `Header field ${name} contains a token too long to fold.`, - ); - } - return lines.join("\r\n"); -} - -function encodeQuotedPrintable(text: string): string { - // First encode the entire string as UTF-8 bytes - const utf8Bytes = new TextEncoder().encode(text); - - let result = ""; - let lineLength = 0; - const maxLineLength = 76; - - for (let i = 0; i < utf8Bytes.length; i++) { - const byte = utf8Bytes[i]; - let encoded = ""; - - // Check if byte needs encoding - if ( - byte < 32 || // Control characters - byte > 126 || // Non-ASCII - byte === 61 || // '=' character - (byte === 46 && lineLength === 0) // '.' at start of line - ) { - encoded = `=${byte.toString(16).toUpperCase().padStart(2, "0")}`; - } else { - encoded = String.fromCharCode(byte); - } - - // Check if adding this encoded sequence would exceed line length - if (lineLength + encoded.length > maxLineLength) { - // Add soft line break (= followed by CRLF) - result += "=\r\n"; - lineLength = 0; - } - - result += encoded; - lineLength += encoded.length; - - // Handle line breaks in the original text - if (byte === 13 && i + 1 < utf8Bytes.length && utf8Bytes[i + 1] === 10) { - // CRLF sequence - add LF and reset line length - i++; // Skip the LF byte since we're handling it here - result += String.fromCharCode(10); - lineLength = 0; - } else if (byte === 10 && (i === 0 || utf8Bytes[i - 1] !== 13)) { - // Standalone LF - reset line length - lineLength = 0; - } - } - - return result; + return { + envelope: plan.envelope, + raw: new TextDecoder().decode(bytes).slice(0, -2), + requiresSmtpUtf8: plan.requiresSmtpUtf8 || stream.encoding === "utf8", + }; } diff --git a/packages/smtp/src/message-stream.test.ts b/packages/smtp/src/message-stream.test.ts index 9453f71..6ed50c5 100644 --- a/packages/smtp/src/message-stream.test.ts +++ b/packages/smtp/src/message-stream.test.ts @@ -7,12 +7,12 @@ import { prepareMessageStream, SmtpAttachmentReplayError, } from "./message-stream.ts"; -import { signMessage } from "./dkim/sign.ts"; +import { signMessage } from "@upyo/mime/internal"; import { TEST_DKIM_ED25519_PRIVATE_KEY, TEST_DKIM_PRIVATE_KEY, } from "./test-utils/dkim-test-keys.ts"; -import type { DkimConfig } from "./dkim/types.ts"; +import type { DkimConfig } from "@upyo/mime"; async function collect( source: AsyncIterable, diff --git a/packages/smtp/src/message-stream.ts b/packages/smtp/src/message-stream.ts index f6f7569..ffb16e4 100644 --- a/packages/smtp/src/message-stream.ts +++ b/packages/smtp/src/message-stream.ts @@ -1,13 +1,10 @@ -import { Buffer } from "node:buffer"; -import { createHash } from "node:crypto"; -import { BodyHasher } from "./dkim/body-hash.ts"; -import { signWithBodyHash } from "./dkim/sign.ts"; +import { + MimeAttachmentReplayError, + prepareMimeStream, +} from "@upyo/mime/internal"; import type { PreparedSmtpMessage } from "./message-converter.ts"; -/** - * A replayable attachment changed between DKIM body reads. - * @since 0.6.0 - */ +/** A replayable attachment changed between DKIM body reads. @since 0.6.0 */ export class SmtpAttachmentReplayError extends TypeError { /** Creates a replay validation failure. */ constructor() { @@ -19,97 +16,45 @@ export class SmtpAttachmentReplayError extends TypeError { /** The final bytes and known size of an SMTP DATA transaction. */ export interface MessageStream { readonly size: number | undefined; + readonly encoding?: "7bit" | "8bit" | "utf8"; read(signal?: AbortSignal, progress?: () => void): AsyncIterable; } -/** Prepares signatures and sizes without consuming unsigned factory sources. */ +/** Prepares signatures and sizes under the SMTP preparation timeout. */ export async function prepareMessageStream( plan: PreparedSmtpMessage, checkSize: (size: number) => void, progress: () => void, signal?: AbortSignal, ): Promise { - const knownSize = await plan.size(signal, checkSize); - if (knownSize != null) checkSize(knownSize); - const signatures = plan.dkim?.signatures ?? []; - if (signatures.length === 0) { + try { + const knownSize = await plan.size(signal, checkSize); + const stream = await prepareMimeStream(plan, { + knownSize, + checkSize, + progress, + signal, + }); return { - size: knownSize, + size: stream.size, + encoding: stream.encoding, async *read(signal, progress) { - yield Buffer.from(plan.headers); - yield* plan.body(signal, progress); + try { + yield* stream.read(signal, progress); + } catch (error) { + signal?.throwIfAborted(); + if (error instanceof MimeAttachmentReplayError) { + throw new SmtpAttachmentReplayError(); + } + throw error; + } }, }; - } - const buffered = plan.dkim?.bodyMode !== "streaming"; - const chunks: Uint8Array[] = []; - const hashes = new Map<"simple" | "relaxed", BodyHasher>(); - for (const sig of signatures) { - const mode = sig.canonicalization?.endsWith("/simple") - ? "simple" - : "relaxed"; - if (!hashes.has(mode)) hashes.set(mode, new BodyHasher(mode)); - } - const rawHash = createHash("sha256"); - let length = 0; - const headerLength = Buffer.byteLength(plan.headers); - for await (const chunk of plan.body(signal, progress)) { - length += chunk.length; - checkSize(headerLength + length); - if (chunk.length > 0) progress(); - if (buffered) chunks.push(chunk.slice()); - rawHash.update(chunk); - for (const hash of hashes.values()) hash.update(chunk); - } - const expectedDigest = rawHash.digest("hex"); - const bodyHashes = new Map( - Array.from(hashes, ([mode, hash]) => [mode, hash.digest()]), - ); - let headers = plan.headers; - try { - for (const sig of signatures) { - const mode = sig.canonicalization?.endsWith("/simple") - ? "simple" - : "relaxed"; - const result = await signWithBodyHash( - headers, - sig, - bodyHashes.get(mode)!, - signal, - ); - headers = `${result.headerName}: ${result.signature}\r\n${headers}`; - } } catch (error) { signal?.throwIfAborted(); - if (plan.dkim?.onSigningFailure !== "send-unsigned") throw error; - console.warn("DKIM signing failed, sending unsigned:", error); + if (error instanceof MimeAttachmentReplayError) { + throw new SmtpAttachmentReplayError(); + } + throw error; } - const size = Buffer.byteLength(headers) + length; - checkSize(size); - return { - size, - async *read(signal, progress) { - yield Buffer.from(headers); - if (buffered) { - for (const chunk of chunks) { - signal?.throwIfAborted(); - yield chunk; - } - return; - } - const replayHash = createHash("sha256"); - let replayLength = 0; - for await (const chunk of plan.body(signal, progress)) { - replayLength += chunk.length; - if (replayLength > length) throw new SmtpAttachmentReplayError(); - replayHash.update(chunk); - yield chunk; - } - if ( - replayLength !== length || replayHash.digest("hex") !== expectedDigest - ) { - throw new SmtpAttachmentReplayError(); - } - }, - }; } diff --git a/packages/smtp/src/mime-regression.test.ts b/packages/smtp/src/mime-regression.test.ts new file mode 100644 index 0000000..067ac49 --- /dev/null +++ b/packages/smtp/src/mime-regression.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createMessage } from "@upyo/core"; +import { convertMessage } from "./message-converter.ts"; + +for ( + const text of [ + "first\r\nsecond", + "a".repeat(150), + ("x".repeat(60) + "\n").repeat(20), + ] +) { + test(`quoted-printable physical lines: ${JSON.stringify(text.slice(0, 20))}`, async () => { + const { raw } = await convertMessage(createMessage({ + from: "from@example.com", + to: "to@example.com", + subject: "Test", + content: { text }, + })); + const body = raw.split("\r\n\r\n")[1]; + assert.ok(!/(? line.length <= 76)); + const decoded = body.replace(/=\r\n/g, "").replace( + /=([0-9A-F]{2})/g, + (_, hex) => String.fromCharCode(parseInt(hex, 16)), + ); + assert.equal(decoded, text); + }); +} + +test("reject an unfoldable inline Content-ID", async () => { + await assert.rejects( + convertMessage(createMessage({ + from: "from@example.com", + to: "to@example.com", + subject: "Test", + content: { text: "Test" }, + attachments: { + filename: "a", + contentType: "text/plain", + content: new Uint8Array(), + inline: true, + contentId: "x".repeat(1000), + }, + })), + RangeError, + ); +}); diff --git a/packages/smtp/src/raw-message.integration.test.ts b/packages/smtp/src/raw-message.integration.test.ts index 53a40c7..5074931 100644 --- a/packages/smtp/src/raw-message.integration.test.ts +++ b/packages/smtp/src/raw-message.integration.test.ts @@ -341,3 +341,141 @@ test("SMTP raw reports partial RCPT rejection", async () => { await context.close(); } }); + +import { composeMessage } from "@upyo/mime"; +import { readAttachmentContent } from "@upyo/core"; +import { TEST_DKIM_PRIVATE_KEY } from "./test-utils/dkim-test-keys.ts"; + +const composedMessage = () => + createMessage({ + from: "sender@example.com", + to: "recipient@example.com", + bcc: "blind@example.com", + subject: "Composed", + content: { text: ".First\r\nSecond" }, + }); +const signing = { + signingDomain: "example.com", + selector: "test", + privateKey: TEST_DKIM_PRIVATE_KEY, +}; + +test("SMTP delivers composed bytes and the Bcc envelope", async () => { + const context = await setup(); + try { + const composed = await composeMessage(composedMessage(), { + dkim: { signatures: [signing] }, + }); + const expected = await readAttachmentContent(composed.content); + assert.ok((await context.transport.sendRaw(composed)).successful); + const received = context.server.getReceivedMessages()[0]; + assert.deepEqual( + Buffer.from(received.rawData.toString().replace(/^\.\./gm, ".")), + Buffer.from(expected), + ); + assert.ok( + context.server.getReceivedCommands().includes( + "RCPT TO:", + ), + ); + } finally { + await context.close(); + } +}); + +test("SMTP raw maps composed replay errors and discards interrupted DATA connections", async () => { + const context = await setup(); + try { + let reads = 0; + const composed = await composeMessage({ + ...composedMessage(), + attachments: [{ + filename: "a", + contentId: "a", + contentType: "text/plain", + inline: false, + content: async function* () { + yield new Uint8Array([reads++]); + }, + }], + }, { dkim: { bodyMode: "streaming", signatures: [signing] } }); + const receipt = await context.transport.sendRaw(composed); + assert.ok(!receipt.successful); + assert.equal(receipt.errors?.[0].code, "smtp.raw-message-invalid"); + assert.ok(!receipt.errors?.[0].retryable); + assert.ok((await context.transport.send(composedMessage())).successful); + assert.equal(context.server.getConnectionCount(), 2); + } finally { + await context.close(); + } +}); + +for (const source of ["nested", "signed"] as const) { + test(`SMTP negotiates ${source} Unicode headers before MAIL`, async () => { + const context = await setup(["8BITMIME"]); + try { + if (source === "signed") { + context.transport.config = { + ...context.transport.config, + dkim: { signatures: [{ ...signing, signingDomain: "한글.example" }] }, + }; + } + const message = source === "nested" + ? { + ...composedMessage(), + attachments: [{ + filename: "a", + contentId: "한글", + contentType: "text/plain" as const, + inline: true, + content: new Uint8Array(), + }], + } + : composedMessage(); + const receipt = await context.transport.send(message); + assert.ok(!receipt.successful); + assert.ok( + !context.server.getReceivedCommands().some((c) => + c.startsWith("MAIL ") + ), + ); + context.transport.config = { + ...context.transport.config, + dkim: undefined, + }; + assert.ok((await context.transport.send(composedMessage())).successful); + assert.equal(context.server.getConnectionCount(), 1); + } finally { + await context.close(); + } + const supported = await setup(); + try { + if (source === "signed") { + supported.transport.config = { + ...supported.transport.config, + dkim: { signatures: [{ ...signing, signingDomain: "한글.example" }] }, + }; + } + const message = source === "nested" + ? { + ...composedMessage(), + attachments: [{ + filename: "a", + contentId: "한글", + contentType: "text/plain" as const, + inline: true, + content: new Uint8Array(), + }], + } + : composedMessage(); + assert.ok((await supported.transport.send(message)).successful); + assert.ok( + supported.server.getReceivedCommands().some((c) => + c.startsWith("MAIL ") && c.includes("SMTPUTF8") + ), + ); + } finally { + await supported.close(); + } + }); +} diff --git a/packages/smtp/src/smtp-connection.ts b/packages/smtp/src/smtp-connection.ts index b01a3f6..c46125c 100644 --- a/packages/smtp/src/smtp-connection.ts +++ b/packages/smtp/src/smtp-connection.ts @@ -1096,6 +1096,7 @@ export class SmtpConnection { prepareMessageStream(message, checkSize, progress, signal), signal, ); + negotiate(stream.encoding); if (stream.size != null) { checkSize(stream.size); if (sizeCapability != null) sizeParameter = ` SIZE=${stream.size}`; diff --git a/packages/smtp/src/smtp-transport.dkim.mailpit.test.ts b/packages/smtp/src/smtp-transport.dkim.mailpit.test.ts index a86acc6..a149a38 100644 --- a/packages/smtp/src/smtp-transport.dkim.mailpit.test.ts +++ b/packages/smtp/src/smtp-transport.dkim.mailpit.test.ts @@ -1,7 +1,7 @@ import { SmtpTransport } from "@upyo/smtp"; import assert from "node:assert/strict"; import { describe, test } from "node:test"; -import type { DkimConfig } from "./dkim/index.ts"; +import type { DkimConfig } from "@upyo/mime"; import { MailpitClient } from "./test-utils/mailpit-client.ts"; import { waitForMailpitDelivery } from "./test-utils/mailpit-delivery-utils.ts"; import { diff --git a/packages/smtp/src/smtp-transport.dkim.test.ts b/packages/smtp/src/smtp-transport.dkim.test.ts index abe44d2..b6752c2 100644 --- a/packages/smtp/src/smtp-transport.dkim.test.ts +++ b/packages/smtp/src/smtp-transport.dkim.test.ts @@ -1,7 +1,7 @@ import type { Message } from "@upyo/core"; import assert from "node:assert/strict"; import { describe, test } from "node:test"; -import type { DkimConfig } from "./dkim/index.ts"; +import type { DkimConfig } from "@upyo/mime"; import { convertMessage } from "./message-converter.ts"; import { TEST_DKIM_DOMAIN, diff --git a/packages/smtp/src/smtp-transport.ts b/packages/smtp/src/smtp-transport.ts index 6f21cfd..0620e7a 100644 --- a/packages/smtp/src/smtp-transport.ts +++ b/packages/smtp/src/smtp-transport.ts @@ -38,7 +38,7 @@ import { import { OAuth2TokenManager } from "./oauth2.ts"; import { prepareMessage } from "./message-converter.ts"; import { SmtpAttachmentReplayError } from "./message-stream.ts"; -import { validateDkimBodyMode } from "./dkim/types.ts"; +import { validateDkimBodyMode } from "@upyo/mime/internal"; import type { SmtpEnhancedStatusCode, SmtpReceipt } from "./smtp-receipt.ts"; import { parseEnhancedSmtpStatusCode } from "./smtp-status-code.ts"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a522833..946153c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ catalogs: '@logtape/testing': specifier: ^2.2.4 version: 2.2.4 + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 @@ -27,6 +30,15 @@ catalogs: '@opentelemetry/semantic-conventions': specifier: ^1.25.1 version: 1.36.0 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 + miniflare: + specifier: 4.20260730.0 + version: 4.20260730.0 + postal-mime: + specifier: ^3.0.0 + version: 3.0.0 tsdown: specifier: ^0.12.7 version: 0.12.9 @@ -58,7 +70,7 @@ importers: version: 1.36.0 '@shikijs/vitepress-twoslash': specifier: ^3.23.0 - version: 3.23.0(typescript@5.8.3) + version: 3.23.0(supports-color@10.2.2)(typescript@5.8.3) '@types/node': specifier: ^24.0.13 version: 24.0.13 @@ -83,6 +95,9 @@ importers: '@upyo/mailtrap': specifier: 'workspace:' version: link:../packages/mailtrap + '@upyo/mime': + specifier: 'workspace:' + version: link:../packages/mime '@upyo/mock': specifier: 'workspace:' version: link:../packages/mock @@ -130,13 +145,13 @@ importers: version: 1.7.5(vite@7.3.6(@types/node@24.0.13)(jiti@2.4.2)) vitepress-plugin-llms: specifier: ^1.13.2 - version: 1.13.2 + version: 1.13.2(supports-color@10.2.2) packages/core: devDependencies: tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(supports-color@10.2.2)(typescript@5.8.3) typescript: specifier: 'catalog:' version: 5.8.3 @@ -147,12 +162,15 @@ importers: specifier: workspace:* version: link:../core devDependencies: + '@upyo/mime': + specifier: workspace:* + version: link:../mime jmap-rfc-types: specifier: ^0.1.2 version: 0.1.2 tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(supports-color@10.2.2)(typescript@5.8.3) typescript: specifier: 'catalog:' version: 5.8.3 @@ -165,7 +183,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(supports-color@10.2.2)(typescript@5.8.3) typescript: specifier: 'catalog:' version: 5.8.3 @@ -184,7 +202,7 @@ importers: version: 2.2.4(@logtape/logtape@2.2.4) tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(supports-color@10.2.2)(typescript@5.8.3) typescript: specifier: 'catalog:' version: 5.8.3 @@ -197,7 +215,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(supports-color@10.2.2)(typescript@5.8.3) typescript: specifier: 'catalog:' version: 5.8.3 @@ -210,7 +228,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(supports-color@10.2.2)(typescript@5.8.3) typescript: specifier: 'catalog:' version: 5.8.3 @@ -223,7 +241,32 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(supports-color@10.2.2)(typescript@5.8.3) + typescript: + specifier: 'catalog:' + version: 5.8.3 + + packages/mime: + dependencies: + '@noble/hashes': + specifier: 'catalog:' + version: 1.8.0 + '@upyo/core': + specifier: workspace:* + version: link:../core + devDependencies: + esbuild: + specifier: 'catalog:' + version: 0.28.2 + miniflare: + specifier: 'catalog:' + version: 4.20260730.0 + postal-mime: + specifier: 'catalog:' + version: 3.0.0 + tsdown: + specifier: 'catalog:' + version: 0.12.9(supports-color@10.2.2)(typescript@5.8.3) typescript: specifier: 'catalog:' version: 5.8.3 @@ -236,7 +279,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(supports-color@10.2.2)(typescript@5.8.3) typescript: specifier: 'catalog:' version: 5.8.3 @@ -267,7 +310,7 @@ importers: version: 1.36.0 tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(supports-color@10.2.2)(typescript@5.8.3) typescript: specifier: 'catalog:' version: 5.8.3 @@ -280,7 +323,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(supports-color@10.2.2)(typescript@5.8.3) typescript: specifier: 'catalog:' version: 5.8.3 @@ -296,7 +339,7 @@ importers: version: link:../mock tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(supports-color@10.2.2)(typescript@5.8.3) typescript: specifier: 'catalog:' version: 5.8.3 @@ -309,7 +352,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(supports-color@10.2.2)(typescript@5.8.3) typescript: specifier: 'catalog:' version: 5.8.3 @@ -322,7 +365,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(supports-color@10.2.2)(typescript@5.8.3) typescript: specifier: 'catalog:' version: 5.8.3 @@ -335,7 +378,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(supports-color@10.2.2)(typescript@5.8.3) typescript: specifier: 'catalog:' version: 5.8.3 @@ -348,7 +391,7 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(supports-color@10.2.2)(typescript@5.8.3) typescript: specifier: 'catalog:' version: 5.8.3 @@ -358,10 +401,13 @@ importers: '@upyo/core': specifier: workspace:* version: link:../core + '@upyo/mime': + specifier: workspace:* + version: link:../mime devDependencies: tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(supports-color@10.2.2)(typescript@5.8.3) typescript: specifier: 'catalog:' version: 5.8.3 @@ -409,6 +455,40 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@cloudflare/workerd-darwin-64@1.20260730.1': + resolution: {integrity: sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260730.1': + resolution: {integrity: sha512-SBHKntPkKvNPgaCrTe99xC1CAl8ygJDzlYfK0LbuJ1muKadIw35WnhO0wu894fKBtllsVQdNzDLee+cm0ppLSQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260730.1': + resolution: {integrity: sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260730.1': + resolution: {integrity: sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260730.1': + resolution: {integrity: sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@deno/shim-deno-test@0.5.0': resolution: {integrity: sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==} @@ -427,164 +507,167 @@ packages: '@emnapi/core@1.4.4': resolution: {integrity: sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/runtime@1.4.4': resolution: {integrity: sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==} '@emnapi/wasi-threads@1.0.3': resolution: {integrity: sha512-8K5IFFsQqF9wQNJptGbS6FNKgUTsSRYnTqNCG1vPP8jFdjSv18n2mQfJpkt2Oibo9iBEzcDnDxNwKTzC7svlJw==} - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -613,6 +696,168 @@ packages: '@iconify/utils@3.1.3': resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@jridgewell/gen-mapping@0.3.12': resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} @@ -626,6 +871,9 @@ packages: '@jridgewell/trace-mapping@0.3.29': resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@logtape/logtape@2.2.4': resolution: {integrity: sha512-2rALzv9m4ibE5FyB8/FMm5MPMMlK7ujgy3ufricVKIxj6e7SZbw4w6J16/fRAsXgdDlOXPUA0aa6XoEQvdlKxw==} @@ -637,6 +885,10 @@ packages: '@napi-rs/wasm-runtime@0.2.11': resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -686,6 +938,15 @@ packages: '@oxc-project/types@0.75.1': resolution: {integrity: sha512-7ZJy+51qWpZRvynaQUezeYfjCtaSdiXIWFUZIlOuTSfDXpXqnSl/m1IUPLx6XrOy6s0SFv3CLE14vcZy63bz7g==} + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@quansync/fs@0.1.3': resolution: {integrity: sha512-G0OnZbMWEs5LhDyqy2UL17vGhSVHkQIfVojMtEWVenvj0V5S84VBgy86kJIuNsGDp2p7sTKlpSIpBUWdC35OKg==} engines: {node: '>=20.0.0'} @@ -903,6 +1164,13 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} @@ -1139,6 +1407,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + copy-anything@3.0.5: resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} engines: {node: '>=12.13'} @@ -1174,6 +1446,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -1205,8 +1481,11 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -1332,6 +1611,10 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} @@ -1490,6 +1773,11 @@ packages: resolution: {integrity: sha512-H/E3J6t+DQs/F2YgfDhxUVZz/dF8JXPPKTLHL/yHCcLZLtCXJDUaqvhJXQwqOVBvbyNn4T0WjLpIHd7PAw7fBA==} hasBin: true + miniflare@4.20260730.0: + resolution: {integrity: sha512-1Z9SB9r/o//80UA02Re3QhtcecSHAyAjf5EcKBfQVlQrCg7Miy79hl2PvtkwFLIaJ5rcrOPdDcRr577okwZPsg==} + engines: {node: '>=22.0.0'} + hasBin: true + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1542,6 +1830,9 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + postal-mime@3.0.0: + resolution: {integrity: sha512-Z4a9ar2Bv3YpK3IXag+Yda30k7bMZfpRuUGyqtHnZ2pjHG8Bl62EhZIk4n1dzv00gfzP9g+94e9kd8+XmjVWLA==} + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -1629,6 +1920,15 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} + shiki@3.23.0: resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} @@ -1665,6 +1965,10 @@ packages: resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==} engines: {node: '>=16'} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + tabbable@6.5.0: resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} @@ -1744,6 +2048,10 @@ packages: undici-types@7.8.0: resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -1859,10 +2167,27 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + workerd@1.20260730.1: + resolution: {integrity: sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA==} + engines: {node: '>=16'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -1875,6 +2200,12 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -1919,6 +2250,25 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@cloudflare/workerd-darwin-64@1.20260730.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260730.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260730.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260730.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260730.1': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@deno/shim-deno-test@0.5.0': {} '@deno/shim-deno@0.18.2': @@ -1938,6 +2288,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.4.4': dependencies: tslib: 2.8.1 @@ -1948,82 +2303,82 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.1': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.1': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.1': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.1': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.1': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.1': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.1': + '@esbuild/win32-x64@0.28.2': optional: true '@floating-ui/core@1.7.2': @@ -2056,6 +2411,112 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 + optional: true + + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.1': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.1': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + optional: true + + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 + optional: true + + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 + optional: true + + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 + optional: true + + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 + optional: true + + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 + optional: true + + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true + + '@img/sharp-wasm32@0.35.2': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-win32-arm64@0.35.2': + optional: true + + '@img/sharp-win32-ia32@0.35.2': + optional: true + + '@img/sharp-win32-x64@0.35.2': + optional: true + '@jridgewell/gen-mapping@0.3.12': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2070,6 +2531,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@logtape/logtape@2.2.4': {} '@logtape/testing@2.2.4(@logtape/logtape@2.2.4)': @@ -2083,6 +2549,8 @@ snapshots: '@tybys/wasm-util': 0.9.0 optional: true + '@noble/hashes@1.8.0': {} + '@opentelemetry/api@1.9.0': {} '@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0)': @@ -2121,6 +2589,18 @@ snapshots: '@oxc-project/types@0.75.1': {} + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@quansync/fs@0.1.3': dependencies: quansync: 0.2.10 @@ -2258,11 +2738,11 @@ snapshots: '@shikijs/core': 3.23.0 '@shikijs/types': 3.23.0 - '@shikijs/twoslash@3.23.0(typescript@5.8.3)': + '@shikijs/twoslash@3.23.0(supports-color@10.2.2)(typescript@5.8.3)': dependencies: '@shikijs/core': 3.23.0 '@shikijs/types': 3.23.0 - twoslash: 0.3.9(typescript@5.8.3) + twoslash: 0.3.9(supports-color@10.2.2)(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -2272,20 +2752,20 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 - '@shikijs/vitepress-twoslash@3.23.0(typescript@5.8.3)': + '@shikijs/vitepress-twoslash@3.23.0(supports-color@10.2.2)(typescript@5.8.3)': dependencies: - '@shikijs/twoslash': 3.23.0(typescript@5.8.3) + '@shikijs/twoslash': 3.23.0(supports-color@10.2.2)(typescript@5.8.3) floating-vue: 5.2.2(vue@3.5.39(typescript@5.8.3)) lz-string: 1.5.0 magic-string: 0.30.21 markdown-it: 14.2.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-gfm: 3.1.0 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-gfm: 3.1.0(supports-color@10.2.2) mdast-util-to-hast: 13.2.1 ohash: 2.0.11 shiki: 3.23.0 - twoslash: 0.3.9(typescript@5.8.3) - twoslash-vue: 0.3.9(typescript@5.8.3) + twoslash: 0.3.9(supports-color@10.2.2)(typescript@5.8.3) + twoslash-vue: 0.3.9(supports-color@10.2.2)(typescript@5.8.3) vue: 3.5.39(typescript@5.8.3) transitivePeerDependencies: - '@nuxt/kit' @@ -2294,6 +2774,10 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} + '@tybys/wasm-util@0.9.0': dependencies: tslib: 2.8.1 @@ -2332,9 +2816,9 @@ snapshots: '@types/web-bluetooth@0.0.21': {} - '@typescript/vfs@1.6.4(typescript@5.8.3)': + '@typescript/vfs@1.6.4(supports-color@10.2.2)(typescript@5.8.3)': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -2530,19 +3014,25 @@ snapshots: comma-separated-tokens@2.0.3: {} + cookie@1.1.1: {} + copy-anything@3.0.5: dependencies: is-what: 4.1.16 csstype@3.2.3: {} - debug@4.4.1: + debug@4.4.1(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decode-named-character-reference@1.2.0: dependencies: @@ -2552,6 +3042,8 @@ snapshots: dequal@2.0.3: {} + detect-libc@2.1.2: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -2568,34 +3060,36 @@ snapshots: entities@7.0.1: {} - esbuild@0.28.1: + error-stack-parser-es@1.0.5: {} + + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 escalade@3.2.0: {} @@ -2696,6 +3190,8 @@ snapshots: kind-of@6.0.3: {} + kleur@4.1.5: {} + linkify-it@5.0.0: dependencies: uc.micro: 2.1.0 @@ -2752,14 +3248,14 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 - mdast-util-from-markdown@2.0.2: + mdast-util-from-markdown@2.0.2(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.2.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2 + micromark: 4.0.2(supports-color@10.2.2) micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -2769,14 +3265,14 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-from-markdown@2.0.3: + mdast-util-from-markdown@2.0.3(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.2.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2 + micromark: 4.0.2(supports-color@10.2.2) micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -2786,12 +3282,12 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-frontmatter@2.0.1: + mdast-util-frontmatter@2.0.1(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 escape-string-regexp: 5.0.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.2(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 micromark-extension-frontmatter: 2.0.0 transitivePeerDependencies: @@ -2805,51 +3301,51 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0: + mdast-util-gfm-footnote@2.1.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.2(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0: + mdast-util-gfm-strikethrough@2.0.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.2(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0: + mdast-util-gfm-table@2.0.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.2(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0: + mdast-util-gfm-task-list-item@2.0.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.2(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0: + mdast-util-gfm@3.1.0(supports-color@10.2.2): dependencies: - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.2(supports-color@10.2.2) mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0 - mdast-util-gfm-strikethrough: 2.0.0 - mdast-util-gfm-table: 2.0.0 - mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-gfm-footnote: 2.1.0(supports-color@10.2.2) + mdast-util-gfm-strikethrough: 2.0.0(supports-color@10.2.2) + mdast-util-gfm-table: 2.0.0(supports-color@10.2.2) + mdast-util-gfm-task-list-item: 2.0.0(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -3007,10 +3503,10 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2: + micromark@4.0.2(supports-color@10.2.2): dependencies: '@types/debug': 4.1.12 - debug: 4.4.1 + debug: 4.4.1(supports-color@10.2.2) decode-named-character-reference: 1.2.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -3033,6 +3529,18 @@ snapshots: dependencies: yargs: 17.7.2 + miniflare@4.20260730.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.2 + undici: 7.28.0 + workerd: 1.20260730.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -3071,6 +3579,8 @@ snapshots: picomatch@4.0.4: {} + postal-mime@3.0.0: {} + postcss@8.5.15: dependencies: nanoid: 3.3.15 @@ -3097,19 +3607,19 @@ snapshots: dependencies: regex-utilities: 2.3.0 - remark-frontmatter@5.0.0: + remark-frontmatter@5.0.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 - mdast-util-frontmatter: 2.0.1 + mdast-util-frontmatter: 2.0.1(supports-color@10.2.2) micromark-extension-frontmatter: 2.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-parse@11.0.0: + remark-parse@11.0.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.2(supports-color@10.2.2) micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -3121,10 +3631,10 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 - remark@15.0.1: + remark@15.0.1(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 - remark-parse: 11.0.0 + remark-parse: 11.0.0(supports-color@10.2.2) remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: @@ -3136,14 +3646,14 @@ snapshots: rfdc@1.4.1: {} - rolldown-plugin-dts@0.13.13(rolldown@1.0.0-beta.24)(typescript@5.8.3): + rolldown-plugin-dts@0.13.13(rolldown@1.0.0-beta.24)(supports-color@10.2.2)(typescript@5.8.3): dependencies: '@babel/generator': 7.28.0 '@babel/parser': 7.28.5 '@babel/types': 7.28.5 ast-kit: 2.1.1 birpc: 2.9.0 - debug: 4.4.1 + debug: 4.4.1(supports-color@10.2.2) dts-resolver: 2.1.1 get-tsconfig: 4.10.1 rolldown: 1.0.0-beta.24 @@ -3206,6 +3716,40 @@ snapshots: semver@7.7.2: {} + semver@7.8.5: {} + + sharp@0.35.2: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 + shiki@3.23.0: dependencies: '@shikijs/core': 3.23.0 @@ -3246,6 +3790,8 @@ snapshots: dependencies: copy-anything: 3.0.5 + supports-color@10.2.2: {} + tabbable@6.5.0: {} tagged-tag@1.0.0: {} @@ -3263,17 +3809,17 @@ snapshots: trough@2.2.0: {} - tsdown@0.12.9(typescript@5.8.3): + tsdown@0.12.9(supports-color@10.2.2)(typescript@5.8.3): dependencies: ansis: 4.1.0 cac: 6.7.14 chokidar: 4.0.3 - debug: 4.4.1 + debug: 4.4.1(supports-color@10.2.2) diff: 8.0.2 empathic: 2.0.0 hookable: 5.5.3 rolldown: 1.0.0-beta.24 - rolldown-plugin-dts: 0.13.13(rolldown@1.0.0-beta.24)(typescript@5.8.3) + rolldown-plugin-dts: 0.13.13(rolldown@1.0.0-beta.24)(supports-color@10.2.2)(typescript@5.8.3) semver: 7.7.2 tinyexec: 1.0.1 tinyglobby: 0.2.15 @@ -3291,18 +3837,18 @@ snapshots: twoslash-protocol@0.3.9: {} - twoslash-vue@0.3.9(typescript@5.8.3): + twoslash-vue@0.3.9(supports-color@10.2.2)(typescript@5.8.3): dependencies: '@vue/language-core': 3.3.5 - twoslash: 0.3.9(typescript@5.8.3) + twoslash: 0.3.9(supports-color@10.2.2)(typescript@5.8.3) twoslash-protocol: 0.3.9 typescript: 5.8.3 transitivePeerDependencies: - supports-color - twoslash@0.3.9(typescript@5.8.3): + twoslash@0.3.9(supports-color@10.2.2)(typescript@5.8.3): dependencies: - '@typescript/vfs': 1.6.4(typescript@5.8.3) + '@typescript/vfs': 1.6.4(supports-color@10.2.2)(typescript@5.8.3) twoslash-protocol: 0.3.9 typescript: 5.8.3 transitivePeerDependencies: @@ -3325,6 +3871,8 @@ snapshots: undici-types@7.8.0: {} + undici@7.28.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -3382,7 +3930,7 @@ snapshots: vite@7.3.6(@types/node@24.0.13)(jiti@2.4.2): dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.15 @@ -3401,19 +3949,19 @@ snapshots: optionalDependencies: vite: 7.3.6(@types/node@24.0.13)(jiti@2.4.2) - vitepress-plugin-llms@1.13.2: + vitepress-plugin-llms@1.13.2(supports-color@10.2.2): dependencies: gray-matter: 4.0.3 markdown-it: 14.1.0 markdown-title: 1.0.2 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) millify: 6.1.0 minimatch: 10.2.5 path-to-regexp: 6.3.0 picocolors: 1.1.1 pretty-bytes: 7.1.0 - remark: 15.0.1 - remark-frontmatter: 5.0.0 + remark: 15.0.1(supports-color@10.2.2) + remark-frontmatter: 5.0.0(supports-color@10.2.2) tokenx: 1.3.0 unist-util-remove: 4.0.0 unist-util-visit: 5.1.0 @@ -3486,12 +4034,22 @@ snapshots: dependencies: isexe: 3.1.1 + workerd@1.20260730.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260730.1 + '@cloudflare/workerd-darwin-arm64': 1.20260730.1 + '@cloudflare/workerd-linux-64': 1.20260730.1 + '@cloudflare/workerd-linux-arm64': 1.20260730.1 + '@cloudflare/workerd-windows-64': 1.20260730.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + ws@8.21.0: {} + y18n@5.0.8: {} yargs-parser@21.1.1: {} @@ -3506,4 +4064,17 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 78d1368..14e4986 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,10 @@ allowBuilds: workerd: true catalog: + '@noble/hashes': ^1.8.0 + postal-mime: ^3.0.0 + esbuild: ^0.28.2 + miniflare: 4.20260730.0 '@logtape/logtape': ^2.2.4 '@logtape/testing': ^2.2.4 '@opentelemetry/api': ^1.9.0 From c0c35c35b90aeefb5cc0611724a5dc9274fdb727 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 9 Sep 2026 19:44:05 +0900 Subject: [PATCH 2/4] Avoid persisting edge-test Git credentials The edge test job only needs a checkout to build and run its tests. Keep the checkout token out of Git configuration during those steps. https://github.com/dahlia/upyo/pull/74#discussion_r3967331139 Assisted-by: Codex:gpt-6-astra --- .github/workflows/main.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 3dcdb83..638f577 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -10,6 +10,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: jdx/mise-action@v4 with: experimental: true From d4888dc07afa8bf4c522d1254c4ec8c004f6a60b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 9 Sep 2026 19:44:20 +0900 Subject: [PATCH 3/4] Resolve edge entry points as file paths Decode the worker file URL before passing it to esbuild so checkout paths containing spaces or Unicode characters remain usable. Verify the runner in a path containing both spaces and Korean text. https://github.com/dahlia/upyo/pull/74#discussion_r3967331192 Assisted-by: Codex:gpt-6-astra --- packages/mime/edge/runner.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/mime/edge/runner.mjs b/packages/mime/edge/runner.mjs index 98edc7a..9a4fce6 100644 --- a/packages/mime/edge/runner.mjs +++ b/packages/mime/edge/runner.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; import { build } from "esbuild"; import { Miniflare } from "miniflare"; import { composeMessage, MimeAttachmentReplayError } from "../dist/index.js"; @@ -19,7 +20,7 @@ assert.equal( ); assert.equal(typeof require("@upyo/mime").composeMessage, "function"); const bundle = await build({ - entryPoints: [new URL("worker.ts", import.meta.url).pathname], + entryPoints: [fileURLToPath(new URL("worker.ts", import.meta.url))], bundle: true, write: false, format: "esm", From 103e409c8d53bc1c83d11e464812ee3786cc485e Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 9 Sep 2026 19:44:35 +0900 Subject: [PATCH 4/4] Validate MIME headers from direct messages Callers can construct Message values without createMessage(), so the shared serializer must reject address line breaks and invalid custom field names before emitting headers. Keep the full RFC 5322 field-name range rather than restricting it to HTTP header tokens. Add regression tests for the shared SMTP preparation path and public MIME composition API, including CRLF injection and valid punctuation. https://github.com/dahlia/upyo/pull/74#discussion_r3967331224 Assisted-by: Codex:gpt-6-astra --- CHANGES.md | 7 ++- changes.d/mime/mime-composition.md | 4 +- changes.d/smtp/mime-line-encoding.md | 3 +- packages/mime/src/header-validation.test.ts | 63 +++++++++++++++++++++ packages/mime/src/message.ts | 14 ++++- 5 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 packages/mime/src/header-validation.test.ts diff --git a/CHANGES.md b/CHANGES.md index 987473b..2b84986 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -235,7 +235,9 @@ To be released. bytes without a transport connection, with optional DKIM signing. Save the bytes as an *.eml* file or pass the result directly to SMTP or JMAP `sendRaw()`. The package supports Node.js, Deno, Bun, and edge runtimes - without Node.js compatibility. [[#68], [#74]] + without Node.js compatibility. Address line breaks and invalid custom + header names are rejected even for directly constructed messages. + [[#68], [#74]] [#68]: https://github.com/dahlia/upyo/issues/68 [#74]: https://github.com/dahlia/upyo/pull/74 @@ -417,7 +419,8 @@ To be released. - Fixed quoted-printable encoding of CRLF, isolated line endings, trailing spaces, and long lines so messages preserve their text and respect MIME line limits. Inline Content-ID headers now reject values that cannot fit - the RFC 5322 line limit. [[#68], [#74]] + the RFC 5322 line limit. Directly constructed messages now reject address + line breaks and invalid custom header names. [[#68], [#74]] [#42]: https://github.com/dahlia/upyo/issues/42 [#43]: https://github.com/dahlia/upyo/issues/43 diff --git a/changes.d/mime/mime-composition.md b/changes.d/mime/mime-composition.md index 8f5e1a6..e594dcf 100644 --- a/changes.d/mime/mime-composition.md +++ b/changes.d/mime/mime-composition.md @@ -7,4 +7,6 @@ links: bytes without a transport connection, with optional DKIM signing. Save the bytes as an *.eml* file or pass the result directly to SMTP or JMAP `sendRaw()`. The package supports Node.js, Deno, Bun, and edge runtimes - without Node.js compatibility. [[#68], [#74]] + without Node.js compatibility. Address line breaks and invalid custom + header names are rejected even for directly constructed messages. + [[#68], [#74]] diff --git a/changes.d/smtp/mime-line-encoding.md b/changes.d/smtp/mime-line-encoding.md index d9197be..9888def 100644 --- a/changes.d/smtp/mime-line-encoding.md +++ b/changes.d/smtp/mime-line-encoding.md @@ -6,4 +6,5 @@ links: - Fixed quoted-printable encoding of CRLF, isolated line endings, trailing spaces, and long lines so messages preserve their text and respect MIME line limits. Inline Content-ID headers now reject values that cannot fit - the RFC 5322 line limit. [[#68], [#74]] + the RFC 5322 line limit. Directly constructed messages now reject address + line breaks and invalid custom header names. [[#68], [#74]] diff --git a/packages/mime/src/header-validation.test.ts b/packages/mime/src/header-validation.test.ts new file mode 100644 index 0000000..c162840 --- /dev/null +++ b/packages/mime/src/header-validation.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createMessage } from "@upyo/core"; +import { composeMessage } from "./index.ts"; +import { prepareMimeMessage } from "./internal.ts"; + +const message = createMessage({ + from: "from@example.com", + to: "to@example.com", + subject: "Test", + content: { text: "Hello" }, +}); + +for ( + const address of [ + "reply@example.com\r\nBcc: injected@example.com", + "reply@example.com\r", + "reply@example.com\n", + ] +) { + test(`reject address line endings: ${JSON.stringify(address)}`, async () => { + const direct = { + ...message, + replyRecipients: [{ address: address as `${string}@${string}` }], + }; + assert.throws(() => prepareMimeMessage(direct), TypeError); + await assert.rejects(composeMessage(direct), TypeError); + }); +} + +class DirectHeaders extends Headers { + constructor(private readonly field: string) { + super(); + } + override *[Symbol.iterator](): Generator<[string, string], void, unknown> { + yield [this.field, "value"]; + } +} + +for ( + const field of [ + "X-Test\r\nBcc", + "X-Test: Bcc", + "X Test", + "", + "X-한글", + "X-Test\n", + ] +) { + test(`reject invalid custom field name: ${JSON.stringify(field)}`, async () => { + const direct = { ...message, headers: new DirectHeaders(field) }; + assert.throws(() => prepareMimeMessage(direct), TypeError); + await assert.rejects(composeMessage(direct), TypeError); + }); +} + +test("accept the full printable ASCII field-name range except colon", async () => { + const field = Array.from( + { length: 94 }, + (_, index) => String.fromCharCode(index + 33), + ).join("").replace(":", ""); + await composeMessage({ ...message, headers: new DirectHeaders(field) }); +}); diff --git a/packages/mime/src/message.ts b/packages/mime/src/message.ts index 0698524..38d2fc5 100644 --- a/packages/mime/src/message.ts +++ b/packages/mime/src/message.ts @@ -34,8 +34,8 @@ export interface PreparedMimeMessage { * @returns A deterministic MIME plan for one send attempt. * @throws {RangeError} If a header cannot fit the RFC 5322 line limit. * @throws {TypeError} If the message carries an invalid message identifier or - * date, or a `Date` or `Message-ID` header containing a carriage return or line - * feed. + * date, an address or identity header containing a carriage return or line + * feed, or an invalid custom header field name. */ export function prepareMimeMessage( message: Message, @@ -560,6 +560,11 @@ function formatDate(date: Date): string { } function encodeAddress(address: Address): string { + if (/[\r\n]/.test(address.address)) { + throw new TypeError( + "Address must not contain a carriage return or line feed.", + ); + } if (address.name == null) { // No display name, just return the email address return address.address; @@ -650,6 +655,11 @@ function encodeMimeParameter(name: string, value: string): string { } function foldHeader(name: string, value: string): string { + if (!/^[\x21-\x39\x3b-\x7e]+$/.test(name)) { + throw new TypeError( + "Header field name must contain printable ASCII characters other than colon.", + ); + } const recommendedLineLength = 78; const lines: string[] = []; let prefix = `${name}: `;