mirror of
https://github.com/mskatoni/ni-mail.git
synced 2026-05-06 20:02:56 +08:00
Merge pull request #3 from mskatoni/ni-mail-R2-beta
Add files via upload
This commit is contained in:
3
.dev.vars.example
Normal file
3
.dev.vars.example
Normal file
@@ -0,0 +1,3 @@
|
||||
AUTH_KEY=replace-with-a-long-random-string
|
||||
DOMAINS=example.com,example.net
|
||||
DEFAULT_MAILBOX=
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,3 +1,8 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
dist/
|
||||
node_modules
|
||||
.wrangler
|
||||
.dev.vars
|
||||
.env
|
||||
.env.*
|
||||
dist
|
||||
coverage
|
||||
.DS_Store
|
||||
|
||||
7
.npmrc
Normal file
7
.npmrc
Normal file
@@ -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
|
||||
70
CLOUDFLARE_BUILDS.md
Normal file
70
CLOUDFLARE_BUILDS.md
Normal file
@@ -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" }
|
||||
```
|
||||
13
THIRD_PARTY_NOTICES.md
Normal file
13
THIRD_PARTY_NOTICES.md
Normal file
@@ -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.
|
||||
1517
package-lock.json
generated
Normal file
1517
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
31
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"
|
||||
}
|
||||
|
||||
58
scripts/examples.mjs
Normal file
58
scripts/examples.mjs
Normal file
@@ -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;
|
||||
}
|
||||
40
scripts/examples.sh
Normal file
40
scripts/examples.sh
Normal file
@@ -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
|
||||
27
src/global.d.ts
vendored
Normal file
27
src/global.d.ts
vendored
Normal file
@@ -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
src/index.ts
Normal file
230
src/index.ts
Normal file
@@ -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
src/mailbox-do.ts
Normal file
1001
src/mailbox-do.ts
Normal file
File diff suppressed because it is too large
Load Diff
155
src/types.ts
Normal file
155
src/types.ts
Normal file
@@ -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
src/utils.ts
Normal file
207
src/utils.ts
Normal file
@@ -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),
|
||||
};
|
||||
}
|
||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
116
worker.js
116
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";
|
||||
|
||||
39
wrangler.jsonc
Normal file
39
wrangler.jsonc
Normal file
@@ -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