Skip to content

feat(imap-server): notify(mailboxId, count) API for IDLE push#78

Merged
ssilvius merged 1 commit intomainfrom
feat/imap-server-notify-api
Apr 11, 2026
Merged

feat(imap-server): notify(mailboxId, count) API for IDLE push#78
ssilvius merged 1 commit intomainfrom
feat/imap-server-notify-api

Conversation

@ssilvius
Copy link
Copy Markdown
Contributor

Summary

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

Also fixes three real errors in the copied docs found during doc review.

API change

interface ImapServer {
  listen(): Promise<void>;
  close(): Promise<void>;
  readonly connections: number;
  readonly port: number | null;                              // NEW
  notify(mailboxId: string, newMessageCount: number): void;  // NEW
}
  • notify -- delivers an EXISTS response 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.
  • port -- returns the actual bound port after listen() resolves. Useful when the server starts on port 0 (ephemeral) and the caller needs the assigned port. Critical for test harnesses.

Implementation

Internal bookkeeping switched from a counter (let activeConnections = 0) to a typed Set<ConnectionEntry> ({ socket, state }). The switch is required to iterate connections inside notify() and filter by mailboxId + idleState. The connections getter still returns the set size so external API surface is unchanged.

Tests (5 new, 10 total)

All in packages/imap-server/tests/server.test.ts:

  • surface check: notify exists on the returned object
  • notify with no active connections does not throw
  • notify with count = 0 does not throw
  • end-to-end: real net.Socket against a locally-bound plain-TCP server, drives LOGIN -> SELECT -> IDLE, calls server.notify("mbx-1", 7) from outside the session, asserts client receives * 7 EXISTS
  • end-to-end: client bound to a different mailbox does NOT receive the notification
  • end-to-end: client that is authenticated and SELECT-ed but NOT in IDLE does NOT receive the notification

Documentation fixes

Three real errors surfaced while reading the docs I shipped in PR #76:

  1. packages/imap-server/docs/quickstart.md -- "App passwords" section prescribed "store hashed (argon2) in your database". Contradicts the rewritten authentication contract: AuthAdapter is interface-only. Fixed.

  2. packages/imap-server/docs/quickstart.md -- "IDLE" section said "call the server's notification mechanism" without naming one, because no API existed. Fixed -- shows the real server.notify() call.

  3. packages/imap-server/docs/deployment.md -- Fly.io and Railway Dockerfiles used node --import tsx src/main.ts combined with pnpm install --prod. The --prod flag strips dev dependencies including tsx, which breaks runtime. Fixed -- multi-stage Dockerfile compiles TypeScript to dist/ in a build stage, runs node dist/main.js from a --prod runtime stage.

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
  • pnpm lint: 0 warnings, 0 errors
  • oxfmt --check on all modified files: clean
  • legion-simplify gate: clean

Stack

Chains off chore/package-readmes (#76), which chains off chore/imap-build-pipeline (#73). When #73 and #76 merge, this branch rebases cleanly onto main.

Note on PR #77

A parallel agent working on issue #57 (Spectrum TCP bridge) created PR #77 from the original feat/imap-server-notify branch with a mismatched title. This PR is the same notify work on a fresh branch (feat/imap-server-notify-api) with the correct title. PR #77 should be closed; Sean can handle that cleanup in the morning.

Why it matters

The @rafters/mail-imap-server package ships IMAP over Node TCP. IDLE push is a core IMAP feature (RFC 2177). Without notify(), a deployed Node IMAP server cannot wake IDLE clients when new mail arrives -- every client would sit in indefinite IDLE and see new mail only after timing out and re-selecting. A silent feature gap that would reach production if this package ships at 0.1.0 without the fix.

ssilvius added a commit that referenced this pull request Apr 11, 2026
The JSDoc above `AuthAdapter` said "App passwords are stored hashed
(argon2) in D1 and verified here." That's wrong on two axes:

1. The IMAP server does not own credential storage or hashing. That
   was the original design but it was rewritten: AuthAdapter is an
   interface contract only, the consumer brings their own auth system.
   Prescribing argon2 contradicts the rewritten contract.

2. Core docs must be generic: "blob storage" not "R2", "database" not
   "D1". Referencing D1 by name inside the core protocol package
   violates the zero-vendor-dependency principle.

Replaced with a proper JSDoc block that:
- States the interface-only posture explicitly
- Names the consumer's ownership (storage, hashing, generation,
  revocation)
- Lists the security guarantees the IMAP server DOES enforce
  regardless of adapter implementation (generic failure response,
  rate limit, disconnect after max attempts)
- Refers to the adapter lifecycle in neutral language

This matches the authoritative `authentication.md` doc shipped in PR
#76 and the interface-only posture in the `@rafters/mail-imap-server`
quickstart.md fixed in PR #78.

Verification:
- pnpm --filter @rafters/mail-imap test: 313 passing
- pnpm --filter @rafters/mail-imap typecheck: clean
- pnpm --filter @rafters/mail-imap build: ESM + dts clean
- pnpm lint: 0 warnings, 0 errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ssilvius ssilvius force-pushed the feat/imap-server-notify-api branch from 2879cba to 4465792 Compare April 11, 2026 19:02
ssilvius added a commit that referenced this pull request Apr 11, 2026
The JSDoc above `AuthAdapter` said "App passwords are stored hashed
(argon2) in D1 and verified here." That's wrong on two axes:

1. The IMAP server does not own credential storage or hashing. That
   was the original design but it was rewritten: AuthAdapter is an
   interface contract only, the consumer brings their own auth system.
   Prescribing argon2 contradicts the rewritten contract.

2. Core docs must be generic: "blob storage" not "R2", "database" not
   "D1". Referencing D1 by name inside the core protocol package
   violates the zero-vendor-dependency principle.

Replaced with a proper JSDoc block that:
- States the interface-only posture explicitly
- Names the consumer's ownership (storage, hashing, generation,
  revocation)
- Lists the security guarantees the IMAP server DOES enforce
  regardless of adapter implementation (generic failure response,
  rate limit, disconnect after max attempts)
- Refers to the adapter lifecycle in neutral language

This matches the authoritative `authentication.md` doc shipped in PR
#76 and the interface-only posture in the `@rafters/mail-imap-server`
quickstart.md fixed in PR #78.

Verification:
- pnpm --filter @rafters/mail-imap test: 313 passing
- pnpm --filter @rafters/mail-imap typecheck: clean
- pnpm --filter @rafters/mail-imap build: ESM + dts clean
- pnpm lint: 0 warnings, 0 errors

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 ssilvius force-pushed the feat/imap-server-notify-api branch from 4465792 to deb8676 Compare April 11, 2026 19:28
ssilvius added a commit that referenced this pull request Apr 11, 2026
The JSDoc above `AuthAdapter` said "App passwords are stored hashed
(argon2) in D1 and verified here." That's wrong on two axes:

1. The IMAP server does not own credential storage or hashing. That
   was the original design but it was rewritten: AuthAdapter is an
   interface contract only, the consumer brings their own auth system.
   Prescribing argon2 contradicts the rewritten contract.

2. Core docs must be generic: "blob storage" not "R2", "database" not
   "D1". Referencing D1 by name inside the core protocol package
   violates the zero-vendor-dependency principle.

Replaced with a proper JSDoc block that:
- States the interface-only posture explicitly
- Names the consumer's ownership (storage, hashing, generation,
  revocation)
- Lists the security guarantees the IMAP server DOES enforce
  regardless of adapter implementation (generic failure response,
  rate limit, disconnect after max attempts)
- Refers to the adapter lifecycle in neutral language

This matches the authoritative `authentication.md` doc shipped in PR
#76 and the interface-only posture in the `@rafters/mail-imap-server`
quickstart.md fixed in PR #78.

Verification:
- pnpm --filter @rafters/mail-imap test: 313 passing
- pnpm --filter @rafters/mail-imap typecheck: clean
- pnpm --filter @rafters/mail-imap build: ESM + dts clean
- pnpm lint: 0 warnings, 0 errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ssilvius added a commit that referenced this pull request Apr 11, 2026
…ramework boundary

The repo-level docs/architecture.md had accumulated significant drift
from the current shipped state:

1. **Package count wrong.** Said "Six packages" -- it is nine now
   after the IMAP packages shipped this week (@rafters/mail-imap,
   @rafters/mail-imap-cloudflare, @rafters/mail-imap-server).
   Updated the count, the list, and the dependency graph.

2. **IMAP section said "designed, not built, planned for post-0.1.0."**
   All three IMAP packages ship in 0.1.0. Rewrote the section:
   - Status is "shipped in 0.1.0 across three packages"
   - Added the Node runtime alongside the DO runtime
   - Expanded the command set to match what is actually shipped:
     added UNSELECT (RFC 3691), COPY + MOVE (RFC 6851), APPEND
     (RFC 4315 APPENDUID). The previous list was the Phase 1 subset.
   - Added the advertised capability list from SERVER_CAPABILITIES
   - Added a transport comparison with the Spectrum future path
     (still blocked on Enterprise tier; issue #57)
   - Added the inbound signaling section covering both runtimes
     (DO: POST /notify, Node: server.notify() from PR #78)

3. **Invented exports in the "Extracted" list.** Replaced:
   - `ResendProvider` implementation -> `createResendProvider` factory
   - `MockEmailProvider` class -> `createMockEmailProvider` factory
   - `ClassifyEmailWorkflow` -> removed (does not exist)
   - Queue consumer `handleEmailClassifyQueue` -> removed (does not exist)
   - Added real exports: createImapDurableObject, createImapWorker,
     createImapServer, parseEmailHeaders, hashContent, classifier
     helper functions

4. **Wrong subpath import example.** `createR2BlobStorage` ->
   `createR2Storage`. Same bug as elsewhere.

5. **Inbound flow described as InboundAdapter doing the work.**
   The shipped adapters provide building blocks (parseEmailHeaders,
   hashContent, createR2Storage); the orchestration is consumer code.
   Rewrote the flow diagram with steps 5-8 clearly labeled as
   "Consumer-written" and added a callout explaining why there is
   no pre-baked handleInboundEmail (schema extensions + auth context
   + pipeline topology are all application decisions).

6. **Classification flow described as framework doing the work.**
   Steps 1-4 are the classifier (which ships in mail-workers-ai);
   steps 5-8 (DB update, spam folder move, label application) are
   consumer-written against their own schema and FolderService.
   Rewrote to make the boundary explicit.

7. **Threading logic described as shipped behavior.** Threading.ts
   exports generateMessageId, buildReferences, and generateSnippet
   as building blocks. Thread matching is NOT shipped -- it is the
   consumer's responsibility for inbound (no InboundAdapter
   implementation exists yet). Outbound threading in composeEmail/
   replyToThread IS shipped via InboxEmailService. Clarified the
   split.

No content additions that invent features. Every claim is verified
against actual source in packages/*.

## Verification

- pnpm test: 609 passing
- pnpm typecheck: clean
- pnpm lint: 0 warnings, 0 errors
- pnpm format:check: clean
- All referenced exports, method names, and command names verified
  against the actual TypeScript source

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ssilvius ssilvius merged commit bb85b4e into main Apr 11, 2026
1 check passed
ssilvius added a commit that referenced this pull request Apr 11, 2026
…on (#79)

The JSDoc above `AuthAdapter` said "App passwords are stored hashed
(argon2) in D1 and verified here." That's wrong on two axes:

1. The IMAP server does not own credential storage or hashing. That
   was the original design but it was rewritten: AuthAdapter is an
   interface contract only, the consumer brings their own auth system.
   Prescribing argon2 contradicts the rewritten contract.

2. Core docs must be generic: "blob storage" not "R2", "database" not
   "D1". Referencing D1 by name inside the core protocol package
   violates the zero-vendor-dependency principle.

Replaced with a proper JSDoc block that:
- States the interface-only posture explicitly
- Names the consumer's ownership (storage, hashing, generation,
  revocation)
- Lists the security guarantees the IMAP server DOES enforce
  regardless of adapter implementation (generic failure response,
  rate limit, disconnect after max attempts)
- Refers to the adapter lifecycle in neutral language

This matches the authoritative `authentication.md` doc shipped in PR
#76 and the interface-only posture in the `@rafters/mail-imap-server`
quickstart.md fixed in PR #78.

Verification:
- pnpm --filter @rafters/mail-imap test: 313 passing
- pnpm --filter @rafters/mail-imap typecheck: clean
- pnpm --filter @rafters/mail-imap build: ESM + dts clean
- pnpm lint: 0 warnings, 0 errors

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ssilvius added a commit that referenced this pull request Apr 11, 2026
…ct-email (#81)

* docs: fix interface drift across adapters.md, better-auth-resend, and react-email

Second pass of accuracy fixes after finding more drift while reviewing
the 967-line adapters.md and cross-referencing against actual source.
Some of these errors were also present in files I introduced in PR #76
(now on main) -- fixing those here too.

## packages/core/docs/adapters.md

**Resend section:**
- Replaced `new ResendService(...) + new ResendProvider(resend)` with
  the actual factory: `createResendProvider({ apiKey, fromEmail })`.
  `ResendProvider` is not an exported class; `createResendProvider`
  is a factory function.
- `CampaignParams.audienceId` -> `listId`. The field name in the
  actual `campaignParamsSchema` is `listId`.
- Replaced `new MockEmailProvider()` with `createMockEmailProvider()`.
  Mock is also a factory, not a class.
- Documented the real mock surface: `sentEmails` as a top-level
  property, `getState()` for full internal state introspection,
  `clear()` for test resets.

**Cloudflare section:**
- Removed all references to `handleInboundEmail` (does not exist)
  and `R2BlobStorage` class (does not exist). Replaced with the
  actual building blocks: `createR2Storage`, `parseEmailHeaders`,
  `hashContent`.
- Fixed the `BlobStorage` interface to match the real shape:
  single `put(key, content, options?)`, `get(key, options?)` returns
  `BlobObject | null`, `delete(key)`, `generateKey(contentHash, extension)`.
  The doc had `putRaw`/`putText`/`putHtml` three-method split, which
  is not how the interface is shaped.
- Worker entry point example rewritten to use the actual exports
  and demonstrate the compose-your-own pattern (read bytes, parse
  headers, hash, store, insert row).
- Thread matching: clarified it is the consumer's responsibility,
  not a feature of the adapter. The adapter provides the parsing
  building blocks; thread matching queries are the consumer's code.
- `InMemoryBlobStorage` example rewritten as a factory function
  returning `BlobStorage` with the correct four methods.

**Workers AI section:**
- Removed `ClassifyEmailWorkflow` and `handleEmailClassifyQueue`
  references (do not exist). The package does not ship a workflow
  subpath or a queue subpath.
- Replaced `createEmailClassifier` with the real factory name
  `createWorkersAIClassifier(ai, config?)`.
- Queue consumer example rewritten to show consumer-implemented
  queue handling using the real classifier.
- Mock AI type is now `AiBinding` from the package, not `Ai`.

**React Email section:**
- Fixed import paths: `BaseEmail` lives at `/templates`, `OtpEmail`
  at `/otp`. Both were importing from the package root, which would
  fail at build time for consumers.
- Rendering section rewritten to show the registry pattern: the
  renderer is name-keyed, templates are registered at construction
  time (or via `.register()`), and `render(name, props)` looks up
  by name. Removed the incorrect pass-component-directly pattern.

**better-auth-resend section:**
- `resendOTP` config shape was entirely wrong. Doc claimed
  `resendOTP(env)` with `env = { RESEND_API_KEY, FROM_EMAIL }`,
  plus an optional second argument for branding. Actual is a single
  `ResendOTPConfig` object: `{ apiKey, fromEmail, brandName,
  logoUrl?, websiteUrl?, expiryMinutes?, baseUrl? }`. Rewrote
  integration example and documented the full config shape.

**Custom adapter example:**
- Replaced `class PostmarkProvider implements EmailProvider` with
  `createPostmarkProvider(config): EmailProvider` factory closure
  to match the monorepo factory-pattern convention in CLAUDE.md.

**Barrel exports section:**
- Replaced 4 wrong imports (`ResendProvider`, `R2BlobStorage`,
  `handleEmailClassifyQueue`, `ClassifyEmailWorkflow`) with the
  actual exports via their correct subpaths.

## packages/better-auth-resend/README.md (fixing #76 drift on main)

- `resendOTP` config: was `{ apiKey, from, appName }`, actual is
  `{ apiKey, fromEmail, brandName, ... }`. Fixed the example and
  the config table. Added the 4 optional fields (`logoUrl`,
  `websiteUrl`, `expiryMinutes`, `baseUrl`) the real schema exposes.

## packages/better-auth-resend/docs/usage.md (fixing #76 drift on main)

- Same `resendOTP` config fix as the README.
- "What the user sees" section updated: subject template is
  `"<brandName> verification code: <otp>"`, not `"Your <appName> code"`.
- "Replacing the template" example now uses the actual renderer
  registry pattern and `provider.sendEmail` (not `.send`).

## packages/react-email/README.md (fixing #76 drift on main)

- Renderer section now shows the registry pattern:
  `createReactEmailRenderer({ otp: OtpEmail })` then
  `renderer.render("otp", { code, brandName })`. The previous
  example called `render(OtpEmail({ otp, appName }))` which neither
  matches the interface nor uses the real OtpEmail prop names.
- Fixed OtpEmail prop names: `otp` -> `code`, `appName` -> `brandName`,
  `expiresInMinutes` -> `expiryMinutes`.

## packages/react-email/docs/templates.md (fixing #76 drift on main)

- Renderer section: same registry-pattern fix as the README, plus
  a "Why a registry instead of passing components" explanation
  (string-keyed templates let service code reference templates
  without importing React components).
- OtpEmail props: same `code`/`brandName`/`expiryMinutes` fix.
- "Writing your own template" example now registers the custom
  template with the renderer and calls `render(name, props)`
  instead of passing the component directly.

## Verification

- pnpm test: 609 passing
- pnpm typecheck: clean
- pnpm lint: 0 warnings, 0 errors
- pnpm format:check: clean on all modified files

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

* docs(architecture): fix package count, IMAP status, and consumer vs framework boundary

The repo-level docs/architecture.md had accumulated significant drift
from the current shipped state:

1. **Package count wrong.** Said "Six packages" -- it is nine now
   after the IMAP packages shipped this week (@rafters/mail-imap,
   @rafters/mail-imap-cloudflare, @rafters/mail-imap-server).
   Updated the count, the list, and the dependency graph.

2. **IMAP section said "designed, not built, planned for post-0.1.0."**
   All three IMAP packages ship in 0.1.0. Rewrote the section:
   - Status is "shipped in 0.1.0 across three packages"
   - Added the Node runtime alongside the DO runtime
   - Expanded the command set to match what is actually shipped:
     added UNSELECT (RFC 3691), COPY + MOVE (RFC 6851), APPEND
     (RFC 4315 APPENDUID). The previous list was the Phase 1 subset.
   - Added the advertised capability list from SERVER_CAPABILITIES
   - Added a transport comparison with the Spectrum future path
     (still blocked on Enterprise tier; issue #57)
   - Added the inbound signaling section covering both runtimes
     (DO: POST /notify, Node: server.notify() from PR #78)

3. **Invented exports in the "Extracted" list.** Replaced:
   - `ResendProvider` implementation -> `createResendProvider` factory
   - `MockEmailProvider` class -> `createMockEmailProvider` factory
   - `ClassifyEmailWorkflow` -> removed (does not exist)
   - Queue consumer `handleEmailClassifyQueue` -> removed (does not exist)
   - Added real exports: createImapDurableObject, createImapWorker,
     createImapServer, parseEmailHeaders, hashContent, classifier
     helper functions

4. **Wrong subpath import example.** `createR2BlobStorage` ->
   `createR2Storage`. Same bug as elsewhere.

5. **Inbound flow described as InboundAdapter doing the work.**
   The shipped adapters provide building blocks (parseEmailHeaders,
   hashContent, createR2Storage); the orchestration is consumer code.
   Rewrote the flow diagram with steps 5-8 clearly labeled as
   "Consumer-written" and added a callout explaining why there is
   no pre-baked handleInboundEmail (schema extensions + auth context
   + pipeline topology are all application decisions).

6. **Classification flow described as framework doing the work.**
   Steps 1-4 are the classifier (which ships in mail-workers-ai);
   steps 5-8 (DB update, spam folder move, label application) are
   consumer-written against their own schema and FolderService.
   Rewrote to make the boundary explicit.

7. **Threading logic described as shipped behavior.** Threading.ts
   exports generateMessageId, buildReferences, and generateSnippet
   as building blocks. Thread matching is NOT shipped -- it is the
   consumer's responsibility for inbound (no InboundAdapter
   implementation exists yet). Outbound threading in composeEmail/
   replyToThread IS shipped via InboxEmailService. Clarified the
   split.

No content additions that invent features. Every claim is verified
against actual source in packages/*.

## Verification

- pnpm test: 609 passing
- pnpm typecheck: clean
- pnpm lint: 0 warnings, 0 errors
- pnpm format:check: clean
- All referenced exports, method names, and command names verified
  against the actual TypeScript source

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

* docs(imap/commands): correct SEARCH criteria list to match parser

The SEARCH command section listed 6 criteria that the actual parser
does not implement: ANSWERED, DELETED, DRAFT, FLAGGED, SEEN, UNSEEN.
Any IMAP client sending one of these receives "BAD Unknown search
criterion: <name>" -- a hard failure, not a graceful fallback.

Verified against packages/imap/src/protocol/parser.ts: the switch
statement parseSearchCriteria has cases for ALL, NEW, FROM, TO, CC,
BCC, SUBJECT, BEFORE, ON, SINCE, LARGER, SMALLER, TEXT, BODY, UID,
NOT, OR plus bare sequence sets. Everything else throws via the
default branch.

Updated the supported list to reflect reality and added a "Not yet
supported" section that:

- Names the missing flag-based criteria explicitly
- Names other RFC 3501 criteria that are also missing (KEYWORD,
  UNKEYWORD, HEADER, SENTBEFORE / SENTON / SENTSINCE)
- Documents the practical workaround: SEARCH NEW matches
  RECENT + UNSEEN for the "new mail" UI case, and STATUS INBOX
  (UNSEEN) returns the unread count without needing the UNSEEN
  criterion.
- Notes that this is a real protocol gap being tracked in an issue

Updated the example to use FROM + SINCE (both supported) instead
of UNSEEN + SINCE (UNSEEN is not supported).

No implementation change in this PR. The parser gap is a separate
issue that needs a code change to add the flag-based criteria to
parseSearchCriteria and wire them to searchMessages in the message
adapter.

## Verification

- Verified every listed criterion against the case statements in
  parser.ts and the SearchCriterion type
- pnpm format:check: clean
- pnpm test: (unchanged)

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

1 participant