feat(mail): wire a real JMAP client into the mail sync path #221

Merged
KofTwentyTwo merged 1 commits from story/m3-jmap-client into dev 2026-07-29 23:40:19 +00:00
KofTwentyTwo commented 2026-07-29 23:36:18 +00:00 (Migrated from github.com)

Summary

  • Closes #189. JmapEngine is now a real RFC 8620/8621 client alongside IMAP/SMTP: with_credentials performs genuine HTTP session discovery (GET /.well-known/jmap), then real Mailbox/get and Email/query/Email/get JSON-RPC calls over reqwest against the discovered apiUrl, flowing through the exact same MailBackend -> fetch_and_persist -> DatabaseEngine::save_email path IMAP already uses. JmapEngine::new (uncredentialed) keeps the deterministic no-network fallback so existing unit tests are unaffected.
  • Fixed the live camelCase wire bug: JmapEmail now has #[serde(rename_all = "camelCase")], so receivedAt/isUnread/bodySnippet actually populate from real server responses instead of silently defaulting. Added jmap_email_deserializes_camel_case_wire_fields as a regression test.
  • nunciod::sync::build_mail_backend's AccountProtocol::Jmap arm now constructs JmapEngine::with_credentials(&config.id, &config.server_host, &config.email_address, password), mirroring the IMAP arm -- a JMAP account can now actually sync.
  • Removed the canned fallbacks from the credentialed path: a session-discovery or JMAP-call HTTP/parse failure now surfaces as MailError::NetworkError/ParseFailed, never fake data.
  • Moved reqwest from [dev-dependencies] to [dependencies] in nuncio-mail (JMAP now makes real HTTP calls from production code).
  • Added wiremock to nunciod's [dev-dependencies].

Tests rewritten off fake data

  • crates/nunciod/src/sync.rs's run_account_sync_persists_real_backend_messages_and_reports_success previously asserted on JmapEngine::new's hardcoded 3-folder/1-message static data. It's rewritten to mount a wiremock::MockServer (session + Mailbox/get + Email/query + Email/get stubs) and point the sample account's server_host at the mock server's .uri(), proving run_account_sync -> real HTTP JMAP fetch -> DatabaseEngine::save_email -> db.get_message(...) end to end.
  • run_all_accounts_sync_persists_across_multiple_accounts had the same problem (it would now hit jmap.nuncio.mx for real once credentials flow through build_mail_backend) and is rewritten the same way, with two independent mock servers.

New tests

  • crates/nuncio-mail/tests/wiremock_jmap_test.rs: jmap_engine_sync_folders_and_messages_through_mail_backend_trait drives JmapEngine::with_credentials through the MailBackend trait itself (sync_folders/sync_messages), not just the static parse helpers, against a wiremock server standing in for a real JMAP host.
  • jmap_engine_sync_folders_surfaces_session_discovery_failure: proves a 500 from session discovery surfaces as a genuine MailError, never a silent fallback.

Gate evidence

cargo fmt --all -- --check     # clean
cargo check-all                # clippy --all-targets --workspace -D warnings: clean
cargo test-all                 # all green, 0 ignored (see per-crate counts below)
  • nuncio-mail: 42 unit tests + 3 wiremock integration tests, all passing.
  • nunciod: 100 unit tests + 6 integration test files, all passing (includes the two rewritten JMAP sync tests).
  • Full workspace cargo test-all: every crate green, 0 failed, 0 ignored.
  • Local pre-commit hook (fmt + clippy -D warnings + full test suite) passed on commit.

No proto changes -- this story reuses the existing nuncio.v1.Mail service unchanged, as required by #189.

Notes for PO review

  • JMAP-native outbound send (Email/set/EmailSubmission) remains a stub, matching #189's explicit scope note that MailBackend::send_email is never called in production for either protocol today (SMTP send is separate, send.rs).
  • Incremental (since_state-aware) JMAP sync is out of scope here, matching IMAP's current same-gap (tracked as M5-S2 per the issue).
  • Email/query currently has no bound on result size for very large mailboxes; that's inherited from IMAP's equivalent full-refetch behavior and is the same size/scope tradeoff the issue calls out.

🤖 Generated with Claude Code

## Summary - Closes #189. `JmapEngine` is now a real RFC 8620/8621 client alongside IMAP/SMTP: `with_credentials` performs genuine HTTP session discovery (`GET /.well-known/jmap`), then real `Mailbox/get` and `Email/query`/`Email/get` JSON-RPC calls over `reqwest` against the discovered `apiUrl`, flowing through the exact same `MailBackend` -> `fetch_and_persist` -> `DatabaseEngine::save_email` path IMAP already uses. `JmapEngine::new` (uncredentialed) keeps the deterministic no-network fallback so existing unit tests are unaffected. - Fixed the live camelCase wire bug: `JmapEmail` now has `#[serde(rename_all = "camelCase")]`, so `receivedAt`/`isUnread`/`bodySnippet` actually populate from real server responses instead of silently defaulting. Added `jmap_email_deserializes_camel_case_wire_fields` as a regression test. - `nunciod::sync::build_mail_backend`'s `AccountProtocol::Jmap` arm now constructs `JmapEngine::with_credentials(&config.id, &config.server_host, &config.email_address, password)`, mirroring the IMAP arm -- a JMAP account can now actually sync. - Removed the canned fallbacks from the credentialed path: a session-discovery or JMAP-call HTTP/parse failure now surfaces as `MailError::NetworkError`/`ParseFailed`, never fake data. - Moved `reqwest` from `[dev-dependencies]` to `[dependencies]` in `nuncio-mail` (JMAP now makes real HTTP calls from production code). - Added `wiremock` to `nunciod`'s `[dev-dependencies]`. ## Tests rewritten off fake data - `crates/nunciod/src/sync.rs`'s `run_account_sync_persists_real_backend_messages_and_reports_success` previously asserted on `JmapEngine::new`'s hardcoded 3-folder/1-message static data. It's rewritten to mount a `wiremock::MockServer` (session + `Mailbox/get` + `Email/query` + `Email/get` stubs) and point the sample account's `server_host` at the mock server's `.uri()`, proving `run_account_sync` -> real HTTP JMAP fetch -> `DatabaseEngine::save_email` -> `db.get_message(...)` end to end. - `run_all_accounts_sync_persists_across_multiple_accounts` had the same problem (it would now hit `jmap.nuncio.mx` for real once credentials flow through `build_mail_backend`) and is rewritten the same way, with two independent mock servers. ## New tests - `crates/nuncio-mail/tests/wiremock_jmap_test.rs`: `jmap_engine_sync_folders_and_messages_through_mail_backend_trait` drives `JmapEngine::with_credentials` through the `MailBackend` trait itself (`sync_folders`/`sync_messages`), not just the static parse helpers, against a wiremock server standing in for a real JMAP host. - `jmap_engine_sync_folders_surfaces_session_discovery_failure`: proves a 500 from session discovery surfaces as a genuine `MailError`, never a silent fallback. ## Gate evidence ``` cargo fmt --all -- --check # clean cargo check-all # clippy --all-targets --workspace -D warnings: clean cargo test-all # all green, 0 ignored (see per-crate counts below) ``` - `nuncio-mail`: 42 unit tests + 3 wiremock integration tests, all passing. - `nunciod`: 100 unit tests + 6 integration test files, all passing (includes the two rewritten JMAP sync tests). - Full workspace `cargo test-all`: every crate green, 0 failed, 0 ignored. - Local pre-commit hook (fmt + clippy -D warnings + full test suite) passed on commit. No proto changes -- this story reuses the existing `nuncio.v1.Mail` service unchanged, as required by #189. ## Notes for PO review - JMAP-native outbound send (`Email/set`/`EmailSubmission`) remains a stub, matching #189's explicit scope note that `MailBackend::send_email` is never called in production for either protocol today (SMTP send is separate, `send.rs`). - Incremental (`since_state`-aware) JMAP sync is out of scope here, matching IMAP's current same-gap (tracked as M5-S2 per the issue). - `Email/query` currently has no bound on result size for very large mailboxes; that's inherited from IMAP's equivalent full-refetch behavior and is the same size/scope tradeoff the issue calls out. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.