8.9 KiB
8.9 KiB
Repository Guidelines
Project Structure
src/index.ts—WorkerEntrypointshell:fetch()→OAuthProvider(@cloudflare/workers-oauth-provider; implements/oauth/token,/oauth/register,/.well-known/*, stores tokens inOAUTH_KV) → Hono app.Bearer aik_requests to/mcpbypass the provider straight to Hono, and the provider is skipped entirely when the optionalOAUTH_KVbinding is absent (OAuth disabled).email()→ email handler,rpcEmail()RPC method.src/app.ts— Hono app: route mounting, auth middleware boundaries, SPA fallback viaASSETS.src/routes/— REST API:auth.ts(/api/auth: setup/login/logout/me),mails.ts(/api/mails),admin.ts(/api/admin: users + grants, admin only),keys.ts(/api/keys: self-service API keys),oauth.ts(/oauth/authorize: OAuth consent page for claude.ai remote connectors).src/middleware/auth.ts—sessionAuth(JWT cookieauthinbox_session),requireAdmin,apiKeyAuth(Beareraik_…keys),oauthPropsAuth(OAuth token props → D1 user lookup).src/services/—mail.ts(visibleMails— the single permission-filtered query layer),auth.ts(PBKDF2 / JWT / API keys via WebCrypto),classify.ts(callProviderLLM extraction + category),mime.ts(MIME decoding, promotional filter).src/email/handler.ts— incoming email pipeline;src/email/rpcEmail.ts— RPC-compatibleForwardableEmailMessagewrapper.src/mcp/server.ts— remote MCP server at/mcp(@hono/mcpStreamable HTTP):list_addresses,list_codes,get_latest_code,wait_for_code.web/— React 18 + Vite + Tailwind + shadcn/ui frontend. Built toweb/dist, served via CloudflareASSETSbinding. Pages: Login (doubles as first-admin setup), Inbox, API Keys, Users & Access (admin).migrations/— D1 schema, applied withwrangler d1 migrations apply. Tables:raw_mails,code_mails(+category),users,api_keys,grants.test/—*.test.tsunit tests (workers pool).test/mail/*.mail.tsreal-mail regression suite (node env,vitest.mail.config.mts):pipeline.mail.tsasserts promo-filter verdicts and LLM-input content deterministically;classify.mail.tsruns the live LLM eval (EVAL_LLM=1). Fixtures come from your own D1 viascripts/pull-fixtures.mjsinto gitignoredtest/fixtures/*.eml; expectations live in the committedtest/fixtures/manifest.json.
Commands
# Install
pnpm install # root (Worker) deps; pnpm workspace includes web/
# Dev
pnpm run dev # wrangler dev — local Worker on :8787
pnpm run dev:web # Vite dev server on :5173 (proxies /api → :8787)
# Database
pnpm run db:migrate:local # apply D1 migrations locally
pnpm run db:migrate:remote # apply D1 migrations to live D1
# Build & deploy
pnpm run build:web # vite build → web/dist
pnpm run deploy # build:web + db:migrate:remote + wrangler deploy
# Misc
pnpm run test # unit tests (vitest with @cloudflare/vitest-pool-workers)
pnpm run fixtures:pull # pull real-mail fixtures from remote D1 into test/fixtures/*.eml (gitignored)
pnpm run test:mail # deterministic pipeline regression on real-mail fixtures (no LLM, free)
pnpm run test:eval # LLM classification eval on fixtures (real provider calls; EVAL_RUNS=n, default 5)
pnpm run cf-typegen # regenerate Worker type bindings
Local setup:
cp wrangler.toml.example wrangler.toml # fill in database_id + AI config (+ optional OAUTH_KV id for OAuth)
echo 'JWT_SECRET=any-local-dev-secret' > .dev.vars # local session signing (gitignored)
pnpm run db:migrate:local
pnpm run build:web
pnpm run dev
Architecture Constraints
- Promotional filter runs before LLM.
isPromotionalEmail()treatsList-ID/List-Post,Precedence: bulk/list, and known ESP campaign X-headers as unconditional bulk signals.List-Unsubscribealone is a weak signal: transactional-looking subjects (code/verify/OTP/sign-in/reset/验证码 …) are exempted, because Gmail/Yahoo bulk-sender rules force senders like Netflix/SES to add it to verification mail too. If matched, raw email is still saved toraw_mailsbut LLM is skipped. - Two DB tables, strict separation. Every email →
raw_mails. Only emails with extracted codes/links →code_mails. Never skipraw_mailsinsert. visibleMails()insrc/services/mail.tsis the only permission layer. REST routes and MCP tools must query mails through it — never add a second filter path. Grants are enforced in SQL (GLOB address pattern + category allowlist).- Sensitive categories are default-deny.
password_reset,account_securityrequireallow_sensitive = 1on the grant;legacy(pre-v2 mails) is always admin-only. Raw email bodies (raw/textBody/htmlBody) are admin-only on every interface. - Auth boundaries live in middleware.
/api/*behindsessionAuth(except/api/authpublic endpoints),/api/admin/*additionally behindrequireAdmin./mcpaccepts two credentials:aik_keys behindapiKeyAuth(routed insrc/index.tsbefore the provider) and OAuth access tokens validated byOAuthProviderthen resolved byoauthPropsAuth. Static assets are intentionally public; data only flows through authed APIs. ASSETSis required for the frontend. The Worker serves the React app from theASSETSbinding (name must beASSETS); there is no server-rendered fallback.web/distis gitignored. Built at deploy time viapnpm run deploy. No need to commit build artifacts.- Schema changes go through
migrations/as new numbered files; never edit applied migrations.
Auth Model
- Web: username/password → PBKDF2-SHA256 (100k iterations, WebCrypto) → JWT (HS256, 7-day exp) in HttpOnly cookie
authinbox_session. Signing key is theJWT_SECRETCloudflare secret. - First admin:
POST /api/auth/setupworks only while theuserstable is empty (the login page automates this). - MCP / programmatic:
Authorization: Bearer aik_…API keys; only the SHA-256 hash is stored, plaintext shown once at creation. Keys inherit the owner's role and grants. - MCP / OAuth (claude.ai remote connectors, optional feature): enabled only when the
OAUTH_KVKV namespace is bound (binding name is mandated by the library; without it/oauth/*returns 404 and/mcpaccepts onlyaik_keys). OAuth 2.1 + PKCE + dynamic client registration via@cloudflare/workers-oauth-provider; tokens/grants/clients live inOAUTH_KV. The consent page at/oauth/authorizereuses the session cookie — unauthenticated visitors are bounced through the SPA login withreturnTo. Grant props store onlyuserId;oauthPropsAuthre-fetches the user from D1 on every request, so deleting a user instantly revokes OAuth access.
AI Provider Configuration
The AI layer uses a single unified function callProvider(config: ProviderConfig, prompt) in src/services/classify.ts. All provider-specific logic is contained there — do not add new per-provider methods. The extraction prompt also assigns a category (login_code | registration | password_reset | account_security | payment | other); invalid output coerces to other.
Env vars (set in wrangler.toml [vars] or as Cloudflare Secrets):
| Variable | Required | Description |
|---|---|---|
AI_BASE_URL |
✅ | Provider base URL, no trailing slash |
AI_API_KEY |
✅ | API key (use Secret in production) |
AI_API_FORMAT |
✅ | openai | responses | anthropic |
AI_MODEL |
✅ | Model ID |
JWT_SECRET |
✅ (Secret) | Session JWT signing key |
AI_FALLBACK_BASE_URL |
optional | Fallback provider base URL |
AI_FALLBACK_API_KEY |
optional | Fallback API key |
AI_FALLBACK_API_FORMAT |
optional | Fallback format |
AI_FALLBACK_MODEL |
optional | Fallback model ID |
UseBark / barkUrl / barkTokens |
optional | Bark iOS push |
Fallback is only active when all four AI_FALLBACK_* vars are set. Primary retries 3× before fallback is attempted (1×).
Format → endpoint mapping:
openai→POST /v1/chat/completions(OpenAI, Gemini OpenAI-compat, DeepSeek, Groq, …)responses→POST /v1/responses(OpenAI Responses API)anthropic→POST /v1/messages(Anthropic Claude direct; sendsanthropic-version: 2023-06-01,max_tokens: 1024)
Coding Style
- Tabs, LF, UTF-8, no trailing whitespace (see
.editorconfig). - Prettier: single quotes, semicolons, print width 140.
PascalCaseclasses/types,camelCasefunctions/variables,UPPER_SNAKE_CASEconstants.
Security
- Never commit
wrangler.tomlwith real secrets;JWT_SECRETandAI_API_KEYbelong in Cloudflare Secrets. - Email HTML preview uses DOMPurify + sandboxed iframe. Do not relax the sandbox.
- Do not log or return API key plaintext after creation; do not expose
rawbodies to non-admin roles.
Commit Style
Conventional Commits: feat:, fix:, docs:, chore:. Keep commits atomic.