From 1a4d05d8c175e40daac86dd48b8c80214fc48d0e Mon Sep 17 00:00:00 2001 From: mskatoni Date: Sat, 25 Apr 2026 16:14:32 +0800 Subject: [PATCH] Add files via upload --- .dev.vars.example | 3 + .gitignore | 11 +- .npmrc | 7 + CLOUDFLARE_BUILDS.md | 70 ++ THIRD_PARTY_NOTICES.md | 13 + package-lock.json | 1517 ++++++++++++++++++++++++++++++++++++++++ package.json | 31 +- scripts/examples.mjs | 58 ++ scripts/examples.sh | 40 ++ src/global.d.ts | 27 + src/index.ts | 230 ++++++ src/mailbox-do.ts | 1001 ++++++++++++++++++++++++++ src/types.ts | 155 ++++ src/utils.ts | 207 ++++++ tsconfig.json | 15 + worker.js | 116 +-- wrangler.jsonc | 39 ++ 17 files changed, 3414 insertions(+), 126 deletions(-) create mode 100644 .dev.vars.example create mode 100644 .npmrc create mode 100644 CLOUDFLARE_BUILDS.md create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 package-lock.json create mode 100644 scripts/examples.mjs create mode 100644 scripts/examples.sh create mode 100644 src/global.d.ts create mode 100644 src/index.ts create mode 100644 src/mailbox-do.ts create mode 100644 src/types.ts create mode 100644 src/utils.ts create mode 100644 tsconfig.json create mode 100644 wrangler.jsonc diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..58f0157 --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,3 @@ +AUTH_KEY=replace-with-a-long-random-string +DOMAINS=example.com,example.net +DEFAULT_MAILBOX= diff --git a/.gitignore b/.gitignore index ef5b808..46a1e70 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ -node_modules/ -.wrangler/ -dist/ +node_modules +.wrangler +.dev.vars +.env +.env.* +dist +coverage +.DS_Store diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..580f828 --- /dev/null +++ b/.npmrc @@ -0,0 +1,7 @@ +registry=https://registry.npmjs.org/ +audit=false +fund=false +progress=false +fetch-retries=5 +fetch-retry-mintimeout=20000 +fetch-retry-maxtimeout=120000 diff --git a/CLOUDFLARE_BUILDS.md b/CLOUDFLARE_BUILDS.md new file mode 100644 index 0000000..c5ccc76 --- /dev/null +++ b/CLOUDFLARE_BUILDS.md @@ -0,0 +1,70 @@ +# Cloudflare Builds Checklist + +This project deploys in source-first mode. + +- Worker name: `ni-mail` +- Wrangler entrypoint: `src/index.ts` +- Compatibility passthrough: `worker.js` +- Deploy command: `npm run deploy` +- Build command: leave empty +- Root directory: repository root (`/` or blank) + +If you upload files manually to GitHub, include the entire `src/` folder. The deployment will fail if `wrangler.jsonc` is present but `src/index.ts` is missing from the repository snapshot. + +## Required dashboard settings + +In Cloudflare Dashboard for Worker `ni-mail`: + +1. Open `Settings -> Builds`. +2. Confirm the connected GitHub repository is this project. +3. Confirm the production branch is the branch that already contains `src/index.ts` and the current `wrangler.jsonc`. +4. Set `Root directory` to `/` or leave it blank. +5. Leave `Build command` empty. +6. Set `Deploy command` to `npm run deploy`. +7. Save the settings. +8. Retry the failed build. + +## Local verification + +Run these commands from the repository root: + +```bash +npm clean-install --progress=false +npm run check:deploy +``` + +Expected result: + +- Wrangler resolves `src/index.ts` +- Wrangler detects the `MAILBOX` Durable Object binding +- Wrangler detects the `BUCKET` R2 binding + +After a local `npm run dev` or a production deploy, run: + +```bash +BASE_URL=https:// AUTH_KEY= MAILBOX=hello@example.com npm run smoke +``` + +Mailbox reads may return `404` until mail arrives. + +## If Git Builds still says `src/index.ts` is missing + +Treat that as a stale or misbound Cloudflare Builds source configuration. + +1. Disconnect the GitHub repository from Worker `ni-mail`. +2. Reconnect the same repository. +3. Re-select the correct production branch. +4. Re-apply the settings above. +5. Trigger a fresh build with a new commit or a manual retry. + +## Smoke test after deploy + +```bash +curl https:///health +``` + +Expected response: + +```json +{ "ok": true, "service": "ni-mail" } +``` diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..d12fcac --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,13 @@ +# Third-party notices + +This repository is an original rewrite prepared for integrating ideas from lightweight mail-reader Workers and Cloudflare inbox examples. + +The implementation in this package was written as a clean-room backend-oriented adaptation focused on: + +- mailbox isolation +- threaded inbox storage +- Cloudflare Durable Objects + SQLite +- R2 attachment storage +- Cloudflare `send_email` integration + +Please review the upstream repositories for their original licenses and notices before mixing code verbatim from them into another distribution. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..dbb582a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1517 @@ +{ + "name": "ni-mail", + "version": "2.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ni-mail", + "version": "2.0.1", + "license": "Apache-2.0", + "dependencies": { + "postal-mime": "^2.6.1" + }, + "devDependencies": { + "wrangler": "4.83.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", + "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.0.tgz", + "integrity": "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260415.1.tgz", + "integrity": "sha512-dsxaKsQm3LnPGNPEdsRv09QN3Y4DqCw7kX5j6noKqbAtro2jTr95sVlYM1jUxZ5FkOl1f7SXgaKKB9t5H5Nkbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260415.1.tgz", + "integrity": "sha512-+JgSgVA49KyKteHRA1SnonE4Zn5Ei5zdAp5FQMxFmXI8qulZw4Hl7safXxRyK4i9sTO8gl7TFOKO5Q64VPvSDQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260415.1.tgz", + "integrity": "sha512-tU+9pwsqCy8afOVlGtiWrWQc/fedQK4SRm4KPIAt+zOiQWDxWASm6YGBUJis5c648WN80yz47qnmdDi8DQNOcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260415.1.tgz", + "integrity": "sha512-bR9uITnV19r5NQ14xnypi2xHXu2iQvfYV8cVgx0JouFUmWwTEEAwFVojDdssGq93VHX9hr/pi2IRUZeegbYBog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260415.1.tgz", + "integrity": "sha512-4NuMLlerI0Ijua3Ir8HXQ+qyNvCUDEG5gDco5Om+sAiK6rnWiz+aGoSlbB8W16yW9QAgzCstbmXLiVknUBflfQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260415.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260415.0.tgz", + "integrity": "sha512-JoExRWN4YBI2luA5BoSMFEgi8rQWXUGzo3mtE+58VXCLV3jj/Xnk5Yeqs/IXWz8Es5GJIaq6BtsixDvAxXSIng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.24.8", + "workerd": "1.20260415.1", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260415.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260415.1.tgz", + "integrity": "sha512-phyPjRnx+mQDfkhN9ENPioL1L0SdhYs4S0YmJK/xF9Oga+ykNfdSy1MHnsOj8yqnOV96zcVQMx32dJ0r3pq0jQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260415.1", + "@cloudflare/workerd-darwin-arm64": "1.20260415.1", + "@cloudflare/workerd-linux-64": "1.20260415.1", + "@cloudflare/workerd-linux-arm64": "1.20260415.1", + "@cloudflare/workerd-windows-64": "1.20260415.1" + } + }, + "node_modules/wrangler": { + "version": "4.83.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.83.0.tgz", + "integrity": "sha512-gw5g3LCiuAqVWxaoKY6+quE0HzAUEFb/FV3oAlNkE1ttd4XP3FiV91XDkkzUCcdqxS4WjhQvPhIDBNdhEi8P0A==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.4.2", + "@cloudflare/unenv-preset": "2.16.0", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260415.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260415.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=20.3.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260415.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "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 + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/package.json b/package.json index f7fa58a..9a41402 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,31 @@ { - "name": "mail-worker", - "version": "1.0.0", - "description": "Minimal Cloudflare Worker for receiving and reading emails via API", - "main": "src/worker.js", + "name": "ni-mail", + "version": "2.0.1", + "private": false, + "description": "Minimal Cloudflare Worker for receiving private-domain email over HTTP API; upgraded to Durable Objects + SQLite + R2 with optional send/reply/forward support.", + "type": "module", "scripts": { + "dev": "wrangler dev", "deploy": "wrangler deploy", - "dev": "wrangler dev" + "check:deploy": "wrangler deploy --dry-run", + "smoke": "node scripts/examples.mjs" }, "dependencies": { - "postal-mime": "^2.2.9" + "postal-mime": "^2.6.1" }, "devDependencies": { - "wrangler": "^3.0.0" - } + "wrangler": "4.83.0" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "cloudflare", + "workers", + "email", + "durable-objects", + "sqlite", + "r2" + ], + "license": "Apache-2.0" } diff --git a/scripts/examples.mjs b/scripts/examples.mjs new file mode 100644 index 0000000..84bacb7 --- /dev/null +++ b/scripts/examples.mjs @@ -0,0 +1,58 @@ +const baseUrl = process.env.BASE_URL || "http://localhost:8787"; +const authKey = process.env.AUTH_KEY || "replace-me"; +const mailbox = process.env.MAILBOX || "hello@example.com"; +const runSend = process.env.RUN_SEND === "1"; +const encodedMailbox = encodeURIComponent(mailbox); + +async function printResponse(title, response) { + const body = await response.text(); + console.log(`== ${title} ==`); + console.log(body); + console.log(); +} + +async function request(path, init = {}) { + try { + return await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + "X-Auth-Key": authKey, + ...(init.headers || {}), + }, + }); + } catch (error) { + throw new Error(`Could not connect to ${baseUrl}. Start Wrangler dev or set BASE_URL to a deployed Worker.`, { + cause: error, + }); + } +} + +try { + await printResponse("health", await request("/health", { headers: {} })); + await printResponse("list latest", await request(`/latest?mailbox=${encodeURIComponent(mailbox)}`)); + await printResponse( + "threaded inbox", + await request(`/api/mailboxes/${encodedMailbox}/emails?folder=inbox&threaded=true&limit=10`), + ); + + if (runSend) { + await printResponse( + "send", + await request(`/api/mailboxes/${encodedMailbox}/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + to: ["user@example.net"], + subject: "hello from ni-mail", + text: "test body", + }), + }), + ); + } else { + console.log("== send =="); + console.log("skipped; set RUN_SEND=1 to exercise the optional EMAIL binding"); + } +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +} diff --git a/scripts/examples.sh b/scripts/examples.sh new file mode 100644 index 0000000..fa5e789 --- /dev/null +++ b/scripts/examples.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost:8787}" +AUTH_KEY="${AUTH_KEY:-replace-me}" +MAILBOX="${MAILBOX:-hello@example.com}" +RUN_SEND="${RUN_SEND:-0}" +ENCODED_MAILBOX="$(node -e "console.log(encodeURIComponent(process.env.MAILBOX || 'hello@example.com'))")" + +curl_json() { + curl -sS \ + -H "X-Auth-Key: ${AUTH_KEY}" \ + -H "Content-Type: application/json" \ + "$@" +} + +echo "== health ==" +curl -sS "${BASE_URL}/health" +echo + +echo "== list latest ==" +curl -sS -H "X-Auth-Key: ${AUTH_KEY}" "${BASE_URL}/latest?mailbox=${MAILBOX}" +echo + +echo "== threaded inbox ==" +curl -sS -H "X-Auth-Key: ${AUTH_KEY}" "${BASE_URL}/api/mailboxes/${ENCODED_MAILBOX}/emails?folder=inbox&threaded=true&limit=10" +echo + +echo "== send ==" +if [ "${RUN_SEND}" = "1" ]; then + curl_json -X POST "${BASE_URL}/api/mailboxes/${ENCODED_MAILBOX}/send" \ + --data '{ + "to": ["user@example.net"], + "subject": "hello from ni-mail", + "text": "test body" + }' + echo +else + echo "skipped; set RUN_SEND=1 to exercise the optional EMAIL binding" +fi diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..a197f3d --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,27 @@ +declare module "postal-mime" { + export interface PostalMimeAttachment { + filename?: string; + mimeType?: string; + content?: ArrayBuffer | Uint8Array | string | null; + contentId?: string | null; + disposition?: string | null; + } + + export interface PostalMimeResult { + subject?: string | null; + text?: string | null; + html?: string | null; + attachments?: PostalMimeAttachment[] | null; + } + + export default class PostalMime { + constructor(options?: Record); + parse(input: ArrayBuffer | Uint8Array | string): Promise; + } +} + +declare module "cloudflare:workers" { + export class DurableObject { + constructor(state: any, env: any); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..575614b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,230 @@ +import PostalMime from "postal-mime"; +import type { Env, IncomingEmailPayload, StoredAttachmentInput } from "./types"; +import { MailboxDO } from "./mailbox-do"; +import { + assertAuthorized, + badRequest, + buildSnippet, + isAllowedMailbox, + json, + mailboxStub, + normalizeEmail, + normalizeMessageId, + notFound, + parseJson, + resolveMailbox, + serverError, + toBase64, + unauthorized, +} from "./utils"; + +export { MailboxDO }; + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === "/health") { + return json({ ok: true, service: "ni-mail" }); + } + + if (!assertAuthorized(request, env)) { + return unauthorized(); + } + + try { + if (request.method === "GET" && url.pathname === "/latest") { + const mailbox = resolveMailbox(request, env); + if (!mailbox) return badRequest("mailbox is required"); + return proxyToMailbox(env, mailbox, `/internal/emails/latest?folder=inbox`); + } + + if (request.method === "GET" && url.pathname === "/mails") { + const mailbox = resolveMailbox(request, env); + if (!mailbox) return badRequest("mailbox is required"); + const search = new URLSearchParams(url.search); + if (!search.has("folder")) search.set("folder", "inbox"); + return proxyToMailbox(env, mailbox, `/internal/emails?${search.toString()}`); + } + + const legacyMailMatch = url.pathname.match(/^\/mail\/([^/]+)$/); + if (request.method === "GET" && legacyMailMatch) { + const mailbox = resolveMailbox(request, env); + if (!mailbox) return badRequest("mailbox is required"); + return proxyToMailbox(env, mailbox, `/internal/emails/${encodeURIComponent(decodeURIComponent(legacyMailMatch[1]!))}`); + } + + if (request.method === "DELETE" && url.pathname === "/mails") { + const mailbox = resolveMailbox(request, env); + if (!mailbox) return badRequest("mailbox is required"); + const folder = url.searchParams.get("folder") ?? "inbox"; + return proxyToMailbox(env, mailbox, `/internal/inbox?folder=${encodeURIComponent(folder)}`, { method: "DELETE" }); + } + + const mailboxBase = url.pathname.match(/^\/api\/mailboxes\/([^/]+)(\/.*)?$/); + if (mailboxBase) { + const mailboxId = normalizeEmail(decodeURIComponent(mailboxBase[1]!)); + if (!isAllowedMailbox(mailboxId, env)) { + return badRequest(`mailbox domain is not allowed: ${mailboxId}`); + } + + const subPath = mailboxBase[2] ?? ""; + + if (request.method === "GET" && subPath === "/latest") { + const folder = url.searchParams.get("folder") ?? "inbox"; + return proxyToMailbox(env, mailboxId, `/internal/emails/latest?folder=${encodeURIComponent(folder)}`); + } + + if (request.method === "GET" && subPath === "/emails") { + const search = new URLSearchParams(url.search); + if (!search.has("folder")) search.set("folder", "inbox"); + return proxyToMailbox(env, mailboxId, `/internal/emails?${search.toString()}`); + } + + const emailMatch = subPath.match(/^\/emails\/([^/]+)$/); + if (request.method === "GET" && emailMatch) { + return proxyToMailbox(env, mailboxId, `/internal/emails/${encodeURIComponent(decodeURIComponent(emailMatch[1]!))}`); + } + + const readMatch = subPath.match(/^\/emails\/([^/]+)\/read$/); + if (request.method === "POST" && readMatch) { + return proxyToMailbox(env, mailboxId, `/internal/emails/${encodeURIComponent(decodeURIComponent(readMatch[1]!))}/read`, { method: "POST" }); + } + + const threadMatch = subPath.match(/^\/threads\/([^/]+)$/); + if (request.method === "GET" && threadMatch) { + return proxyToMailbox(env, mailboxId, `/internal/threads/${encodeURIComponent(decodeURIComponent(threadMatch[1]!))}`); + } + + if (request.method === "POST" && subPath === "/send") { + const body = await parseJson>(request); + return proxyToMailbox(env, mailboxId, `/internal/send`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...body, mailboxId }), + }); + } + + if (request.method === "POST" && subPath === "/reply") { + const body = await parseJson>(request); + return proxyToMailbox(env, mailboxId, `/internal/reply`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...body, mailboxId }), + }); + } + + if (request.method === "POST" && subPath === "/forward") { + const body = await parseJson>(request); + return proxyToMailbox(env, mailboxId, `/internal/forward`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...body, mailboxId }), + }); + } + + if (request.method === "DELETE" && subPath === "/emails") { + const folder = url.searchParams.get("folder") ?? "inbox"; + return proxyToMailbox(env, mailboxId, `/internal/inbox?folder=${encodeURIComponent(folder)}`, { method: "DELETE" }); + } + } + + const attachmentMatch = url.pathname.match(/^\/api\/attachments\/([^/]+)$/); + if (request.method === "GET" && attachmentMatch) { + const mailbox = resolveMailbox(request, env); + if (!mailbox) return badRequest("mailbox is required"); + return proxyToMailbox(env, mailbox, `/internal/attachments/${encodeURIComponent(decodeURIComponent(attachmentMatch[1]!))}`); + } + + return notFound("unknown route"); + } catch (error) { + return serverError("request failed", serializeError(error)); + } + }, + + async email(message: ForwardableEmailMessageLike, env: Env): Promise { + const mailbox = normalizeEmail(message.to); + if (!isAllowedMailbox(mailbox, env)) { + message.setReject(`mailbox domain not allowed: ${mailbox}`); + return; + } + + try { + const parser = new PostalMime(); + const raw = await new Response(message.raw).arrayBuffer(); + const parsed = await parser.parse(raw); + const headers = Object.fromEntries(Array.from(message.headers.entries()).map(([key, value]) => [key.toLowerCase(), value])); + const subject = parsed.subject ?? "(no subject)"; + const bodyText = parsed.text ?? ""; + const bodyHtml = parsed.html ?? ""; + const attachments: StoredAttachmentInput[] = (parsed.attachments ?? []).map((attachment) => ({ + filename: attachment.filename ?? "attachment.bin", + type: attachment.mimeType ?? "application/octet-stream", + disposition: attachment.disposition === "inline" ? "inline" : "attachment", + contentId: attachment.contentId ?? undefined, + contentBase64: toBase64(attachment.content ?? new Uint8Array()), + })); + + const payload: IncomingEmailPayload = { + id: crypto.randomUUID(), + folderId: "inbox", + from: normalizeEmail(message.from), + to: mailbox, + cc: headers["cc"] ?? null, + bcc: headers["bcc"] ?? null, + subject, + bodyText, + bodyHtml, + snippet: buildSnippet(bodyText, bodyHtml), + date: headers["date"] ? new Date(headers["date"]).toISOString() : new Date().toISOString(), + messageId: normalizeMessageId(headers["message-id"] ?? `${crypto.randomUUID()}@${mailbox.split("@")[1] ?? "localhost"}`), + inReplyTo: normalizeMessageId(headers["in-reply-to"] ?? ""), + references: headers["references"] ?? null, + rawHeaders: headers, + attachments, + }; + + const response = await proxyToMailbox(env, mailbox, `/internal/incoming`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + const textBody = await response.text(); + throw new Error(`mailbox storage failed: ${textBody}`); + } + } catch (error) { + message.setReject(`worker failed to process email: ${stringifyError(error)}`); + } + }, +}; + +type ForwardableEmailMessageLike = { + from: string; + to: string; + headers: Headers; + raw: ReadableStream; + setReject(reason: string): void; +}; + +async function proxyToMailbox(env: Env, mailboxId: string, path: string, init?: RequestInit): Promise { + const stub = mailboxStub(env, mailboxId); + const request = new Request(`https://mailbox.internal${path}`, init); + return await stub.fetch(request); +} + +function serializeError(error: unknown): Record { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + return { message: String(error) }; +} + +function stringifyError(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} diff --git a/src/mailbox-do.ts b/src/mailbox-do.ts new file mode 100644 index 0000000..faf5652 --- /dev/null +++ b/src/mailbox-do.ts @@ -0,0 +1,1001 @@ +import { DurableObject } from "cloudflare:workers"; +import type { + DurableObjectStateLike, + Env, + ForwardRequestBody, + IncomingEmailPayload, + ReplyRequestBody, + SendRequestBody, + StoredAttachmentInput, + StoredAttachmentRecord, +} from "./types"; +import { + badRequest, + buildParticipants, + buildSnippet, + ensureArray, + escapeHtml, + formatAddress, + fromBase64, + json, + hasOutboundEmailBinding, + makeMessageId, + normalizeEmail, + normalizeMessageId, + normalizeSubject, + notFound, + parseReferences, + participantsOverlap, + quoteHtml, + safeFilename, + sanitizeAttachmentInput, + serverError, + stripHtml, + text, + toBase64, +} from "./utils"; + +type EmailRow = { + id: string; + folder_id: string; + subject: string | null; + sender: string | null; + recipient: string | null; + cc: string | null; + bcc: string | null; + date: string; + read: number; + starred: number; + body_text: string | null; + body_html: string | null; + snippet: string | null; + normalized_subject: string | null; + participants: string | null; + in_reply_to: string | null; + email_references: string | null; + thread_id: string | null; + message_id: string | null; + raw_headers: string | null; +}; + +type ListSummary = { + id: string; + folderId: string; + subject: string; + sender: string; + recipient: string; + date: string; + read: boolean; + starred: boolean; + snippet: string; + threadId: string; + messageId: string; + attachmentCount: number; +}; + +type ThreadSummary = { + threadId: string; + subject: string; + lastDate: string; + lastSender: string; + lastRecipient: string; + snippet: string; + unreadCount: number; + messageCount: number; + latestEmailId: string; + attachmentCount: number; +}; + +const FOLDERS = [ + { id: "inbox", name: "Inbox", deletable: 0 }, + { id: "sent", name: "Sent", deletable: 0 }, + { id: "archive", name: "Archive", deletable: 0 }, + { id: "trash", name: "Trash", deletable: 0 }, +]; + +export class MailboxDO extends DurableObject { + private state: DurableObjectStateLike; + private env: Env; + private sql: DurableObjectStateLike["storage"]["sql"]; + + constructor(state: DurableObjectStateLike, env: Env) { + super(state as never, env as never); + this.state = state; + this.env = env; + this.sql = state.storage.sql; + void this.state.blockConcurrencyWhile(async () => { + this.initSchema(); + }); + } + + async fetch(request: Request): Promise { + const url = new URL(request.url); + + try { + if (request.method === "POST" && url.pathname === "/internal/incoming") { + const payload = (await request.json()) as IncomingEmailPayload; + return json(await this.storeIncoming(payload)); + } + + if (request.method === "GET" && url.pathname === "/internal/emails/latest") { + const folderId = url.searchParams.get("folder") ?? "inbox"; + const email = this.getLatestEmail(folderId); + return email ? json(email) : notFound("no mail"); + } + + if (request.method === "GET" && url.pathname === "/internal/emails") { + const threaded = ["1", "true"].includes((url.searchParams.get("threaded") ?? "").toLowerCase()); + const folderId = url.searchParams.get("folder") ?? "inbox"; + const limit = clampNumber(url.searchParams.get("limit"), 10, 1, 200); + const offset = clampNumber(url.searchParams.get("offset"), 0, 0, 10000); + const sortColumn = normalizeSortColumn(url.searchParams.get("sortColumn")); + const sortDirection = normalizeSortDirection(url.searchParams.get("sortDirection")); + const q = (url.searchParams.get("q") ?? "").trim(); + + if (threaded) { + return json(this.listThreads({ folderId, limit, offset, q, sortDirection })); + } + return json(this.listEmails({ folderId, limit, offset, q, sortColumn, sortDirection })); + } + + const emailMatch = url.pathname.match(/^\/internal\/emails\/([^/]+)$/); + if (request.method === "GET" && emailMatch) { + const email = this.getEmailById(decodeURIComponent(emailMatch[1]!)); + return email ? json(email) : notFound(); + } + + if (request.method === "POST" && emailMatch && url.pathname.endsWith("/read") === false) { + return badRequest("unsupported email mutation"); + } + + const readMatch = url.pathname.match(/^\/internal\/emails\/([^/]+)\/read$/); + if (request.method === "POST" && readMatch) { + const id = decodeURIComponent(readMatch[1]!); + this.sql.exec(`UPDATE emails SET read = 1 WHERE id = ?`, id); + return json({ ok: true, id }); + } + + const threadMatch = url.pathname.match(/^\/internal\/threads\/([^/]+)$/); + if (request.method === "GET" && threadMatch) { + return json(this.getThread(decodeURIComponent(threadMatch[1]!))); + } + + const attachmentMatch = url.pathname.match(/^\/internal\/attachments\/([^/]+)$/); + if (request.method === "GET" && attachmentMatch) { + return await this.serveAttachment(decodeURIComponent(attachmentMatch[1]!)); + } + + if (request.method === "POST" && url.pathname === "/internal/send") { + const body = (await request.json()) as SendRequestBody & { mailboxId: string }; + return json(await this.sendNew(body.mailboxId, body)); + } + + if (request.method === "POST" && url.pathname === "/internal/reply") { + const body = (await request.json()) as ReplyRequestBody & { mailboxId: string }; + return json(await this.reply(body.mailboxId, body)); + } + + if (request.method === "POST" && url.pathname === "/internal/forward") { + const body = (await request.json()) as ForwardRequestBody & { mailboxId: string }; + return json(await this.forward(body.mailboxId, body)); + } + + if (request.method === "DELETE" && url.pathname === "/internal/inbox") { + const deleted = await this.clearFolder(url.searchParams.get("folder") ?? "inbox"); + return json({ ok: true, deleted }); + } + + return notFound("unknown route"); + } catch (error) { + const maybeStatus = (error as Error & { status?: number })?.status; + if (maybeStatus && maybeStatus >= 400 && maybeStatus < 600) { + return json({ error: (error as Error).message }, maybeStatus); + } + return serverError("mailbox operation failed", serializeError(error)); + } + } + + private initSchema(): void { + this.sql.exec(` + CREATE TABLE IF NOT EXISTS folders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + is_deletable INTEGER NOT NULL DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS emails ( + id TEXT PRIMARY KEY, + folder_id TEXT NOT NULL, + subject TEXT, + sender TEXT, + recipient TEXT, + cc TEXT, + bcc TEXT, + date TEXT, + read INTEGER NOT NULL DEFAULT 0, + starred INTEGER NOT NULL DEFAULT 0, + body_text TEXT, + body_html TEXT, + snippet TEXT, + normalized_subject TEXT, + participants TEXT, + in_reply_to TEXT, + email_references TEXT, + thread_id TEXT, + message_id TEXT, + raw_headers TEXT, + FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + email_id TEXT NOT NULL, + r2_key TEXT NOT NULL, + filename TEXT NOT NULL, + mimetype TEXT NOT NULL, + size INTEGER NOT NULL, + content_id TEXT, + disposition TEXT, + FOREIGN KEY (email_id) REFERENCES emails(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_emails_folder_date ON emails(folder_id, date DESC); + CREATE INDEX IF NOT EXISTS idx_emails_thread ON emails(thread_id, date DESC); + CREATE INDEX IF NOT EXISTS idx_emails_message_id ON emails(message_id); + CREATE INDEX IF NOT EXISTS idx_emails_subject_date ON emails(normalized_subject, date DESC); + `); + + for (const folder of FOLDERS) { + this.sql.exec( + `INSERT OR IGNORE INTO folders (id, name, is_deletable) VALUES (?, ?, ?)`, + folder.id, + folder.name, + folder.deletable, + ); + } + } + + private async storeIncoming(payload: IncomingEmailPayload): Promise<{ ok: true; emailId: string; threadId: string }> { + const normalizedSubject = normalizeSubject(payload.subject); + const participants = buildParticipants(payload.from, payload.to, payload.cc, payload.bcc); + const threadId = this.resolveThreadId({ + normalizedSubject, + participants, + inReplyTo: payload.inReplyTo, + references: payload.references, + messageId: payload.messageId, + date: payload.date, + }); + + const emailId = payload.id || crypto.randomUUID(); + this.insertEmail({ + id: emailId, + folderId: payload.folderId, + subject: payload.subject, + sender: payload.from, + recipient: payload.to, + cc: payload.cc ?? null, + bcc: payload.bcc ?? null, + date: payload.date, + read: 0, + bodyText: payload.bodyText, + bodyHtml: payload.bodyHtml, + snippet: payload.snippet, + normalizedSubject, + participants, + inReplyTo: normalizeMessageId(payload.inReplyTo), + references: payload.references ?? null, + threadId, + messageId: normalizeMessageId(payload.messageId), + rawHeaders: JSON.stringify(payload.rawHeaders), + }); + + await this.storeAttachments(emailId, payload.attachments); + return { ok: true, emailId, threadId }; + } + + private listEmails(params: { + folderId: string; + limit: number; + offset: number; + q: string; + sortColumn: "date" | "subject" | "sender"; + sortDirection: "ASC" | "DESC"; + }): { items: ListSummary[]; total: number } { + const rows = this.queryEmailRows(params.folderId, params.q); + const sorted = rows.sort((left, right) => compareEmailRows(left, right, params.sortColumn, params.sortDirection)); + const sliced = sorted.slice(params.offset, params.offset + params.limit); + + return { + items: sliced.map((row) => this.toListSummary(row)), + total: sorted.length, + }; + } + + private listThreads(params: { + folderId: string; + limit: number; + offset: number; + q: string; + sortDirection: "ASC" | "DESC"; + }): { items: ThreadSummary[]; total: number } { + const rows = this.queryEmailRows(params.folderId, params.q); + const groups = new Map(); + + for (const row of rows) { + const key = row.thread_id || row.id; + const bucket = groups.get(key) ?? []; + bucket.push(row); + groups.set(key, bucket); + } + + const summaries: ThreadSummary[] = [...groups.entries()].map(([threadId, items]) => { + items.sort((a, b) => compareIso(a.date, b.date, "DESC")); + const latest = items[0]!; + const unreadCount = items.filter((item) => item.folder_id === "inbox" && item.read === 0).length; + const attachmentCount = items.reduce((sum, item) => sum + this.countAttachments(item.id), 0); + return { + threadId, + subject: latest.subject ?? "(no subject)", + lastDate: latest.date, + lastSender: latest.sender ?? "", + lastRecipient: latest.recipient ?? "", + snippet: latest.snippet ?? "", + unreadCount, + messageCount: items.length, + latestEmailId: latest.id, + attachmentCount, + }; + }); + + summaries.sort((a, b) => compareIso(a.lastDate, b.lastDate, params.sortDirection)); + const sliced = summaries.slice(params.offset, params.offset + params.limit); + return { items: sliced, total: summaries.length }; + } + + private getLatestEmail(folderId: string): unknown | null { + const row = this.queryEmailRows(folderId, "").sort((a, b) => compareIso(a.date, b.date, "DESC"))[0]; + return row ? this.getEmailById(row.id) : null; + } + + private getEmailById(id: string): unknown | null { + const row = this.one(`SELECT * FROM emails WHERE id = ? LIMIT 1`, id); + if (!row) return null; + return this.hydrateEmail(row); + } + + private getThread(threadId: string): { threadId: string; items: unknown[] } { + const rows = this.all(`SELECT * FROM emails WHERE thread_id = ? ORDER BY date ASC`, threadId); + return { + threadId, + items: rows.map((row) => this.hydrateEmail(row)), + }; + } + + private async serveAttachment(id: string): Promise { + const record = this.one(`SELECT * FROM attachments WHERE id = ? LIMIT 1`, id); + if (!record) return notFound("attachment not found"); + + const object = await this.env.BUCKET.get(record.r2_key); + if (!object || !object.body) return notFound("attachment body missing"); + + return new Response(object.body, { + headers: { + "content-type": record.mimetype, + "content-disposition": `${record.disposition === "inline" ? "inline" : "attachment"}; filename="${safeFilename(record.filename)}"`, + "content-length": String(record.size), + }, + }); + } + + private ensureOutboundEnabled(): void { + if (!hasOutboundEmailBinding(this.env)) { + const error = new Error("outbound email is not configured; add an EMAIL send_email binding after deployment"); + (error as Error & { status?: number }).status = 501; + throw error; + } + } + + private async sendNew(mailboxId: string, body: SendRequestBody): Promise> { + this.ensureOutboundEnabled(); + if (!body.subject?.trim()) { + throw new Error("subject is required"); + } + const cleanedAttachments = (body.attachments ?? []).map(sanitizeAttachmentInput); + const messageId = makeMessageId(mailboxId); + const threadId = normalizeMessageId(messageId); + const now = new Date().toISOString(); + const textBody = body.text ?? stripHtml(body.html ?? ""); + const htmlBody = body.html ?? `
${escapeHtml(textBody)}
`; + + const provider = await this.env.EMAIL!.send({ + to: body.to, + cc: body.cc, + bcc: body.bcc, + from: formatAddress(mailboxId, body.fromName), + replyTo: body.replyTo, + subject: body.subject, + text: textBody, + html: htmlBody, + attachments: cleanedAttachments.map((item) => ({ + content: item.contentBase64, + filename: item.filename, + type: item.type, + disposition: item.disposition ?? "attachment", + contentId: item.contentId, + })), + headers: { + "Message-ID": `<${messageId}>`, + }, + }); + + const emailId = crypto.randomUUID(); + this.insertEmail({ + id: emailId, + folderId: "sent", + subject: body.subject, + sender: mailboxId, + recipient: ensureArray(body.to).join(", "), + cc: ensureArray(body.cc).join(", ") || null, + bcc: ensureArray(body.bcc).join(", ") || null, + date: now, + read: 1, + bodyText: textBody, + bodyHtml: htmlBody, + snippet: buildSnippet(textBody, htmlBody), + normalizedSubject: normalizeSubject(body.subject), + participants: buildParticipants(mailboxId, ensureArray(body.to).join(", "), ensureArray(body.cc).join(", "), ensureArray(body.bcc).join(", ")), + inReplyTo: null, + references: null, + threadId, + messageId, + rawHeaders: JSON.stringify({ "message-id": `<${messageId}>` }), + }); + await this.storeAttachments(emailId, cleanedAttachments); + + return { + ok: true, + emailId, + threadId, + messageId, + providerMessageId: provider.messageId, + }; + } + + private async reply(mailboxId: string, body: ReplyRequestBody): Promise> { + this.ensureOutboundEnabled(); + const original = this.one(`SELECT * FROM emails WHERE id = ? LIMIT 1`, body.originalEmailId); + if (!original) throw new Error("original email not found"); + + const toList = body.replyAll + ? uniqueAddresses([ + original.sender, + original.recipient, + original.cc, + ]).filter((addr) => normalizeEmail(addr) !== normalizeEmail(mailboxId)) + : uniqueAddresses([original.sender]); + + if (toList.length === 0) throw new Error("no valid recipients for reply"); + + const originalMessageId = normalizeMessageId(original.message_id); + const originalRefs = parseReferences(original.email_references); + const referenceChain = [...new Set([...originalRefs, originalMessageId].filter(Boolean))]; + const messageId = makeMessageId(mailboxId); + const threadId = original.thread_id || originalMessageId || normalizeMessageId(messageId); + const subject = ensureReplySubject(original.subject ?? "(no subject)"); + const replyText = body.text ?? ""; + const originalQuote = buildReplyQuoteText(original); + const textBody = [replyText.trim(), originalQuote].filter(Boolean).join("\n\n"); + const htmlBody = body.html + ? `${body.html}
${buildReplyQuoteHtml(original)}` + : `

${escapeHtml(replyText).replace(/\n/g, "
")}


${buildReplyQuoteHtml(original)}`; + const cleanedAttachments = (body.attachments ?? []).map(sanitizeAttachmentInput); + const now = new Date().toISOString(); + + const provider = await this.env.EMAIL!.send({ + to: toList, + from: formatAddress(mailboxId, body.fromName), + subject, + text: textBody, + html: htmlBody, + attachments: cleanedAttachments.map((item) => ({ + content: item.contentBase64, + filename: item.filename, + type: item.type, + disposition: item.disposition ?? "attachment", + contentId: item.contentId, + })), + headers: { + "Message-ID": `<${messageId}>`, + "In-Reply-To": `<${originalMessageId}>`, + References: referenceChain.map((item) => `<${item}>`).join(" "), + }, + }); + + const emailId = crypto.randomUUID(); + this.insertEmail({ + id: emailId, + folderId: "sent", + subject, + sender: mailboxId, + recipient: toList.join(", "), + cc: null, + bcc: null, + date: now, + read: 1, + bodyText: textBody, + bodyHtml: htmlBody, + snippet: buildSnippet(replyText, htmlBody), + normalizedSubject: normalizeSubject(subject), + participants: buildParticipants(mailboxId, toList.join(", ")), + inReplyTo: originalMessageId, + references: referenceChain.join(" "), + threadId, + messageId, + rawHeaders: JSON.stringify({ + "message-id": `<${messageId}>`, + "in-reply-to": `<${originalMessageId}>`, + references: referenceChain.map((item) => `<${item}>`).join(" "), + }), + }); + await this.storeAttachments(emailId, cleanedAttachments); + + return { + ok: true, + emailId, + threadId, + messageId, + providerMessageId: provider.messageId, + }; + } + + private async forward(mailboxId: string, body: ForwardRequestBody): Promise> { + this.ensureOutboundEnabled(); + const original = this.one(`SELECT * FROM emails WHERE id = ? LIMIT 1`, body.originalEmailId); + if (!original) throw new Error("original email not found"); + + const originalAttachments = body.includeOriginalAttachments === false + ? [] + : await this.loadAttachmentsForSend(body.originalEmailId); + const extraAttachments = (body.attachments ?? []).map(sanitizeAttachmentInput).map((item) => ({ + content: item.contentBase64, + filename: item.filename, + type: item.type, + disposition: item.disposition ?? "attachment", + contentId: item.contentId, + })); + const attachments = [...originalAttachments, ...extraAttachments]; + + const subject = body.subject?.trim() || ensureForwardSubject(original.subject ?? "(no subject)"); + const introText = body.introText?.trim() || ""; + const introHtml = body.introHtml?.trim() || `

${escapeHtml(introText).replace(/\n/g, "
")}

`; + const forwardedText = buildForwardText(original, introText); + const forwardedHtml = `${introHtml}
${buildForwardHtml(original)}`; + const messageId = makeMessageId(mailboxId); + const threadId = normalizeMessageId(messageId); + const now = new Date().toISOString(); + + const provider = await this.env.EMAIL!.send({ + to: body.to, + cc: body.cc, + bcc: body.bcc, + from: formatAddress(mailboxId, body.fromName), + subject, + text: forwardedText, + html: forwardedHtml, + attachments, + headers: { + "Message-ID": `<${messageId}>`, + }, + }); + + const emailId = crypto.randomUUID(); + this.insertEmail({ + id: emailId, + folderId: "sent", + subject, + sender: mailboxId, + recipient: ensureArray(body.to).join(", "), + cc: ensureArray(body.cc).join(", ") || null, + bcc: ensureArray(body.bcc).join(", ") || null, + date: now, + read: 1, + bodyText: forwardedText, + bodyHtml: forwardedHtml, + snippet: buildSnippet(forwardedText, forwardedHtml), + normalizedSubject: normalizeSubject(subject), + participants: buildParticipants(mailboxId, ensureArray(body.to).join(", "), ensureArray(body.cc).join(", "), ensureArray(body.bcc).join(", ")), + inReplyTo: null, + references: null, + threadId, + messageId, + rawHeaders: JSON.stringify({ "message-id": `<${messageId}>` }), + }); + + const persistedAttachments: StoredAttachmentInput[] = []; + for (const item of attachments) { + const contentBase64 = typeof item.content === "string" ? item.content : toBase64(item.content); + persistedAttachments.push({ + filename: item.filename, + type: item.type, + disposition: item.disposition, + contentId: item.contentId, + contentBase64, + }); + } + await this.storeAttachments(emailId, persistedAttachments); + + return { + ok: true, + emailId, + threadId, + messageId, + providerMessageId: provider.messageId, + }; + } + + private async clearFolder(folderId: string): Promise { + const attachmentRows = this.all( + `SELECT attachments.* FROM attachments JOIN emails ON attachments.email_id = emails.id WHERE emails.folder_id = ?`, + folderId, + ); + if (attachmentRows.length > 0) { + await this.env.BUCKET.delete(attachmentRows.map((row) => row.r2_key)); + } + const emails = this.all<{ id: string }>(`SELECT id FROM emails WHERE folder_id = ?`, folderId); + this.sql.exec(`DELETE FROM emails WHERE folder_id = ?`, folderId); + return emails.length; + } + + private resolveThreadId(params: { + normalizedSubject: string; + participants: string; + inReplyTo?: string | null; + references?: string | null; + messageId: string; + date: string; + }): string { + const refs = parseReferences(params.references); + if (refs.length > 0) { + const refRow = this.one<{ thread_id: string | null }>( + `SELECT thread_id FROM emails WHERE message_id = ? LIMIT 1`, + refs[0], + ); + return refRow?.thread_id || refs[0]; + } + + const inReplyTo = normalizeMessageId(params.inReplyTo); + if (inReplyTo) { + const replyRow = this.one<{ thread_id: string | null }>( + `SELECT thread_id FROM emails WHERE message_id = ? LIMIT 1`, + inReplyTo, + ); + return replyRow?.thread_id || inReplyTo; + } + + if (params.normalizedSubject) { + const cutoff = new Date(new Date(params.date).getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(); + const candidates = this.all<{ thread_id: string | null; participants: string | null }>( + `SELECT thread_id, participants FROM emails WHERE normalized_subject = ? AND date >= ? ORDER BY date DESC LIMIT 20`, + params.normalizedSubject, + cutoff, + ); + for (const candidate of candidates) { + if (candidate.thread_id && participantsOverlap(candidate.participants ?? "", params.participants)) { + return candidate.thread_id; + } + } + } + + return normalizeMessageId(params.messageId) || crypto.randomUUID(); + } + + private insertEmail(record: { + id: string; + folderId: string; + subject: string; + sender: string; + recipient: string; + cc: string | null; + bcc: string | null; + date: string; + read: number; + bodyText: string; + bodyHtml: string; + snippet: string; + normalizedSubject: string; + participants: string; + inReplyTo: string | null; + references: string | null; + threadId: string; + messageId: string; + rawHeaders: string; + }): void { + this.sql.exec( + `INSERT INTO emails ( + id, folder_id, subject, sender, recipient, cc, bcc, date, read, starred, + body_text, body_html, snippet, normalized_subject, participants, + in_reply_to, email_references, thread_id, message_id, raw_headers + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + record.id, + record.folderId, + record.subject, + record.sender, + record.recipient, + record.cc, + record.bcc, + record.date, + record.read, + record.bodyText, + record.bodyHtml, + record.snippet, + record.normalizedSubject, + record.participants, + record.inReplyTo, + record.references, + record.threadId, + record.messageId, + record.rawHeaders, + ); + } + + private async storeAttachments(emailId: string, attachments: StoredAttachmentInput[]): Promise { + for (const rawAttachment of attachments) { + const attachment = sanitizeAttachmentInput(rawAttachment); + const bytes = fromBase64(attachment.contentBase64); + const attachmentId = crypto.randomUUID(); + const r2Key = `attachments/${emailId}/${attachmentId}/${safeFilename(attachment.filename)}`; + await this.env.BUCKET.put(r2Key, bytes, { + httpMetadata: { contentType: attachment.type }, + customMetadata: { + emailId, + attachmentId, + }, + }); + this.sql.exec( + `INSERT INTO attachments (id, email_id, r2_key, filename, mimetype, size, content_id, disposition) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + attachmentId, + emailId, + r2Key, + attachment.filename, + attachment.type, + attachment.size ?? bytes.byteLength, + attachment.contentId ?? null, + attachment.disposition ?? "attachment", + ); + } + } + + private async loadAttachmentsForSend(emailId: string): Promise> { + const records = this.listAttachments(emailId); + const result: Array<{ content: string; filename: string; type: string; disposition: "attachment" | "inline"; contentId?: string }> = []; + + for (const record of records) { + const object = await this.env.BUCKET.get(record.r2_key); + if (!object?.body) continue; + const arrayBuffer = await new Response(object.body).arrayBuffer(); + result.push({ + content: toBase64(arrayBuffer), + filename: record.filename, + type: record.mimetype, + disposition: record.disposition === "inline" ? "inline" : "attachment", + contentId: record.content_id ?? undefined, + }); + } + return result; + } + + private queryEmailRows(folderId: string, q: string): EmailRow[] { + const rows = folderId === "all" + ? this.all(`SELECT * FROM emails`) + : this.all(`SELECT * FROM emails WHERE folder_id = ?`, folderId); + + if (!q) return rows; + const needle = q.toLowerCase(); + return rows.filter((row) => { + const haystack = [ + row.subject, + row.sender, + row.recipient, + row.cc, + row.bcc, + row.body_text, + row.body_html, + row.snippet, + ] + .filter(Boolean) + .join("\n") + .toLowerCase(); + return haystack.includes(needle); + }); + } + + private hydrateEmail(row: EmailRow): Record { + return { + id: row.id, + folderId: row.folder_id, + subject: row.subject ?? "(no subject)", + from: row.sender ?? "", + to: row.recipient ?? "", + cc: row.cc ?? "", + bcc: row.bcc ?? "", + date: row.date, + read: Boolean(row.read), + starred: Boolean(row.starred), + text: row.body_text ?? "", + html: row.body_html ?? "", + snippet: row.snippet ?? "", + threadId: row.thread_id ?? row.id, + messageId: row.message_id ?? "", + inReplyTo: row.in_reply_to ?? "", + references: row.email_references ?? "", + attachments: this.listAttachments(row.id).map((item) => ({ + id: item.id, + filename: item.filename, + mimeType: item.mimetype, + size: item.size, + contentId: item.content_id, + disposition: item.disposition, + downloadPath: `/api/attachments/${encodeURIComponent(item.id)}`, + })), + rawHeaders: row.raw_headers ? safeJsonParse(row.raw_headers) : null, + }; + } + + private toListSummary(row: EmailRow): ListSummary { + return { + id: row.id, + folderId: row.folder_id, + subject: row.subject ?? "(no subject)", + sender: row.sender ?? "", + recipient: row.recipient ?? "", + date: row.date, + read: Boolean(row.read), + starred: Boolean(row.starred), + snippet: row.snippet ?? "", + threadId: row.thread_id ?? row.id, + messageId: row.message_id ?? "", + attachmentCount: this.countAttachments(row.id), + }; + } + + private countAttachments(emailId: string): number { + const row = this.one<{ count: number }>(`SELECT COUNT(*) AS count FROM attachments WHERE email_id = ?`, emailId); + return Number(row?.count ?? 0); + } + + private listAttachments(emailId: string): StoredAttachmentRecord[] { + return this.all(`SELECT * FROM attachments WHERE email_id = ? ORDER BY filename ASC`, emailId); + } + + private all(query: string, ...bindings: unknown[]): T[] { + return [...this.sql.exec(query, ...bindings)]; + } + + private one(query: string, ...bindings: unknown[]): T | null { + const rows = this.all(query, ...bindings); + return rows[0] ?? null; + } +} + +function clampNumber(value: string | null, fallback: number, min: number, max: number): number { + const parsed = Number(value ?? fallback); + if (Number.isNaN(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); +} + +function normalizeSortColumn(value: string | null): "date" | "subject" | "sender" { + return value === "subject" || value === "sender" ? value : "date"; +} + +function normalizeSortDirection(value: string | null): "ASC" | "DESC" { + return String(value ?? "DESC").toUpperCase() === "ASC" ? "ASC" : "DESC"; +} + +function compareIso(left: string, right: string, direction: "ASC" | "DESC"): number { + if (left === right) return 0; + const result = left < right ? -1 : 1; + return direction === "ASC" ? result : -result; +} + +function compareString(left: string, right: string, direction: "ASC" | "DESC"): number { + const result = left.localeCompare(right); + return direction === "ASC" ? result : -result; +} + +function compareEmailRows( + left: EmailRow, + right: EmailRow, + sortColumn: "date" | "subject" | "sender", + sortDirection: "ASC" | "DESC", +): number { + if (sortColumn === "subject") { + return compareString(left.subject ?? "", right.subject ?? "", sortDirection) || compareIso(left.date, right.date, "DESC"); + } + if (sortColumn === "sender") { + return compareString(left.sender ?? "", right.sender ?? "", sortDirection) || compareIso(left.date, right.date, "DESC"); + } + return compareIso(left.date, right.date, sortDirection); +} + +function ensureReplySubject(subject: string): string { + return /^re:/i.test(subject) ? subject : `Re: ${subject}`; +} + +function ensureForwardSubject(subject: string): string { + return /^fwd?:/i.test(subject) ? subject : `Fwd: ${subject}`; +} + +function uniqueAddresses(values: Array): string[] { + const set = new Set(); + for (const value of values) { + for (const token of String(value ?? "").split(",")) { + const trimmed = token.trim(); + if (trimmed) set.add(trimmed); + } + } + return [...set]; +} + +function buildReplyQuoteText(original: EmailRow): string { + const date = original.date; + const from = original.sender ?? ""; + const body = original.body_text?.trim() || stripHtml(original.body_html ?? ""); + return `On ${date}, ${from} wrote:\n> ${body.replace(/\n/g, "\n> ")}`; +} + +function buildReplyQuoteHtml(original: EmailRow): string { + const header = `

On ${escapeHtml(original.date)}, ${escapeHtml(original.sender ?? "")} wrote:

`; + const originalHtml = original.body_html?.trim() || `
${escapeHtml(original.body_text ?? "")}
`; + return `${header}
${originalHtml}
`; +} + +function buildForwardText(original: EmailRow, introText: string): string { + const lines = [ + introText, + "---------- Forwarded message ---------", + `From: ${original.sender ?? ""}`, + `Date: ${original.date}`, + `Subject: ${original.subject ?? "(no subject)"}`, + `To: ${original.recipient ?? ""}`, + original.cc ? `Cc: ${original.cc}` : "", + "", + original.body_text?.trim() || stripHtml(original.body_html ?? ""), + ].filter(Boolean); + return lines.join("\n"); +} + +function buildForwardHtml(original: EmailRow): string { + const rows = [ + [`From`, original.sender ?? ""], + [`Date`, original.date], + [`Subject`, original.subject ?? "(no subject)"], + [`To`, original.recipient ?? ""], + original.cc ? [`Cc`, original.cc] : null, + ].filter(Boolean) as Array<[string, string]>; + const meta = rows + .map(([label, value]) => `

${escapeHtml(label)}: ${escapeHtml(value)}

`) + .join(""); + const originalHtml = original.body_html?.trim() || `
${escapeHtml(original.body_text ?? "")}
`; + return `

---------- Forwarded message ---------

${meta}
${originalHtml}
`; +} + +function safeJsonParse(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function serializeError(error: unknown): Record { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + return { message: String(error) }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..c8dceb6 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,155 @@ +export type SqlCursorRow = object; + +export interface SqlStorage { + exec(query: string, ...bindings: unknown[]): Iterable; +} + +export interface DurableObjectStateLike { + storage: { + sql: SqlStorage; + }; + blockConcurrencyWhile(callback: () => Promise | T): Promise; +} + +export interface DurableObjectIdLike { + toString(): string; +} + +export interface DurableObjectStubLike { + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; +} + +export interface DurableObjectNamespaceLike { + idFromName(name: string): DurableObjectIdLike; + get(id: DurableObjectIdLike): DurableObjectStubLike; +} + +export interface R2ObjectBodyLike { + body: ReadableStream | null; + size: number; + httpMetadata?: { + contentType?: string; + }; +} + +export interface R2BucketLike { + put( + key: string, + value: ArrayBuffer | Uint8Array | string, + options?: { + httpMetadata?: { + contentType?: string; + }; + customMetadata?: Record; + }, + ): Promise; + get(key: string): Promise; + delete(keys: string | string[]): Promise; +} + +export interface EmailAttachment { + content: string | ArrayBuffer; + filename: string; + type: string; + disposition: "attachment" | "inline"; + contentId?: string; +} + +export interface EmailSendMessage { + to: string | string[]; + from: string | { email: string; name: string }; + subject: string; + html?: string; + text?: string; + cc?: string | string[]; + bcc?: string | string[]; + replyTo?: string | { email: string; name: string }; + attachments?: EmailAttachment[]; + headers?: Record; +} + +export interface EmailBindingLike { + send(message: EmailSendMessage): Promise<{ messageId: string }>; +} + +export interface Env { + AUTH_KEY: string; + DOMAINS: string; + DEFAULT_MAILBOX?: string; + MAILBOX: DurableObjectNamespaceLike; + BUCKET: R2BucketLike; + EMAIL?: EmailBindingLike; +} + +export interface StoredAttachmentInput { + filename: string; + type: string; + disposition?: "attachment" | "inline"; + contentId?: string; + contentBase64: string; + size?: number; +} + +export interface StoredAttachmentRecord { + id: string; + email_id: string; + r2_key: string; + filename: string; + mimetype: string; + size: number; + content_id: string | null; + disposition: string | null; +} + +export interface IncomingEmailPayload { + id: string; + folderId: string; + from: string; + to: string; + cc?: string | null; + bcc?: string | null; + subject: string; + bodyText: string; + bodyHtml: string; + snippet: string; + date: string; + messageId: string; + inReplyTo?: string | null; + references?: string | null; + rawHeaders: Record; + attachments: StoredAttachmentInput[]; +} + +export interface SendRequestBody { + fromName?: string; + to: string | string[]; + cc?: string | string[]; + bcc?: string | string[]; + subject: string; + text?: string; + html?: string; + replyTo?: string; + attachments?: StoredAttachmentInput[]; +} + +export interface ReplyRequestBody { + originalEmailId: string; + fromName?: string; + text?: string; + html?: string; + replyAll?: boolean; + attachments?: StoredAttachmentInput[]; +} + +export interface ForwardRequestBody { + originalEmailId: string; + to: string | string[]; + cc?: string | string[]; + bcc?: string | string[]; + fromName?: string; + subject?: string; + introText?: string; + introHtml?: string; + includeOriginalAttachments?: boolean; + attachments?: StoredAttachmentInput[]; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..0dea811 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,207 @@ +import type { DurableObjectStubLike, Env, StoredAttachmentInput } from "./types"; + +const REPLY_PREFIX_RE = /^(\s*(?:re|fw|fwd|aw|wg|sv|réf)\s*:\s*)+/i; +const EMAIL_RE = /([a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+)/g; + +export function json(data: unknown, status = 200, headers?: HeadersInit): Response { + return new Response(JSON.stringify(data, null, 2), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + ...(headers ?? {}), + }, + }); +} + +export function text(data: string, status = 200, headers?: HeadersInit): Response { + return new Response(data, { status, headers }); +} + +export function badRequest(message: string, details?: unknown): Response { + return json({ error: message, details }, 400); +} + +export function unauthorized(): Response { + return json({ error: "unauthorized" }, 401); +} + +export function notFound(message = "not found"): Response { + return json({ error: message }, 404); +} + +export function serverError(message: string, details?: unknown): Response { + return json({ error: message, details }, 500); +} + +export function normalizeEmail(value: string): string { + return value.trim().toLowerCase(); +} + +export function mailboxStub(env: Env, mailboxId: string): DurableObjectStubLike { + const id = env.MAILBOX.idFromName(normalizeEmail(mailboxId)); + return env.MAILBOX.get(id); +} + +export function assertAuthorized(request: Request, env: Env): boolean { + const incoming = request.headers.get("x-auth-key") ?? ""; + return Boolean(env.AUTH_KEY) && incoming === env.AUTH_KEY; +} + +export function hasOutboundEmailBinding(env: Env): boolean { + return Boolean(env.EMAIL && typeof env.EMAIL.send === "function"); +} + +export function resolveMailbox(request: Request, env: Env, explicit?: string | null): string | null { + const url = new URL(request.url); + const mailbox = explicit ?? url.searchParams.get("mailbox") ?? request.headers.get("x-mailbox") ?? env.DEFAULT_MAILBOX ?? ""; + return mailbox ? normalizeEmail(mailbox) : null; +} + +export function allowedDomains(env: Env): string[] { + return String(env.DOMAINS ?? "") + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); +} + +export function isAllowedMailbox(mailbox: string, env: Env): boolean { + const domains = allowedDomains(env); + if (domains.length === 0) return true; + const domain = mailbox.split("@")[1] ?? ""; + return domains.includes(domain.toLowerCase()); +} + +export function parseJson(request: Request): Promise { + return request.json() as Promise; +} + +export function ensureArray(value: T | T[] | undefined | null): T[] { + if (value === undefined || value === null) return []; + return Array.isArray(value) ? value : [value]; +} + +export function normalizeSubject(subject: string): string { + return (subject || "(no subject)").replace(REPLY_PREFIX_RE, "").trim().toLowerCase(); +} + +export function extractEmails(value: string | undefined | null): string[] { + if (!value) return []; + const found = value.match(EMAIL_RE) ?? []; + return [...new Set(found.map((item) => normalizeEmail(item)))]; +} + +export function buildParticipants(sender: string, recipient: string, cc?: string | null, bcc?: string | null): string { + const values = new Set([ + ...extractEmails(sender), + ...extractEmails(recipient), + ...extractEmails(cc ?? undefined), + ...extractEmails(bcc ?? undefined), + ]); + return [...values].sort().join(","); +} + +export function participantsOverlap(left: string, right: string): boolean { + const a = new Set(left.split(",").map((item) => item.trim()).filter(Boolean)); + const b = new Set(right.split(",").map((item) => item.trim()).filter(Boolean)); + for (const value of a) { + if (b.has(value)) return true; + } + return false; +} + +export function normalizeMessageId(value: string | undefined | null): string { + if (!value) return ""; + return value.trim().replace(/^<+|>+$/g, "").toLowerCase(); +} + +export function parseReferences(value: string | undefined | null): string[] { + if (!value) return []; + return value + .split(/\s+/) + .map((item) => normalizeMessageId(item)) + .filter(Boolean); +} + +export function buildSnippet(textValue: string, htmlValue: string): string { + const source = textValue?.trim() || stripHtml(htmlValue); + return source.replace(/\s+/g, " ").trim().slice(0, 220); +} + +export function stripHtml(html: string | undefined | null): string { + if (!html) return ""; + return html.replace(//gi, " ") + .replace(//gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/\s+/g, " ") + .trim(); +} + +export function toBase64(input: ArrayBuffer | Uint8Array | string): string { + if (typeof input === "string") { + return btoa(input); + } + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); + let binary = ""; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + return btoa(binary); +} + +export function fromBase64(input: string): Uint8Array { + const binary = atob(input); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +export function safeFilename(input: string): string { + return input.replace(/[\\/:*?"<>|]+/g, "-").trim() || "attachment.bin"; +} + +export function quoteHtml(textValue: string): string { + return textValue + .split("\n") + .map((line) => `
${escapeHtml(line || " ")}
`) + .join("\n"); +} + +export function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function makeMessageId(mailbox: string): string { + const domain = mailbox.split("@")[1] ?? "localhost"; + return `${crypto.randomUUID()}@${domain}`; +} + +export function formatAddress(mailbox: string, name?: string): string | { email: string; name: string } { + if (!name?.trim()) return mailbox; + return { email: mailbox, name: name.trim() }; +} + +export function attachmentSize(base64: string): number { + const trimmed = base64.replace(/=+$/, ""); + return Math.floor((trimmed.length * 3) / 4); +} + +export function sanitizeAttachmentInput(input: StoredAttachmentInput): StoredAttachmentInput { + return { + filename: safeFilename(input.filename), + type: input.type || "application/octet-stream", + disposition: input.disposition ?? "attachment", + contentId: input.contentId ?? undefined, + contentBase64: input.contentBase64, + size: input.size ?? attachmentSize(input.contentBase64), + }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..577e23a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [], + "baseUrl": ".", + // Local JS emission only; Cloudflare deploys from wrangler.jsonc -> src/index.ts. + "outDir": "dist" + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"] +} diff --git a/worker.js b/worker.js index 730a7ec..ae0f57c 100644 --- a/worker.js +++ b/worker.js @@ -1,115 +1 @@ -import PostalMime from "postal-mime"; - -const MAX_MAILS = 50; -const STORAGE_KEY = "inbox"; - -// ─── 收信 Handler ──────────────────────────────────────────────── -export async function email(message, env) { - const parser = new PostalMime(); - const raw = await streamToArrayBuffer(message.raw); - const parsed = await parser.parse(raw); - - const mail = { - id: crypto.randomUUID(), - receivedAt: new Date().toISOString(), - from: message.from, - to: message.to, - subject: parsed.subject ?? "(no subject)", - text: parsed.text ?? "", - html: parsed.html ?? "", - // 只保留附件 metadata,不存 base64 內容,避免撞 KV 25MB 限制 - attachments: (parsed.attachments ?? []).map((a) => ({ - filename: a.filename ?? "unknown", - mimeType: a.mimeType ?? "application/octet-stream", - size: a.content?.byteLength ?? 0, - })), - }; - - const existing = await loadInbox(env); - const updated = [mail, ...existing].slice(0, MAX_MAILS); - await env.MAIL_KV.put(STORAGE_KEY, JSON.stringify(updated)); -} - -// ─── HTTP API Handler ───────────────────────────────────────────── -export default { - email, - - async fetch(request, env) { - if (request.headers.get("X-Auth-Key") !== env.AUTH_KEY) { - return json({ error: "unauthorized" }, 401); - } - - const url = new URL(request.url); - const mails = await loadInbox(env); - - // GET /latest → 最新一封完整郵件 - if (url.pathname === "/latest") { - return mails.length ? json(mails[0]) : json({ error: "no mail" }, 404); - } - - // GET /mails?limit=10 → 最近 N 封列表(不含正文) - if (url.pathname === "/mails" && request.method === "GET") { - const limit = Math.min( - parseInt(url.searchParams.get("limit") ?? "10"), - MAX_MAILS - ); - const list = mails - .slice(0, limit) - .map(({ id, receivedAt, from, to, subject, attachments }) => ({ - id, - receivedAt, - from, - to, - subject, - attachments, - })); - return json(list); - } - - // DELETE /mails → 清空收件匣 - if (url.pathname === "/mails" && request.method === "DELETE") { - await env.MAIL_KV.delete(STORAGE_KEY); - return json({ ok: true }); - } - - // GET /mail/:id → 單封完整內容 - const match = url.pathname.match(/^\/mail\/(.+)$/); - if (match) { - const found = mails.find((x) => x.id === match[1]); - return found ? json(found) : json({ error: "not found" }, 404); - } - - return json({ error: "unknown route" }, 404); - }, -}; - -// ─── 工具函數 ───────────────────────────────────────────────────── -async function loadInbox(env) { - const raw = await env.MAIL_KV.get(STORAGE_KEY); - return raw ? JSON.parse(raw) : []; -} - -function json(data, status = 200) { - return new Response(JSON.stringify(data, null, 2), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -async function streamToArrayBuffer(stream) { - const reader = stream.getReader(); - const chunks = []; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - } - const total = chunks.reduce((n, c) => n + c.byteLength, 0); - const buf = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - buf.set(chunk, offset); - offset += chunk.byteLength; - } - return buf.buffer; -} +export { default, MailboxDO } from "./src/index.ts"; diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..5bd5162 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,39 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "ni-mail", + "main": "src/index.ts", + "compatibility_date": "2026-04-17", + "compatibility_flags": [ + "nodejs_compat" + ], + "observability": { + "enabled": true + }, + "vars": { + "DOMAINS": "", + "DEFAULT_MAILBOX": "" + }, + "r2_buckets": [ + { + "binding": "BUCKET", + "bucket_name": "ni-mail-attachments", + "preview_bucket_name": "ni-mail-attachments-preview" + } + ], + "durable_objects": { + "bindings": [ + { + "name": "MAILBOX", + "class_name": "MailboxDO" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": [ + "MailboxDO" + ] + } + ] +}