docs: add CLAUDE.md
Notes the architecture that takes several files to piece together: worker.ts as the real entrypoint, the two routing rule scopes, webhook retries riding the outbound queue, the dual-migration requirement in src/lib/setup/migration.ts, and the requireUser/500 and DialogContent overflow gotchas. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PGeWDVSkXtVEmDAN2AtB9h
This commit is contained in:
1 parent
caf48d7f07
commit
9dcdce309f
1 file changed
+116
@@ -0,0 +1,116 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # next dev (Cloudflare bindings via initOpenNextCloudflareForDev)
|
||||
npm run lint # eslint (next/core-web-vitals + next/typescript)
|
||||
npm run build # next build only — does NOT produce the deployable Worker
|
||||
|
||||
npm run db:generate # drizzle-kit generate from src/db/schema/index.ts
|
||||
npm run db:migrate:local # wrangler d1 migrations apply DB --local
|
||||
npm run db:migrate:remote # --remote (needs a concrete database_id in wrangler.jsonc)
|
||||
npm run db:seed # POST /api/seed against localhost:3000
|
||||
|
||||
npm run deploy # opennextjs-cloudflare build + wrangler deploy
|
||||
npm run deploy:with-migrations # remote migrations, then deploy
|
||||
npm run preview # local OpenNext preview
|
||||
npm run cf-typegen # regenerate cloudflare-env.d.ts from wrangler.jsonc
|
||||
```
|
||||
|
||||
There is no test suite and no test runner configured.
|
||||
|
||||
`next.config.ts` sets `typescript.ignoreBuildErrors: true` and `tsconfig.json` sets `noImplicitAny: false`, so the build will not catch type errors. Run `npx tsc --noEmit` if you want real type checking.
|
||||
|
||||
Do not deploy with `opennextjs-cloudflare deploy`. The deploy script deliberately builds with OpenNext and uploads with Wrangler because `worker.ts` — not the generated Next worker — is the entrypoint.
|
||||
|
||||
## Architecture
|
||||
|
||||
Next.js 16 App Router running on Cloudflare Workers via OpenNext. Drizzle ORM over D1, R2 for raw MIME and attachments, Queues for async mail processing, a Durable Object for realtime, and a Workflow for backups.
|
||||
|
||||
### worker.ts is the entrypoint
|
||||
|
||||
`worker.ts` wraps the generated `.open-next/worker.js` and adds handlers Next.js cannot express:
|
||||
|
||||
- **`fetch`** — intercepts `/api/realtime` for the WebSocket upgrade (authenticates the session cookie, then routes to `env.REALTIME.getByName(user.id)`), delegating everything else to Next.
|
||||
- **`email`** — the Cloudflare Email Routing handler. Resolves domain routing rules first (`resolveIncomingMail` in `src/lib/email/incoming.ts`) because `message.setReject()` and `message.forward()` only exist here, then applies optional account-level forwarding (loop-guarded by the `MAILFLARE_FORWARDED_HEADER`), writes raw MIME to R2, and enqueues to `INBOUND_QUEUE`. It never parses mail inline.
|
||||
- **`queue`** — a single consumer for both queues; `isInboundQueueMessage` and `isWebhookRetryMessage` in `worker-utils.ts` discriminate inbound mail, webhook retries, and outbound payloads. Failures `retry({ delaySeconds: 10 })`.
|
||||
|
||||
It also re-exports `RealtimeHub` and `DatabaseBackupWorkflow`, which is why those classes must live outside the Next build.
|
||||
|
||||
### Mail pipeline
|
||||
|
||||
Inbound: `email` handler → R2 → queue → `processInboundMessage` (`src/lib/email/inbound.ts`) → `resolveInboundAddress` routing decision (deliver / reject / forward) → `parseRawMime` (postal-mime) → insert message + attachments → upsert contacts → `dispatchWebhooks` → `notifyUsersOfNewMessage` over the Durable Object.
|
||||
|
||||
Outbound: `src/lib/email/send.ts` / `sender.ts`, composing with mimetext and sending through the `EMAIL` send_email binding, with `outbound_jobs` rows tracking queued sends.
|
||||
|
||||
### Routing rules have two scopes
|
||||
|
||||
`routing_rules.scope` splits two genuinely different mechanisms, and mixing them up is the easy mistake:
|
||||
|
||||
- **`domain`** — evaluated by `resolveInboundAddress` (`src/lib/email/routing.ts`) *while resolving the address*, in three phases: `reject` rules first (so a sender can be blocked even when the recipient is a real mailbox), then exact mailbox and alias lookup, then `forward`/`store` catch-all fallbacks. Ordered by descending `priority`, then oldest first. This phase split is what stops a `*` catch-all from shadowing real mailboxes — preserve it.
|
||||
- **`mailbox`** — evaluated by `resolveInboxRuleDestination` *after* delivery, to pick a folder or move to spam/trash.
|
||||
|
||||
Both queries filter on `scope`, so any new rule must set it explicitly. `forward` and `reject` are actioned in `worker.ts`, never in the queue consumer.
|
||||
|
||||
### Webhook retries ride the outbound queue
|
||||
|
||||
`src/lib/email/webhooks.ts` records every attempt (status, error snippet, duration, `nextRetryAt`) on `webhook_deliveries` and re-enqueues failures onto `OUTBOUND_QUEUE` with a `delaySeconds` backoff rather than adding a third queue binding. `env.d.ts` widens `OUTBOUND_QUEUE` to the union of both payload types accordingly. `runDelivery` is shared by the retry queue and the manual retry endpoint.
|
||||
|
||||
### Cloudflare is a live dependency, not just a host
|
||||
|
||||
Domain and mailbox management call the Cloudflare API at runtime (`src/lib/cloudflare-api.ts`, `src/lib/domains/`). Adding a domain enables Email Routing DNS and sending subdomains on the zone; creating a mailbox creates a Cloudflare Email Routing rule targeting `CF_EMAIL_WORKER_NAME`; removing a domain cleans those up (`src/lib/domains/cloudflare-cleanup.ts`).
|
||||
|
||||
Consequence: `CF_EMAIL_WORKER_NAME`, the deployed Worker `name`, and `services[].service` for `WORKER_SELF_REFERENCE` in `wrangler.jsonc` must all agree. Cloudflare service bindings need a literal name and cannot reference the top-level `name`.
|
||||
|
||||
Auth is `CF_TOKEN` (preferred) or the legacy `CF_EMAIL` + `CF_API_KEY` pair.
|
||||
|
||||
### Schema and the dual-migration gotcha
|
||||
|
||||
Schema lives in one file: `src/db/schema/index.ts` (21 tables). Migrations are generated into `drizzle/migrations/`. Note that `drizzle-kit generate` currently prompts interactively about a snapshot rename conflict, so recent migrations were hand-written to match the generated style.
|
||||
|
||||
**`src/lib/setup/migration.ts` duplicates the entire schema as inline SQL.** `/api/setup/prepare` uses it to bootstrap an empty D1 database in one batch, then inserts every migration name into `d1_migrations` so Wrangler treats them as applied. When you add a migration you must update both places: run `db:generate`, then add the new DDL to `INITIAL_SCHEMA_SQL` and the filename to `MIGRATION_NAMES`. Omitting a name from `MIGRATION_NAMES` leaves that migration pending for a later Wrangler apply (this is how `0013_add_license_settings.sql` is currently handled).
|
||||
|
||||
The setup path only ever initializes an empty database — it refuses to touch one that already has tables.
|
||||
|
||||
### Access control
|
||||
|
||||
Two independent auth surfaces:
|
||||
|
||||
- **Session cookie** (`ep_session`) — `getCurrentUser` / `requireUser` in `src/lib/auth/cookies.ts`, backed by `src/lib/auth/session.ts`. Used by dashboard/admin API routes. `requireUser` *throws*, which Next surfaces as a 500; prefer `requireSessionUser` from `src/lib/api/auth.ts`, which returns a proper 401 response. Most older routes still use `requireUser` and 500 on unauthenticated requests.
|
||||
- **API key bearer token** — `authenticateApiKey` + `requireScope` in `src/lib/api/auth.ts`, used by the public `/api/v1/*` surface.
|
||||
|
||||
Mailbox authorization is separate from user role and goes through `src/lib/mailboxes/access.ts` (`getMailboxAccessLevel`, `listAccessibleMailboxes`, `listAccessibleMailboxIds`), which accounts for ownership, the `mailbox_access` sharing table, and admin role. Message queries scope by accessible mailbox IDs, not by `userId` — see `src/app/api/messages/route.ts` for the canonical pattern.
|
||||
|
||||
### Folders are mostly virtual
|
||||
|
||||
`messages.status` is a free-text column driving the folder views: `received` (inbox), `sent`, `draft`, `spam`, `trash`, `archived`. Orthogonal to that are `starred`, `snoozedUntil`, and `folderId` (user-created folders in the `folders` table). A "folder" route under `src/app/(dashboard)/` is usually a status filter, not a table.
|
||||
|
||||
### Licensing gates branding
|
||||
|
||||
Pro/Team keys are validated against Paymug (`src/lib/licenses/`); only a one-way key hash is stored. Without an active license the app falls back to the default name, icon, and favicon, and custom branding is unavailable. `getLicenseEntitlements` is the gate.
|
||||
|
||||
### Self-update
|
||||
|
||||
The admin overview dispatches `deploy-update.yml` (constant in `src/app/api/admin/update/utils.ts`) in the installation repo, which merges the upstream default branch and applies D1 migrations. It does not build or deploy. The README refers to this workflow as `update.yml`; the code is authoritative.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Tabs for indentation. `@/*` maps to `src/*`.
|
||||
- Types and pure helpers are split out of components and modules into sibling `*-types.d.ts` and `*-utils.ts` files (41 and 27 of them respectively). Follow this when adding anything non-trivial.
|
||||
- Server code reaches bindings through `getEnv()` / `getEnvAsync()` in `src/lib/cloudflare.ts`, then `getDb(env)` from `src/db`. Never import `getCloudflareContext` directly.
|
||||
- API routes return `NextResponse.json({ error: "..." }, { status })` for failures; there is no shared error envelope helper.
|
||||
- UI is Tailwind v4 + shadcn/Radix primitives in `src/components/ui/`. `DialogContent` sets no max height, so a tall dialog overflows the viewport with an unreachable submit button — add `max-h-[calc(100vh-4rem)] overflow-y-auto` on any dialog with more than a few fields.
|
||||
- `cloudflare-env.d.ts` is generated (500KB) — regenerate with `cf-typegen`, never hand-edit.
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
Reference in new issue
Block a user