Merge pull request #3 from mskatoni/ni-mail-R2-beta

Add files via upload
This commit is contained in:
mskatoni
2026-04-28 17:26:04 +08:00
committed by GitHub
17 changed files with 3414 additions and 126 deletions

3
.dev.vars.example Normal file
View File

@@ -0,0 +1,3 @@
AUTH_KEY=replace-with-a-long-random-string
DOMAINS=example.com,example.net
DEFAULT_MAILBOX=

11
.gitignore vendored
View File

@@ -1,3 +1,8 @@
node_modules/
.wrangler/
dist/
node_modules
.wrangler
.dev.vars
.env
.env.*
dist
coverage
.DS_Store

7
.npmrc Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

155
src/types.ts Normal file
View 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
View 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(/&nbsp;/g, " ")
.replace(/&amp;/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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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
View 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
View File

@@ -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
View 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"
]
}
]
}