Skip to content

feat(imap-cloudflare): Spectrum TCP bridge for native IMAP clients#77

Closed
ssilvius wants to merge 4 commits intomainfrom
feat/imap-server-notify
Closed

feat(imap-cloudflare): Spectrum TCP bridge for native IMAP clients#77
ssilvius wants to merge 4 commits intomainfrom
feat/imap-server-notify

Conversation

@ssilvius
Copy link
Copy Markdown
Contributor

Summary

  • Adds createTcpBridge() to @rafters/mail-imap-cloudflare (src/tcp-bridge.ts)
  • Handles pre-login IMAP phase (CAPABILITY, NOOP, LOGOUT) directly in the Worker
  • Routes on first LOGIN command to the correct Durable Object via WebSocket, then pipes all subsequent traffic transparently
  • Exports ./tcp-bridge subpath from package.json; added to tsup entry points
  • Adds Spectrum deployment notes to wrangler.jsonc

Architecture

Transport-only bridge. The DO handles all protocol parsing and credential verification. The bridge extracts the email address from LOGIN solely to identify which DO instance to route to (idFromName(email)). mailboxId passed to the DO equals the email address.

Note on file location: Issue #57 spec listed packages/imap/src/transport/tcp-bridge.ts, but the Socket type and DurableObjectNamespace binding are Cloudflare-specific. Placed in packages/imap-cloudflare per the zero-vendor-deps rule for core.

Test plan

  • extractLoginEmail -- atom and quoted string parsing, edge cases (RFC 3501 Section 6.2.3)
  • LineReader -- line framing, CRLF splitting, oversized line rejection, pipelined commands after LOGIN
  • createTcpBridge -- shape and options
  • pnpm test -- 18/18 pass
  • oxlint -- 0 warnings, 0 errors
  • oxfmt --check -- clean on changed files
  • tsc --noEmit -- clean

Closes #57

🤖 Generated with Claude Code

ssilvius and others added 4 commits April 11, 2026 04:06
…erver

The three IMAP packages shipped without the tsup + dist exports setup that
the other six packages use. Their package.json exports pointed at src/*.ts,
blocking npm publish and breaking consumers who don't run TypeScript through
a bundler. Platform is waiting on these builds.

- Add tsup.config.ts to each of the three packages (esm, dts, clean).
- Update package.json exports to point at dist/*.js with matching subpaths.
- Update files field to ship dist/ only.
- Add tsup to devDependencies.
- Add all three packages to the initial-release changeset so they ship
  alongside the other six at 0.1.0.

Side fixes landed alongside the build plumbing:

- imap-cloudflare: convert 8 private members of the class expression
  returned from createImapDurableObject to JavaScript #private fields.
  TypeScript cannot emit declarations for private/protected members of
  anonymous-type class expressions (TS4094), so dts generation failed.
  Hard-private fields have equivalent encapsulation with a declaration
  -emittable shape.

- imap-cloudflare: remove unused @rafters/mail dependency. The package
  only imports from @rafters/mail-imap.

Verification:
- pnpm -r build: all 9 packages build ESM + dts
- pnpm test: 609 passing / 37 files
- pnpm typecheck: clean
- pnpm lint: 0 warnings, 0 errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ackages

Adds a README.md in every package so the npm package page renders
properly instead of "no readme available". Adds "license": "MIT" to
every package.json so the license badge links via npm's SPDX lookup
to the MIT text (the actual LICENSE file lives in the org .github
repo, not this repo).

READMEs cover per-package scope, install command, minimal usage
examples, subpath export table where applicable, and a link back to
the monorepo README for the wider architecture. Each one starts with
a single-paragraph identity statement so the npm consumer sees what
the package is for within three seconds of landing.

Files added (9):
- packages/core/README.md
- packages/imap/README.md
- packages/imap-cloudflare/README.md
- packages/imap-server/README.md
- packages/resend/README.md
- packages/cloudflare/README.md
- packages/react-email/README.md
- packages/workers-ai/README.md
- packages/better-auth-resend/README.md

Files modified (9):
- all 9 package.json files -- add "license": "MIT" after description

Verification:
- pnpm publish --dry-run on all 9 packages shows README.md in the
  tarball contents and the correct file count bump (+1 per package).

CHANGELOGs are not hand-written -- they are auto-generated by the
pnpm changeset version step from the pending initial-release.md
changeset during the release flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Every package now has its own docs directory with topic-scoped
documentation, shipped in the npm tarball via files: ["dist", "docs"].
Consumers landing on an npm package page see real documentation, not
just a one-line README.

## Per-package docs

- **core** (5 docs):
  - reference.md (51.5 kB) -- complete schema and API reference for
    all 13 tables, service interfaces, Zod schemas, design decisions
  - threading.md -- RFC 5322 threading via In-Reply-To / References
  - migrations.md -- database migration workflow
  - newsletters.md -- mailing lists, subscribers, campaigns
  - adapters.md -- how adapters connect core to external services

- **imap** (2 docs):
  - commands.md -- full IMAP4rev1 command reference with RFC section
    citations
  - authentication.md -- AuthAdapter contract and consumer ownership

- **imap-server** (2 docs):
  - quickstart.md -- zero-to-running IMAP server
  - deployment.md -- Fly, Railway, Fargate, Docker, VPS per-platform
    guides with TLS handling

- **imap-cloudflare** (1 doc):
  - deployment.md -- Durable Object setup, bindings, cost model

- **resend** (1 doc):
  - outbound.md -- compose, send, webhook handling, audit

- **cloudflare** (3 docs):
  - quickstart.md -- Workers + D1 + R2 + Email Routing from zero
  - inbound.md -- parsing, dedupe, blob storage, thread matching
  - blob-storage.md -- R2 adapter, key schema, retrieval

- **react-email** (1 doc, newly written):
  - templates.md -- BaseEmail, OtpEmail, custom templates, renderer

- **workers-ai** (1 doc):
  - classification.md -- categories, priority derivation, tag patterns

- **better-auth-resend** (1 doc, newly written):
  - usage.md -- full better-auth integration, configuration, and how
    to replace the default template

Plus repo-level overview docs that do not ship with any package:
- docs/architecture.md -- 580 lines of internal design reference
- docs/getting-started.md -- 397 lines of cross-package quickstart

## Source

Most docs were copied from vault-2026/projects/rafters/mail-docs/
(the 16 docs written during the IMAP phase but never integrated into
the package tree). The two new ones (react-email/docs/templates.md,
better-auth-resend/docs/usage.md) were written fresh because vault
had nothing allocated to those packages.

## Shipping

Every package.json files field updated to ["dist", "docs"]. Every
README gained a Documentation section listing the local docs with
descriptions.

Verified via pnpm publish --dry-run on all 9 packages: each tarball
now includes the docs/ tree and the total file count reflects the
additions.

| Package               | Docs files | Tarball size | Total files |
|-----------------------|-----------:|-------------:|------------:|
| @rafters/mail         |          5 |     47.6 kB  |          34 |
| @rafters/mail-imap    |          2 |     24.9 kB  |          36 |
| @rafters/mail-imap-server  |     2 |      8.0 kB  |           6 |
| @rafters/mail-imap-cloudflare | 1 |      7.4 kB  |          11 |
| @rafters/mail-resend  |          1 |      8.5 kB  |          13 |
| @rafters/mail-cloudflare |       3 |      7.6 kB  |          13 |
| @rafters/mail-react-email |      1 |      5.0 kB  |          14 |
| @rafters/mail-workers-ai |       1 |      5.4 kB  |           8 |
| @rafters/better-auth-resend |    1 |      3.1 kB  |           5 |

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…getter

Adds a public notify method to ImapServer so inbound email handlers can
push EXISTS responses to IDLE clients bound to a specific mailbox.
Matches the feature parity of @rafters/mail-imap-cloudflare, which has
an equivalent inbound signal via POST /notify?count=N on its Durable
Object. Until now, the Node runtime had no way to wake IDLE clients
when new mail arrived; the docs promised a "notification mechanism"
that did not exist.

## API

```typescript
interface ImapServer {
  listen(): Promise<void>;
  close(): Promise<void>;
  readonly connections: number;
  readonly port: number | null;
  notify(mailboxId: string, newMessageCount: number): void;
}
```

- `notify(mailboxId, newMessageCount)` delivers an EXISTS response
  (RFC 2177 IDLE push) to every session that is (a) currently in IDLE
  state and (b) bound to the specified mailbox. Sessions on other
  mailboxes and sessions not in IDLE are not affected. newMessageCount
  is the total for the mailbox after the insertion, not a delta --
  IMAP clients read the EXISTS value as the new total.
- `port` returns the actual bound port after listen() resolves. Useful
  when the server starts on port 0 (ephemeral) and the caller needs to
  learn the assigned port. Critical for test harnesses that spin up
  throwaway servers.

## Implementation

The internal connection bookkeeping switched from a counter
(`let activeConnections = 0`) to a typed Set of `ConnectionEntry`
records (`{ socket, state }`). The switch is necessary to iterate
over connections inside notify() and filter by mailboxId + idleState.
The connections getter still returns the size of the Set, so
externally the API surface of that field is unchanged.

## Tests

5 new tests in tests/server.test.ts (total for this file: 10):

- surface check: notify is a function on the returned server
- calling notify with no active connections does not throw
- calling notify with count=0 does not throw
- end-to-end (real TCP socket): notify delivers "* <n> EXISTS" to a
  client that is authenticated, selected, and in IDLE, with matching
  mailboxId
- end-to-end: notify does NOT deliver to a client bound to a
  different mailbox (via a custom resolveMailboxId)
- end-to-end: notify does NOT deliver to a client that is not in IDLE
  state (authenticated and selected, but no IDLE issued)

The end-to-end tests open a real net.Socket against a locally-bound
plain-TCP server (no TLS, simulates TLS-terminating proxy mode) and
drive the IMAP protocol through LOGIN, SELECT, IDLE, then call
notify() from outside the session and assert the client either
receives the EXISTS or does not within a timeout window.

## Documentation fixes

Three real errors surfaced while reading the copied docs:

1. `packages/imap-server/docs/quickstart.md` -- the "App passwords"
   section prescribed "store hashed (argon2) in your database".
   That prescription contradicts the rewritten authentication
   contract (interface-only, consumer owns credential storage and
   hashing). Replaced with a section that points at the
   authentication.md doc and makes the interface-only posture
   explicit.

2. `packages/imap-server/docs/quickstart.md` -- the "IDLE" section
   said "call the server's notification mechanism after storing a
   new message" without specifying an API. That API did not exist
   until this PR. Replaced with a real usage example calling
   server.notify(mailboxId, totalMessages).

3. `packages/imap-server/docs/deployment.md` -- the Fly.io and
   Railway Dockerfiles used `node --import tsx src/main.ts` combined
   with `pnpm install --prod`. The --prod flag strips devDependencies
   including tsx, which breaks the runtime. Replaced with a
   multi-stage Dockerfile: build stage compiles TypeScript to dist/
   with dev deps, runtime stage installs --prod and runs compiled
   `node dist/main.js`.

## Verification

- pnpm --filter @rafters/mail-imap-server test: 10 passing
- pnpm --filter @rafters/mail-imap-server typecheck: clean
- pnpm --filter @rafters/mail-imap-server build: ESM + dts clean
  (dist/index.js 12.82 KB, dist/index.d.ts 2.47 KB)
- pnpm lint: 0 warnings, 0 errors (full workspace)
- oxfmt check on all four modified files: clean

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ssilvius
Copy link
Copy Markdown
Contributor Author

Closing as duplicate of PR #78. PR #77 was created with title 'Spectrum TCP bridge' but its branch feat/imap-server-notify actually contains my notify(mailboxId, count) API work -- a title/content mismatch from a coordination race earlier this session. I've since opened PR #78 on a fresh branch feat/imap-server-notify-api with the same notify work, correctly titled, rebased onto current main (post #73/#74/#75 merges), format-normalized, and with a clean legion-simplify gate. PR #78 covers everything this PR would have covered. Not deleting the branch in case the parallel agent working on the actual Spectrum TCP bridge (#57) needs to reuse the branch name -- they can force-push over it.

@ssilvius ssilvius closed this Apr 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: IMAP Spectrum TCP transport for native clients

1 participant