jmap-contacts-client 0.1.2

JMAP Contacts HTTP client — extension trait over jmap-base-client
Documentation
# jmap-contacts-client

## What it is

Typed client methods for JMAP Contacts ([RFC 9610]). Depends
on [`jmap-base-client`] for transport, authentication, and session management.

## What it's for

Implements draft-ietf-jmap-contacts (RFC 9610) method bindings on top of
`jmap-base-client`: `AddressBook/get|changes|set` and
`ContactCard/get|changes|set|copy|query|queryChanges`. Sibling of
`jmap-mail-client` in the extension-client family — mirrors that crate's
shape. Depends on `jmap-base-client` for transport and session, and on
`jmap-contacts-types` for the wire types (including the RFC 9553 JSContact
sub-types re-exported under the `jscontact` module alias).

## How to use

```rust,no_run
use jmap_base_client::{BearerAuth, ClientConfig, JmapClient};
use jmap_contacts_client::{JmapContactsExt, AddressBookSetParams};
use jmap_contacts_types::ContactCard;
use jmap_types::{Id, State};
use serde_json::json;

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

    // 2. Fetch the JMAP session document.
    let session = client.fetch_session().await?;

    // 3. Bind the session to a contacts client.
    let contacts = client.with_contacts_session(session);

    // 4. Fetch all address books.
    let response = contacts.address_book_get(None, None).await?;
    for ab in &response.list {
        println!("{}: {}", ab.id, ab.name);
    }

    // 5. Fetch all contact cards.
    let cards = contacts.contact_card_get(None, None).await?;
    for card in &cards.list {
        if let Some(id) = &card.id {
            println!("card id={}", id);
        }
    }

    // 6. Create a new ContactCard.
    //    JSContact sub-objects (name, emails, etc.) must be built as
    //    serde_json::Value — no typed builder is provided (see Known Limitations).
    let create_card = json!({
        "newCard": {
            "addressBookIds": { "ab-id-here": true },
            "version": "1.0",
            "uid": "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6",
            "name": {
                "components": [
                    { "kind": "given", "value": "Jane" },
                    { "kind": "surname", "value": "Smith" }
                ],
                "isOrdered": true
            },
            "emails": {
                "e1": {
                    "contexts": { "work": true },
                    "address": "jane@example.com"
                }
            }
        }
    });
    let set_resp = contacts
        .contact_card_set(Some(create_card), None, None)
        .await?;
    if let Some(created) = set_resp.created {
        if let Some(card) = created.get("newCard") {
            println!("created card id={:?}", card.id);
        }
    }

    // 7. Query contact cards in a specific address book.
    let filter = json!({ "inAddressBook": "ab-id-here" });
    let query = contacts
        .contact_card_query(Some(filter), None, None, None)
        .await?;
    println!("found {} card(s)", query.ids.len());

    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 constructing a `SessionClient` via `with_contacts_session`, all JMAP
Contacts methods are available without passing `&Session` on every call. If the
session expires, re-fetch with `JmapClient::fetch_session` and construct a new
`SessionClient`.

## Registered methods

All method implementations live on `SessionClient` in the `methods/` submodules.

| Method | Function | Returns |
|---|---|---|
| `AddressBook/get` | `address_book_get` | `GetResponse<AddressBook>` |
| `AddressBook/changes` | `address_book_changes` | `ChangesResponse` |
| `AddressBook/set` | `address_book_set` | `SetResponse<AddressBook>` |
| `ContactCard/get` | `contact_card_get` | `GetResponse<ContactCard>` |
| `ContactCard/changes` | `contact_card_changes` | `ChangesResponse` |
| `ContactCard/set` | `contact_card_set` | `SetResponse<ContactCard>` |
| `ContactCard/copy` | `contact_card_copy` | `SetResponse<ContactCard>` |
| `ContactCard/query` | `contact_card_query` | `QueryResponse` |
| `ContactCard/queryChanges` | `contact_card_query_changes` | `QueryChangesResponse` |

Note: `AddressBook/query` and `AddressBook/queryChanges` are not implemented —
RFC 9610 does not define these methods.

### AddressBook/set — extra arguments

`address_book_set` accepts an optional `AddressBookSetParams` for the
contacts-specific method-level arguments:

```rust
pub struct AddressBookSetParams {
    /// When true, ContactCards belonging only to a destroyed AddressBook
    /// are also destroyed; cards in multiple books are detached.
    pub on_destroy_remove_contents: Option<bool>,

    /// Designates one AddressBook as the account default after all other
    /// operations succeed (RFC 9610 §2.3).
    pub on_success_set_is_default: Option<serde_json::Value>,
}
```

Pass `None` for `params` (or `Some(Default::default())`) when neither argument
is needed.

## How it works

Every method follows the same five-step pattern:

1. Validate arguments (defence-in-depth empty-state guards return
   `InvalidArgument` before any network call; Id-shaped parameters are
   typed `&Id` / `&[Id]` so empty-Id inputs are unrepresentable).
2. Call `session_parts()` to extract `(api_url, account_id)` from the bound
   session.
3. Build the JMAP method arguments as a `serde_json::Value`.
4. Call `build_request(method_name, args, USING_CONTACTS)` to construct a
   single-method `JmapRequest`.
5. POST to the API URL and extract the typed response via
   `jmap_base_client::extract_response`.

The capability `using` array for all contacts requests is:
`["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"]`.

## Gotchas

- Creating or updating a `ContactCard` requires the caller to build
  `serde_json::Value` for all JSContact sub-objects (`name`, `phones`,
  `addresses`, `emails`, etc.) manually. No typed builder or fluent API is
  provided. Use RFC 9553 as the schema when constructing these values.
- `contact_card_query` and `contact_card_query_changes` accept `filter` and
  `sort` as untyped `serde_json::Value`. Use `ContactCardFilterCondition` and
  `ContactCardComparator` from `jmap-contacts-types` and serialize them with
  `serde_json::to_value` before passing.
- No integration tests against a real JMAP server. Tests use direct
  serialization assertions against known JSON shapes from the spec.

## Crate family

```
jmap-types
    └── jmap-base-client         transport, auth, session
            └── jmap-contacts-client  ← this crate
                    (also depends on jmap-contacts-types for response types)
```

## References

- **[RFC 9610]** — JMAP Contacts (normative for all method
  names, argument shapes, and response formats)
- **[RFC 9553]** — JSContact (normative for ContactCard sub-object schemas)
- **[RFC 8620]** — JMAP Core (request format, response shapes, `/get`, `/set`,
  `/changes`, `/query`, `/queryChanges`, `/copy`)

[RFC 9610]: https://www.rfc-editor.org/rfc/rfc9610
[RFC 9553]: https://www.rfc-editor.org/rfc/rfc9553
[RFC 8620]: https://www.rfc-editor.org/rfc/rfc8620
[`jmap-base-client`]: ../crate-jmap-base-client