diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..acd705d --- /dev/null +++ b/CLAUDE.md @@ -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. + + + +# 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. + +