jmap-mail-client 0.1.2

RFC 8621 JMAP for Mail client — Email, Mailbox, Thread, Identity, EmailSubmission methods
Documentation
# jmap-mail-client

## What it is

RFC 8621 JMAP for Mail client methods — typed bindings on top of `jmap-base-client`.

Typed JMAP client methods for JMAP Mail ([RFC 8621]). An extension trait on
`jmap-base-client::JmapClient` that adds all 26 RFC 8621 method calls as typed
async methods.

## What it's for

Implements RFC 8621 method bindings (`Email/get`, `Mailbox/get`/`set`/`changes`,
`Thread/get`, `EmailSubmission/set`, `Identity/get`, `SearchSnippet/get`,
`MDN/send`, etc.) on top of `jmap-base-client`. This crate is the canonical
template for every extension `*-client` crate in the workspace — chat, calendars,
tasks, contacts, filenode, sharing, and metadata all mirror its module layout
and method-shape conventions. Depends on `jmap-base-client` for transport and
session, and on `jmap-mail-types` for the wire types.

## How to use

```rust
use jmap_base_client::{BearerAuth, ClientConfig, JmapClient};
use jmap_mail_client::{JmapMailExt, EmailGetParams};
use jmap_types::Id;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let auth = BearerAuth::new("my-token")?;
    let client = JmapClient::new_plain(auth, "https://jmap.example.com", ClientConfig::default())?;

    // Fetch the session and bind it to a mail session client.
    let session = client.fetch_session().await?;
    let mail = client.with_mail_session(session);

    // Fetch two emails with body values inlined.
    let params = EmailGetParams {
        fetch_text_body_values: Some(true),
        ..Default::default()
    };
    let ids = [Id::new_validated("e1")?, Id::new_validated("e2")?];
    let resp: jmap_mail_client::GetResponse<jmap_mail_types::Email> =
        mail.email_get(Some(&ids), None, Some(params)).await?;

    for email in &resp.list {
        println!("{}: {:?}", email.id, email.subject);
    }
    Ok(())
}
```

Id parameters are typed `&jmap_types::Id` (or `&[jmap_types::Id]` for slices)
to make invalid Ids unrepresentable. State tokens use `&jmap_types::State`.
Construct Ids with `Id::new_validated(s)` to enforce RFC 8620 §1.2 syntax at
the boundary, or with `Id::from(s)` when the value is known-valid (e.g.
already came back from a server response).

After calling `JmapMailExt::with_mail_session(session)` the returned
[`SessionClient`] carries the session and makes it available to all methods
without requiring the caller to pass it again. Construct a new `SessionClient`
after each `fetch_session` call — do not reuse a stale one across session
state changes.

## Registered methods

All 26 RFC 8621 method names are available as typed async methods on
[`SessionClient`]:

| Method | Parameters | Returns |
|---|---|---|
| `email_get` | `ids: Option<&[Id]>, properties: Option<&[&str]>, params: Option<EmailGetParams>` | `GetResponse<Email>` |
| `email_changes` | `since_state: &State, max_changes: Option<u64>` | `ChangesResponse` |
| `email_set` | `create: Option<Value>, update: Option<HashMap<Id, PatchObject>>, destroy: Option<Vec<Id>>, if_in_state: Option<&State>` | `SetResponse<Email>` |
| `email_query` | `filter: Option<Value>, sort: Option<Value>, position: Option<u64>, limit: Option<u64>, collapse_threads: Option<bool>` | `QueryResponse` |
| `email_query_changes` | `since_query_state: &State, max_changes: Option<u64>, collapse_threads: Option<bool>, filter: Option<Value>, sort: Option<Value>, up_to_id: Option<&Id>, calculate_total: Option<bool>` | `QueryChangesResponse` |
| `email_copy` | `params: EmailCopyParams, create: Value` | `SetResponse<Email>` |
| `email_import` | `emails: &HashMap<String, EmailImportInput>, if_in_state: Option<&State>` | `EmailImportResponse` |
| `email_parse` | `blob_ids: &[Id], params: Option<EmailParseParams>` | `EmailParseResponse` |
| `mailbox_get` | `ids: Option<&[Id]>, properties: Option<&[&str]>` | `GetResponse<Mailbox>` |
| `mailbox_changes` | `since_state: &State, max_changes: Option<u64>` | `ChangesResponse` |
| `mailbox_set` | `create: Option<Value>, update: Option<HashMap<Id, PatchObject>>, destroy: Option<Vec<Id>>, params: Option<MailboxSetParams>` | `SetResponse<Mailbox>` |
| `mailbox_query` | `filter: Option<Value>, sort: Option<Value>, position: Option<u64>, limit: Option<u64>` | `QueryResponse` |
| `mailbox_query_changes` | `since_query_state: &State, max_changes: Option<u64>, filter: Option<Value>, sort: Option<Value>, up_to_id: Option<&Id>, calculate_total: Option<bool>` | `QueryChangesResponse` |
| `thread_get` | `ids: Option<&[Id]>, properties: Option<&[&str]>` | `GetResponse<Thread>` |
| `thread_changes` | `since_state: &State, max_changes: Option<u64>` | `ChangesResponse` |
| `identity_get` | `ids: Option<&[Id]>, properties: Option<&[&str]>` | `GetResponse<Identity>` |
| `identity_changes` | `since_state: &State, max_changes: Option<u64>` | `ChangesResponse` |
| `identity_set` | `create: Option<Value>, update: Option<HashMap<Id, PatchObject>>, destroy: Option<Vec<Id>>` | `SetResponse<Identity>` |
| `search_snippet_get` | `filter: Value, email_ids: Option<&[Id]>` | `Value` |
| `email_submission_get` | `ids: Option<&[Id]>, properties: Option<&[&str]>` | `GetResponse<EmailSubmission>` |
| `email_submission_changes` | `since_state: &State, max_changes: Option<u64>` | `ChangesResponse` |
| `email_submission_query` | `filter: Option<Value>, sort: Option<Value>, position: Option<u64>, limit: Option<u64>` | `QueryResponse` |
| `email_submission_query_changes` | `since_query_state: &State, max_changes: Option<u64>, filter: Option<Value>, sort: Option<Value>, up_to_id: Option<&Id>, calculate_total: Option<bool>` | `QueryChangesResponse` |
| `email_submission_set` | `create: Option<Value>, update: Option<HashMap<Id, PatchObject>>, destroy: Option<Vec<Id>>, if_in_state: Option<&State>, params: Option<EmailSubmissionSetParams>` | `SetResponse<EmailSubmission>` |
| `vacation_response_get` | _(none)_ | `VacationResponse` (unwraps the spec-mandated singleton from the /get envelope) |
| `vacation_response_get_envelope` | _(none)_ | `GetResponse<VacationResponse>` (envelope including `state` token for `ifInState` guards) |
| `vacation_response_set` | `update: Option<HashMap<Id, PatchObject>>` | `SetResponse<VacationResponse>` |

`Id` and `State` here are `jmap_types::Id` and `jmap_types::State`.

## EmailSubmissionSetParams

`EmailSubmissionSetParams` carries two method-level fields that are sent at the
top level of the `EmailSubmission/set` request body, not inside a create or
update object:

```rust
pub struct EmailSubmissionSetParams {
    /// Map of creation key → PatchObject to apply to the related Email
    /// if the submission is created successfully (RFC 8621 §7.5).
    ///
    /// Keys prefixed with "#" are result references to creation keys in the
    /// same `create` map.  Typical use: remove `$draft` keyword on success.
    pub on_success_update_email: Option<HashMap<String, jmap_types::PatchObject>>,

    /// Email IDs (or "#"-prefixed creation keys) to destroy if the submission
    /// succeeds (RFC 8621 §7.5).
    ///
    /// Typical use: destroy the draft email after successful delivery.
    pub on_success_destroy_email: Option<Vec<String>>,

    /// Vendor / site / private extension fields preserved across the wire
    /// per the workspace extras-preservation policy.
    pub extra: serde_json::Map<String, serde_json::Value>,
}
```

Both fields are omitted from the JSON payload when `None`.

## EmailGetParams

`EmailGetParams` controls which body content the server includes in an
`Email/get` response (RFC 8621 §4.1.8). All fields default to `None` (server
default):

```rust
pub struct EmailGetParams {
    /// Which body-part properties to return (overrides server default list).
    pub body_properties: Option<Vec<String>>,

    /// If true, inline decoded values for text/plain body parts.
    pub fetch_text_body_values: Option<bool>,

    /// If true, inline decoded values for text/html body parts.
    /// Wire name is `fetchHTMLBodyValues` (HTML uppercase) per RFC 8621 §4.2.
    pub fetch_html_body_values: Option<bool>,

    /// If true, inline decoded values for all body parts.
    pub fetch_all_body_values: Option<bool>,

    /// Maximum bytes of body value to return per part (0 or absent = no limit).
    pub max_body_value_bytes: Option<u64>,

    /// Vendor / site / private extension fields preserved across the wire
    /// per the workspace extras-preservation policy.
    pub extra: serde_json::Map<String, serde_json::Value>,
}
```

Fields set to `None` are omitted from the request; the server uses its own
defaults for omitted fields.

## Response types

| Type | RFC section | Description |
|---|---|---|
| `GetResponse<T>` | RFC 8620 §5.1 | `/get` response: `account_id`, `state`, `list`, `not_found` |
| `ChangesResponse` | RFC 8620 §5.2 | `/changes` response: `old_state`, `new_state`, `has_more_changes`, `created`, `updated`, `destroyed` |
| `SetResponse<T>` | RFC 8620 §5.3 | `/set` response: `created`, `updated`, `destroyed`, `not_created`, `not_updated`, `not_destroyed` |
| `QueryResponse` | RFC 8620 §5.5 | `/query` response: `query_state`, `can_calculate_changes`, `position`, `ids`, `total`, `limit` |
| `QueryChangesResponse` | RFC 8620 §5.6 | `/queryChanges` response: `old_query_state`, `new_query_state`, `removed`, `added` |
| `AddedItem` | RFC 8620 §5.6 | Entry in `QueryChangesResponse::added`: `id` and `index` |

`SetResponse<T>` defaults to `SetResponse<serde_json::Value>` when no type
parameter is given. Use `SetResponse<Email>` to get typed created/updated maps.

## How it works

Every method on `SessionClient` follows the same six-step pipeline:

1. **Validate arguments** — defence-in-depth empty-state guards fire before any
   I/O, returning `ClientError::InvalidArgument` immediately. Id-shaped
   parameters are typed `&Id` / `&[Id]` and validated by construction
   (`Id::new_validated`); the production code does not re-validate Id syntax.
2. **`session_parts()`** — extracts `(api_url, account_id)` from the bound
   session; returns `ClientError::InvalidSession` if there is no primary account
   for `urn:ietf:params:jmap:mail`.
3. **Build args JSON** — constructs the `serde_json::Value` argument object,
   merging in any extra params structs by iterating their key-value pairs.
4. **`build_request(method, args, USING_*)`** — wraps the single invocation
   into a `JmapRequest` with the appropriate `using` array per RFC 8621 §1.3:
   `USING_MAIL` for Email/Mailbox/Thread/SearchSnippet methods,
   `USING_SUBMISSION` for Identity/EmailSubmission methods (includes both
   `urn:ietf:params:jmap:mail` and `urn:ietf:params:jmap:submission`), and
   `USING_VACATION` for VacationResponse methods. Call ID is `"r1"`.
5. **`call_internal(api_url, &req)`** — delegates to
   `jmap_base_client::JmapClient::call`, which POSTs the request and returns a
   `JmapResponse`.
6. **`extract_response(&resp, CALL_ID)`** — finds the invocation for call ID
   `"r1"` in the response and deserializes it into the typed return value.

## Examples

The crate ships one runnable example under `examples/` demonstrating the
end-to-end client flow:

- [`examples/mailbox_list.rs`]examples/mailbox_list.rs — fetch and
  print all mailboxes from a synthetic JMAP server (wiremock mock with a
  hand-written `Mailbox/get` response covering the common RFC 8621 §2.1
  roles). Run: `cargo run --example mailbox_list -p jmap-mail-client`.

NOT FOR PRODUCTION — synthetic mock-server fixtures only, no auth, no TLS.
Demonstrates the consume-side API.

## Gotchas

- **`email_import` requires a separately uploaded blob.** RFC 8621 §4.8
  `Email/import` operates on a previously uploaded raw RFC 5322 message;
  callers must upload the raw bytes via `jmap_base_client::JmapClient`'s
  blob-upload API and pass the resulting `blob_id` to `EmailImportInput`.
  Likewise, `email_parse` operates on blobs already in the store.
- **Partial `Email/get` via `properties` filtering breaks deserialization.**
  `Email` has six required metadata fields (`id`, `blob_id`, `thread_id`,
  `mailbox_ids`, `keywords`, `size`, `received_at`). If the server omits any of
  these because of a `properties` filter, `GetResponse<Email>` will fail to
  deserialize. Use `GetResponse<serde_json::Value>` for partial-field responses.
- **No streaming API for large email bodies.** All body values returned by
  `Email/get` with `fetch_*_body_values` options are buffered in memory as part
  of the JMAP response. Very large messages should be downloaded via
  `JmapClient::download_blob` instead.
- **Implicit `Email/set` from `EmailSubmission/set`.** When
  `EmailSubmissionSetParams::on_success_update_email` or
  `on_success_destroy_email` is set, the server generates an implicit `Email/set`
  invocation and includes it in the response. `extract_response` extracts only
  the `EmailSubmission/set` result identified by call ID `"r1"`. Callers that
  need to inspect the implicit `Email/set` result must call
  `jmap_base_client::JmapClient::call` directly and iterate
  `JmapResponse::method_responses` themselves.

## Crate family

```
jmap-types
    ├── jmap-mail-types      Email, Mailbox, Thread, Identity, etc.
    │       └── jmap-mail-client  ← this crate
    └── jmap-base-client     transport, session, auth
            └── (also a dep of jmap-mail-client)
```

Path dependencies between crates use `path = "../crate-jmap-*"` and will
remain that way until the family is published to crates.io.

## References

- **[RFC 8621]** — JMAP for Mail (method names, argument shapes, error conditions)
- **[RFC 8620]** — JMAP Core (request/response envelope, `/set` and `/query`
  shapes, ResultReference, error types)
- **[RFC 5322]** — Internet Message Format (message structure referenced by
  `Email/import` and `Email/parse`)

[RFC 8621]: https://www.rfc-editor.org/rfc/rfc8621
[RFC 8620]: https://www.rfc-editor.org/rfc/rfc8620
[RFC 5322]: https://www.rfc-editor.org/rfc/rfc5322