mirror of
https://github.com/mskatoni/ni-mail.git
synced 2026-09-05 15:37:09 +08:00
Merge pull request #3 from mskatoni/ni-mail-R2-beta
Add files via upload
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
AUTH_KEY=replace-with-a-long-random-string
|
||||||
|
DOMAINS=example.com,example.net
|
||||||
|
DEFAULT_MAILBOX=
|
||||||
+8
-3
@@ -1,3 +1,8 @@
|
|||||||
node_modules/
|
node_modules
|
||||||
.wrangler/
|
.wrangler
|
||||||
dist/
|
.dev.vars
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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://<your-worker-or-domain> AUTH_KEY=<your-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://<your-worker>/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "service": "ni-mail" }
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
Generated
+1517
File diff suppressed because it is too large
Load Diff
+23
-8
@@ -1,16 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "mail-worker",
|
"name": "ni-mail",
|
||||||
"version": "1.0.0",
|
"version": "2.0.1",
|
||||||
"description": "Minimal Cloudflare Worker for receiving and reading emails via API",
|
"private": false,
|
||||||
"main": "src/worker.js",
|
"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": {
|
"scripts": {
|
||||||
|
"dev": "wrangler dev",
|
||||||
"deploy": "wrangler deploy",
|
"deploy": "wrangler deploy",
|
||||||
"dev": "wrangler dev"
|
"check:deploy": "wrangler deploy --dry-run",
|
||||||
|
"smoke": "node scripts/examples.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"postal-mime": "^2.2.9"
|
"postal-mime": "^2.6.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"wrangler": "^3.0.0"
|
"wrangler": "4.83.0"
|
||||||
}
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"cloudflare",
|
||||||
|
"workers",
|
||||||
|
"email",
|
||||||
|
"durable-objects",
|
||||||
|
"sqlite",
|
||||||
|
"r2"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Vendored
+27
@@ -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<string, unknown>);
|
||||||
|
parse(input: ArrayBuffer | Uint8Array | string): Promise<PostalMimeResult>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "cloudflare:workers" {
|
||||||
|
export class DurableObject {
|
||||||
|
constructor(state: any, env: any);
|
||||||
|
}
|
||||||
|
}
|
||||||
+230
@@ -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<Response> {
|
||||||
|
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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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<void> {
|
||||||
|
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<Uint8Array>;
|
||||||
|
setReject(reason: string): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function proxyToMailbox(env: Env, mailboxId: string, path: string, init?: RequestInit): Promise<Response> {
|
||||||
|
const stub = mailboxStub(env, mailboxId);
|
||||||
|
const request = new Request(`https://mailbox.internal${path}`, init);
|
||||||
|
return await stub.fetch(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeError(error: unknown): Record<string, unknown> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
+1001
File diff suppressed because it is too large
Load Diff
+155
@@ -0,0 +1,155 @@
|
|||||||
|
export type SqlCursorRow = object;
|
||||||
|
|
||||||
|
export interface SqlStorage {
|
||||||
|
exec<T extends SqlCursorRow = SqlCursorRow>(query: string, ...bindings: unknown[]): Iterable<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DurableObjectStateLike {
|
||||||
|
storage: {
|
||||||
|
sql: SqlStorage;
|
||||||
|
};
|
||||||
|
blockConcurrencyWhile<T>(callback: () => Promise<T> | T): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DurableObjectIdLike {
|
||||||
|
toString(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DurableObjectStubLike {
|
||||||
|
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DurableObjectNamespaceLike<T = unknown> {
|
||||||
|
idFromName(name: string): DurableObjectIdLike;
|
||||||
|
get(id: DurableObjectIdLike): DurableObjectStubLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface R2ObjectBodyLike {
|
||||||
|
body: ReadableStream<Uint8Array> | null;
|
||||||
|
size: number;
|
||||||
|
httpMetadata?: {
|
||||||
|
contentType?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface R2BucketLike {
|
||||||
|
put(
|
||||||
|
key: string,
|
||||||
|
value: ArrayBuffer | Uint8Array | string,
|
||||||
|
options?: {
|
||||||
|
httpMetadata?: {
|
||||||
|
contentType?: string;
|
||||||
|
};
|
||||||
|
customMetadata?: Record<string, string>;
|
||||||
|
},
|
||||||
|
): Promise<void>;
|
||||||
|
get(key: string): Promise<R2ObjectBodyLike | null>;
|
||||||
|
delete(keys: string | string[]): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string>;
|
||||||
|
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[];
|
||||||
|
}
|
||||||
+207
@@ -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<T>(request: Request): Promise<T> {
|
||||||
|
return request.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureArray<T>(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<string>([
|
||||||
|
...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(/<style[\s\S]*?<\/style>/gi, " ")
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/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) => `<blockquote>${escapeHtml(line || " ")}</blockquote>`)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -1,115 +1 @@
|
|||||||
import PostalMime from "postal-mime";
|
export { default, MailboxDO } from "./src/index.ts";
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user