jmap-contacts-client 0.1.2

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

JMAP Contacts (RFC 9610) method implementations on top of
`jmap-base-client`.

## Crate Family Position

```
jmap-types
    ├── jmap-contacts-types
    │       └── (types used here)
    └── jmap-base-client
            └── jmap-contacts-client  ← this crate
```

## What This Crate Is

An extension layer over `jmap-base-client` that adds typed methods for every
JMAP Contacts operation: `AddressBook/get`, `AddressBook/changes`,
`AddressBook/set`, `ContactCard/get`, `ContactCard/changes`,
`ContactCard/query`, `ContactCard/queryChanges`, `ContactCard/set`,
`ContactCard/copy`.

Consumers call `jmap-base-client::JmapClient::call()` directly or use the
typed helpers defined here.  No new HTTP machinery — all network operations go
through `jmap-base-client`.

## What This Crate Is Not

- Not a server-side crate
- Not a standalone HTTP client (no auth, no transport — that's `jmap-base-client`)
- Not handling vCard import/export or CardDAV synchronization
- Not a command-line address book application

## Source Material

This is **greenfield** — no existing Rust implementation to extract from.

Design pattern to follow:
- `~/PROJECT/crate-jmap/crate-jmap-mail-client/` — identical extension trait pattern,
  module layout, and test strategy
- `~/PROJECT/crate-jmapchat-client/src/methods/` — how method inputs/outputs
  are structured and how `JmapRequestBuilder` is used to issue calls
- `~/PROJECT/jmap-chat-spec/references/RFC 9610.txt`  normative spec (§2–§4 for method arguments and responses)
- `~/PROJECT/jmap-chat-spec/references/rfc8620.txt` — base protocol (get/set/
  query/changes/queryChanges/copy request and response structures)

## Dependencies

```toml
jmap-types          = { path = "../crate-jmap-types" }
jmap-contacts-types = { path = "../crate-jmap-contacts-types" }
jmap-base-client    = { path = "../crate-jmap-base-client" }
serde_json          = "1"
thiserror           = "2"
```

No direct reqwest/tokio dependency — all I/O goes through `jmap-base-client`.

## Extension Trait Pattern

Cross-crate inherent impls are not valid Rust (orphan rule).  To add methods to
`JmapClient` from this crate, we use an **extension trait**:

```rust
pub trait JmapContactsExt {
    async fn address_book_get(...) -> Result<...>;
}

impl JmapContactsExt for JmapClient {
    async fn address_book_get(...) -> Result<...> { ... }
}
```

Callers must bring the trait into scope: `use jmap_contacts_client::JmapContactsExt;`

Rust 1.75 AFIT (async fn in trait, via RPITIT) is used — no `async-trait` crate
needed.  This works because we do not need `dyn JmapContactsExt`.  If dyn
dispatch is ever required, wrap with `async-trait 0.1` at that time.

## Planned Public API

```rust
use jmap_base_client::{ClientError, JmapClient};
use jmap_base_client::response::{
    ChangesResponse, GetResponse, QueryResponse, QueryChangesResponse, SetResponse,
};
use jmap_contacts_types::{
    AddressBook,
    ContactCard,
    ContactCardFilter, ContactCardComparator,
};
use jmap_types::{Id, State};

/// Extension trait adding JMAP Contacts methods to [`JmapClient`].
///
/// Import this trait to use: `use jmap_contacts_client::JmapContactsExt;`
///
/// All methods serialize to a single JMAP method call and deserialize the
/// response. For multi-method request batching, use
/// `JmapClient::call()` directly with a `JmapRequestBuilder`.
pub trait JmapContactsExt {
    // ── AddressBook ──────────────────────────────────────────────────────

    /// Fetch address books by id. Pass `ids: None` to fetch all.
    async fn address_book_get(
        &self,
        account_id: &Id,
        ids: Option<&[Id]>,
        properties: Option<&[&str]>,
    ) -> Result<GetResponse<AddressBook>, ClientError>;

    /// Fetch changes to address books since `since_state`.
    async fn address_book_changes(
        &self,
        account_id: &Id,
        since_state: &State,
        max_changes: Option<u64>,
    ) -> Result<ChangesResponse, ClientError>;

    /// Create, update, or destroy address books.
    ///
    /// `on_destroy_remove_contents`: if true, cards in destroyed address books
    /// are also destroyed (or detached if they belong to other books).
    /// `on_success_set_is_default`: if Some, set this address book as the
    /// default after all creates/updates/destroys succeed.
    async fn address_book_set(
        &self,
        account_id: &Id,
        req: AddressBookSetRequest,
    ) -> Result<SetResponse<AddressBook>, ClientError>;

    // ── ContactCard ──────────────────────────────────────────────────────

    /// Fetch contact cards by id. Pass `ids: None` to fetch all.
    async fn contact_card_get(
        &self,
        account_id: &Id,
        ids: Option<&[Id]>,
        properties: Option<&[&str]>,
    ) -> Result<GetResponse<ContactCard>, ClientError>;

    /// Fetch changes to contact cards since `since_state`.
    async fn contact_card_changes(
        &self,
        account_id: &Id,
        since_state: &State,
        max_changes: Option<u64>,
    ) -> Result<ChangesResponse, ClientError>;

    /// Query contact cards with optional filter and sort.
    async fn contact_card_query(
        &self,
        account_id: &Id,
        req: ContactCardQueryRequest,
    ) -> Result<QueryResponse, ClientError>;

    /// Fetch incremental changes to a query result since `since_query_state`.
    async fn contact_card_query_changes(
        &self,
        account_id: &Id,
        req: ContactCardQueryChangesRequest,
    ) -> Result<QueryChangesResponse, ClientError>;

    /// Create, update, or destroy contact cards.
    async fn contact_card_set(
        &self,
        account_id: &Id,
        req: SetRequest<ContactCard>,
    ) -> Result<SetResponse<ContactCard>, ClientError>;

    /// Copy a contact card from one account to another.
    async fn contact_card_copy(
        &self,
        from_account_id: &Id,
        to_account_id: &Id,
        req: ContactCardCopyRequest,
    ) -> Result<ContactCardCopyResponse, ClientError>;
}

impl JmapContactsExt for JmapClient {
    // implementations in addressbook.rs, card.rs
}
```

## Request and Response Types

These types are defined in `src/` (not in `jmap-contacts-types`, which holds
only wire data objects, not method arguments):

```rust
/// Arguments for AddressBook/set beyond the standard SetRequest fields.
pub struct AddressBookSetRequest {
    pub if_in_state: Option<State>,
    pub create: Option<HashMap<String, AddressBook>>,
    pub update: Option<HashMap<Id, jmap_types::PatchObject>>,  // RFC 8620 §5.3
    pub destroy: Option<Vec<Id>>,
    pub on_destroy_remove_contents: bool,                // default: false
    pub on_success_set_is_default: Option<Id>,           // RFC 9610 §2.3
}

/// Arguments for ContactCard/query.
pub struct ContactCardQueryRequest {
    pub filter: Option<ContactCardFilter>,
    pub sort: Option<Vec<ContactCardComparator>>,
    pub position: Option<i64>,
    pub anchor: Option<Id>,
    pub anchor_offset: Option<i64>,
    pub limit: Option<u64>,
    pub calculate_total: Option<bool>,
}

/// Arguments for ContactCard/queryChanges.
pub struct ContactCardQueryChangesRequest {
    pub since_query_state: State,
    pub filter: Option<ContactCardFilter>,
    pub sort: Option<Vec<ContactCardComparator>>,
    pub max_changes: Option<u64>,
    pub up_to_id: Option<Id>,
    pub calculate_total: Option<bool>,
}

/// Arguments for ContactCard/copy (RFC 8620 §5.4).
pub struct ContactCardCopyRequest {
    pub if_from_in_state: Option<State>,
    pub if_in_state: Option<State>,
    pub create: HashMap<String, ContactCardCopyEntry>,
    pub on_success_destroy_original: bool,  // default: false
}

pub struct ContactCardCopyEntry {
    pub id: Id,                                        // source card id
    pub address_book_ids: HashMap<Id, bool>,           // dest address books
}

/// Response for ContactCard/copy.
pub struct ContactCardCopyResponse {
    pub from_account_id: Id,
    pub account_id: Id,
    pub old_state: Option<State>,
    pub new_state: State,
    pub created: Option<HashMap<String, ContactCard>>,
    pub not_created: Option<HashMap<String, SetError>>,
}
```

## Module Layout

```
src/
  lib.rs          pub trait JmapContactsExt; impl JmapContactsExt for JmapClient;
                  re-exports of request/response types
  addressbook.rs  AddressBook/get, /changes, /set — request building and
                  response parsing; AddressBookSetRequest type
  card.rs         ContactCard/get, /changes, /query, /queryChanges, /set, /copy —
                  request building and response parsing;
                  ContactCardQueryRequest, ContactCardQueryChangesRequest,
                  ContactCardCopyRequest, ContactCardCopyResponse types
```

## Extras-preservation policy (JMAP-lbdy)

Every public method-argument struct defined in this crate that appears on
the JMAP wire carries an `extra` field per the workspace extras-preservation
policy (see workspace `AGENTS.md`):

```rust
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
```

This preserves vendor / site / private-extension fields across
deserialize/serialize round-trip. Wire format is byte-identical when extras
are empty. The `default` attribute is inert for Serialize-only method-arg
structs and active for Deserialize method-response structs; the attribute
set is kept uniform across both directions of wire flow.

In scope in this crate (each has at least one round-trip preservation
test in `methods/mod.rs` following the `*_propagates_vendor_extras`
pattern — construct + serialize):

- Method-argument structs (Serialize-only): `AddressBookSetParams`
  (carries the `AddressBook/set`-specific top-level arguments per
  draft-ietf-jmap-contacts-*).

The crate also re-exports standard response wrappers (`GetResponse<T>`,
`SetResponse<T>`, `ChangesResponse`, `QueryResponse`,
`QueryChangesResponse`) from `jmap-types`; those carry their own `extra`
field per JMAP-lbdy.1 and are not re-documented here.

Out of scope (explicitly excluded by the workspace policy):

- Filter / comparator algebra types and control enums — see workspace
  AGENTS.md "Filter algebra and control enums are explicitly EXCLUDED"
  for the full rationale.
- Internal Rust state types (`SessionClient`) — not wire-format.

### New-type rule

Any new public method-argument or method-response struct added to this
crate that appears on the JMAP wire MUST include the `extra` field from
day one with the documented serde attributes and at least one round-trip
preservation test. Per the canonical-template propagation rule (workspace
AGENTS.md), this crate is a sibling of the canonical `jmap-mail-client`;
changes to the extras shape here should mirror the canonical template
and propagate across all extension-client siblings in lock-step.

## Test Strategy

- All tests use `wiremock` (or equivalent mock HTTP server) via
  `jmap-base-client`'s HTTP layer — no live network
- Request serialization tests: construct a typed request, serialize to JSON,
  assert it matches the wire format expected by the spec
- Response deserialization tests: feed JSON from the spec examples (§4.1, §4.2),
  assert the typed structs are populated correctly
- Primary oracle: the §4.1 example exchange in RFC 9610

### Key test cases

- `address_book_get(None)` serializes to `{"accountId": ..., "ids": null}` and
  deserializes the §4.1 AddressBook response including `shareWith` and `myRights`
- `address_book_set` with `onSuccessSetIsDefault` serializes the extra field and
  deserializes the §4.2 response (two entries in `updated`)
- `contact_card_get(None)` deserializes the §4.1 ContactCard response including
  `addressBookIds`, `name.components`, and `emails` map
- `contact_card_query` with `ContactCardFilter::Condition(ContactCardFilterCondition
  { in_address_book: Some(id), ..Default::default() })` serializes `inAddressBook`
  correctly
- `contact_card_query` with sort by `"name/surname"` serializes correctly
- `contact_card_set` creates a new card; response includes server-assigned `id`
- `contact_card_copy` serializes `fromAccountId`, `accountId`, and the
  `addressBookIds` destination; deserializes the copy response

## Congruence with jmap-mail-client

| jmap-mail-client method | jmap-contacts-client method | Notes |
|---|---|---|
| `mailbox_get` | `address_book_get` | Same pattern; ids=None fetches all |
| `mailbox_set` | `address_book_set` | Extra args for both (onDestroy...) |
| `email_get` | `contact_card_get` | Both support partial properties |
| `email_set` | `contact_card_set` | Standard SetRequest<T> |
| `email_query` | `contact_card_query` | Typed filter + comparator |
| `email_changes` | `contact_card_changes` | Identical signature |
| (none) | `contact_card_query_changes` | Mail client lacks this method |
| (none) | `contact_card_copy` | Mail client has email_copy equivalent |