Skip to main content

jmap_chat_client/methods/
mod.rs

1//! Typed JMAP Chat method wrappers — response types, `Patch<T>`, SessionClient,
2//! input/patch structs, constants, and helpers.
3//!
4//! Response types mirror RFC 8620 standard shapes (§5.1 /get, §5.5 /query,
5//! §5.2 /changes, §5.3 /set). Method implementations live in sub-modules and
6//! operate on `SessionClient`.
7
8pub mod contact;
9pub mod misc;
10pub mod space_ban;
11pub mod space_invite;
12
13pub mod blob;
14pub mod custom_emoji;
15pub mod quota;
16
17use std::collections::HashMap;
18
19use serde::Deserialize;
20
21use jmap_types::Id;
22
23// ---------------------------------------------------------------------------
24// Response types (RFC 8620 §5)
25// ---------------------------------------------------------------------------
26//
27// Re-exported from `jmap-types::methods` so all `jmap-*-client` crates share
28// one canonical set of /get, /set, /changes, /query, /queryChanges shapes.
29// The wire format is identical to the previous local definitions.
30//
31// JMAP Chat extends `SetError` with a `serverRetryAfter` field for slow-mode
32// rate limiting. The base `SetError` captures unknown extension fields in
33// `extra` via `#[serde(flatten)]`; the [`server_retry_after`] free function
34// at the bottom of this module reads that field.
35
36pub use jmap_types::{
37    AddedItem, ChangesResponse, GetResponse, QueryChangesResponse, QueryResponse, SetError,
38    SetResponse,
39};
40
41/// Response to a `PushSubscription/set` create call (RFC 8620 §7.2).
42///
43/// `account_id` is always `null` for PushSubscription objects (they are not
44/// account-scoped). `Option<Id>` handles both the null case and servers that
45/// echo the session accountId anyway.
46#[non_exhaustive]
47#[derive(Debug, Clone, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct PushSubscriptionCreateResponse {
50    /// The account this response refers to. Always `None` for `PushSubscription`
51    /// (not account-scoped); preserved as `Option<Id>` for servers that echo it.
52    #[serde(default)]
53    pub account_id: Option<Id>,
54    /// Successfully created subscriptions, keyed by the caller-supplied creation key.
55    pub created: Option<HashMap<String, serde_json::Value>>,
56    /// Creation failures, keyed by the caller-supplied creation key.
57    #[serde(default)]
58    pub not_created: Option<HashMap<String, SetError>>,
59    /// Catch-all for vendor / site / private extension fields not covered
60    /// by the typed fields above. Preserves unknown fields across
61    /// deserialize/serialize round-trip per workspace extras-preservation
62    /// policy (see workspace AGENTS.md).
63    ///
64    /// **Constraint**: keys in `extra` MUST NOT collide with the
65    /// typed-field wire names above (the camelCase spelling — e.g.
66    /// `"accountId"`, `"ids"`, `"properties"`, `"blobIds"`,
67    /// `"fromAccountId"`, etc.). On collision the typed-field value
68    /// wins on the wire and the `extra` value is silently dropped at
69    /// serialization. Place vendor extensions under vendor-prefixed
70    /// keys (e.g. `"acmeCorpFoo"`) to avoid the collision class.
71    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
72    pub extra: serde_json::Map<String, serde_json::Value>,
73}
74
75/// Response to a `Chat/typing` call (JMAP Chat §Chat/typing).
76///
77/// The server echoes only `accountId`.
78#[non_exhaustive]
79#[derive(Debug, Clone, Deserialize)]
80#[serde(rename_all = "camelCase")]
81pub struct TypingResponse {
82    /// The account this response refers to.
83    pub account_id: Id,
84    /// Catch-all for vendor / site / private extension fields not covered
85    /// by the typed fields above. Preserves unknown fields across
86    /// deserialize/serialize round-trip per workspace extras-preservation
87    /// policy (see workspace AGENTS.md).
88    ///
89    /// **Constraint**: keys in `extra` MUST NOT collide with the
90    /// typed-field wire names above (the camelCase spelling — e.g.
91    /// `"accountId"`, `"ids"`, `"properties"`, `"blobIds"`,
92    /// `"fromAccountId"`, etc.). On collision the typed-field value
93    /// wins on the wire and the `extra` value is silently dropped at
94    /// serialization. Place vendor extensions under vendor-prefixed
95    /// keys (e.g. `"acmeCorpFoo"`) to avoid the collision class.
96    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
97    pub extra: serde_json::Map<String, serde_json::Value>,
98}
99
100/// Response to a `Space/join` call (JMAP Chat §Space/join).
101#[non_exhaustive]
102#[derive(Debug, Clone, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct SpaceJoinResponse {
105    /// The account this response refers to.
106    pub account_id: Id,
107    /// The JMAP id of the Space the caller is now a member of.
108    pub space_id: Id,
109    /// Catch-all for vendor / site / private extension fields not covered
110    /// by the typed fields above. Preserves unknown fields across
111    /// deserialize/serialize round-trip per workspace extras-preservation
112    /// policy (see workspace AGENTS.md).
113    ///
114    /// **Constraint**: keys in `extra` MUST NOT collide with the
115    /// typed-field wire names above (the camelCase spelling — e.g.
116    /// `"accountId"`, `"ids"`, `"properties"`, `"blobIds"`,
117    /// `"fromAccountId"`, etc.). On collision the typed-field value
118    /// wins on the wire and the `extra` value is silently dropped at
119    /// serialization. Place vendor extensions under vendor-prefixed
120    /// keys (e.g. `"acmeCorpFoo"`) to avoid the collision class.
121    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
122    pub extra: serde_json::Map<String, serde_json::Value>,
123}
124
125// ---------------------------------------------------------------------------
126// Patch<T>: three-way update value for nullable fields
127// ---------------------------------------------------------------------------
128
129/// Three-way patch value for nullable JMAP fields.
130///
131/// - `Keep` (default): the field is omitted from the patch — server leaves it unchanged.
132/// - `Set(v)`: the field is included with value `v`.
133/// - `Clear`: the field is included as JSON `null` (clears the server-side value).
134///
135/// Use `Patch::from(v)` to construct `Set(v)`. Use `Default::default()` or
136/// `Patch::Keep` to leave the field unchanged. Use `Patch::Clear` to set a
137/// nullable field to null explicitly.
138///
139/// # Serde usage
140///
141/// Fields of type `Patch<T>` **must** carry both attributes:
142/// ```ignore
143/// #[serde(default, skip_serializing_if = "Patch::is_keep")]
144/// pub my_field: Patch<String>,
145/// ```
146/// - `default`: absent JSON key → `Patch::Keep` (no change).
147/// - `skip_serializing_if`: omits the key from the output when the value is `Keep`.
148///
149/// Both attributes are required. Without `skip_serializing_if`, `Patch::Keep`
150/// serialises as a runtime error. Without `default`, an absent JSON key
151/// silently deserialises as `Patch::Clear` rather than `Patch::Keep` — a
152/// silent semantic corruption, not a parse error. See the doctest below
153/// for both failure modes.
154///
155/// # Deserialization
156///
157/// `Patch::Keep` is **not reachable from JSON deserialization**. The custom
158/// `Deserialize` impl maps JSON `null` → `Clear` and a JSON value → `Set(v)`.
159/// An absent key (via `#[serde(default)]`) produces `Keep` via `Default`.
160///
161/// # Failure modes if the attributes are wrong
162///
163/// The two `#[serde(...)]` attributes above are not enforced by the type
164/// system. Forgetting either of them produces a failure far from the
165/// declaration site — the doctest below documents what each failure looks
166/// like so downstream callers building their own `*Patch` structs can
167/// recognise them in production.
168///
169/// ```rust
170/// use jmap_chat_client::Patch;
171/// use serde::{Deserialize, Serialize};
172///
173/// // ── Correct: both attributes present ────────────────────────────────
174/// #[derive(Serialize, Deserialize, PartialEq, Debug)]
175/// struct GoodPatch {
176///     #[serde(default, skip_serializing_if = "Patch::is_keep")]
177///     name: Patch<String>,
178/// }
179///
180/// // `Patch::Keep` is the Default; absent JSON key deserialises to Keep.
181/// let v: GoodPatch = serde_json::from_str("{}").unwrap();
182/// assert_eq!(v.name, Patch::Keep);
183/// // `Patch::Keep` is omitted from the serialised output.
184/// assert_eq!(serde_json::to_string(&v).unwrap(), "{}");
185///
186/// // `Patch::Set(v)` serialises as `"name": v` and round-trips back.
187/// let v = GoodPatch { name: Patch::Set("Alice".into()) };
188/// let s = serde_json::to_string(&v).unwrap();
189/// assert_eq!(s, r#"{"name":"Alice"}"#);
190/// assert_eq!(serde_json::from_str::<GoodPatch>(&s).unwrap(), v);
191///
192/// // `Patch::Clear` serialises as `"name": null` and round-trips back.
193/// let v = GoodPatch { name: Patch::Clear };
194/// let s = serde_json::to_string(&v).unwrap();
195/// assert_eq!(s, r#"{"name":null}"#);
196/// assert_eq!(serde_json::from_str::<GoodPatch>(&s).unwrap(), v);
197///
198/// // ── Wrong: missing `default` ────────────────────────────────────────
199/// // Absent JSON key silently deserialises as `Patch::Clear` — NOT
200/// // `Patch::Keep` and NOT a missing-field error. This is the most
201/// // dangerous failure mode of the three because it surfaces no error
202/// // at all: a server response that omits an unchanged field is
203/// // interpreted by the client as "the server cleared this field".
204/// //
205/// // The mechanism: `Patch<T>`'s custom `Deserialize` impl calls
206/// // `Option::<T>::deserialize(d)`, and serde treats `Option`-shaped
207/// // deserializers as accepting absence by returning `None`. The custom
208/// // impl then maps `None` to `Patch::Clear`. `#[serde(default)]` is
209/// // what tells serde to skip calling the deserialize at all on absent
210/// // keys and use `Default::default()` (`Patch::Keep`) instead.
211/// #[derive(Deserialize, PartialEq, Debug)]
212/// struct MissingDefault {
213///     #[serde(skip_serializing_if = "Patch::is_keep")]
214///     name: Patch<String>,
215/// }
216/// let v: MissingDefault = serde_json::from_str("{}").unwrap();
217/// assert_eq!(v.name, Patch::Clear, "without #[serde(default)], absent key silently becomes Clear");
218///
219/// // ── Wrong: missing `skip_serializing_if` ───────────────────────────
220/// // The default `Patch::Keep` panics at serialise time with a custom
221/// // error from the `Serialize` impl in this crate. This surfaces every
222/// // time a caller builds a patch and leaves a field at its `Keep`
223/// // default — i.e. every partial update.
224/// #[derive(Serialize)]
225/// struct MissingSkip {
226///     #[serde(default)]
227///     name: Patch<String>,
228/// }
229/// let v = MissingSkip { name: Patch::Keep };
230/// let err = serde_json::to_string(&v).unwrap_err();
231/// assert!(
232///     err.to_string().contains("Patch::Keep cannot be serialized"),
233///     "expected Patch::Keep serialisation error, got: {err}"
234/// );
235/// ```
236///
237/// # Closed enum (no `#[non_exhaustive]`)
238///
239/// `Patch` is deliberately **not** marked `#[non_exhaustive]`. The three
240/// variants exhaustively model JMAP's tri-state patch semantic
241/// (RFC 8620 §5.3: absent key = no change; value = set; JSON `null` =
242/// clear), and no future spec extension is anticipated. Callers may
243/// `match` on `Patch<T>` without a wildcard arm. Every other public
244/// enum in this crate carries `#[non_exhaustive]`; this one is the
245/// principled exception per bd:JMAP-26di.48.
246#[derive(Debug, Default, Clone, PartialEq)]
247pub enum Patch<T> {
248    /// Omit the field from the patch — server leaves it unchanged.
249    #[default]
250    Keep,
251    /// Include the field with value `T`.
252    Set(T),
253    /// Include the field as JSON `null` (clears the server-side value).
254    Clear,
255}
256
257impl<T> Patch<T> {
258    /// Returns `true` if this is `Patch::Keep` (field should be omitted from serialization).
259    pub fn is_keep(&self) -> bool {
260        matches!(self, Patch::Keep)
261    }
262}
263
264impl<T> From<T> for Patch<T> {
265    fn from(v: T) -> Self {
266        Patch::Set(v)
267    }
268}
269
270impl<T: serde::Serialize> Patch<T> {
271    /// Returns `None` when `Keep` (omit key from patch),
272    /// `Some(Value::Null)` when `Clear`, or `Some(serialized_value)` when `Set`.
273    ///
274    /// Crate-internal helper for the `*_set` / `*_update` builders. Not
275    /// exposed publicly because (a) the return type would leak
276    /// `serde_json::Error` into the public SemVer surface (per
277    /// bd:JMAP-26di.35) and (b) external callers construct `Patch<T>`
278    /// via `Patch::from(v)` / `Patch::Clear` and let serde + the
279    /// `#[serde(skip_serializing_if = "Patch::is_keep")]` attribute
280    /// handle wire serialization — they have no reason to call this
281    /// helper directly.
282    pub(crate) fn map_entry(&self) -> Result<Option<serde_json::Value>, serde_json::Error> {
283        match self {
284            Patch::Keep => Ok(None),
285            Patch::Clear => Ok(Some(serde_json::Value::Null)),
286            Patch::Set(v) => serde_json::to_value(v).map(Some),
287        }
288    }
289}
290
291impl<T: serde::Serialize> serde::Serialize for Patch<T> {
292    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
293        match self {
294            Patch::Keep => Err(serde::ser::Error::custom(
295                "Patch::Keep cannot be serialized; add \
296                 #[serde(skip_serializing_if = \"Patch::is_keep\")] to the field",
297            )),
298            Patch::Clear => s.serialize_none(),
299            Patch::Set(v) => v.serialize(s),
300        }
301    }
302}
303
304impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Patch<T> {
305    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
306        // JSON absent (via #[serde(default)]) → Keep (default).
307        // JSON null → Clear. JSON value → Set(v).
308        Option::<T>::deserialize(d).map(|opt| match opt {
309            None => Patch::Clear,
310            Some(v) => Patch::Set(v),
311        })
312    }
313}
314
315// ---------------------------------------------------------------------------
316// Constants
317// ---------------------------------------------------------------------------
318
319/// The call-id embedded in every single-method JMAP request produced
320/// by this crate. Pass directly to
321/// [`jmap_base_client::extract_response`].
322///
323/// External callers need this constant when indexing into
324/// `SetResponse`-shaped `created` / `notCreated` / `updated` maps
325/// returned by methods that use a per-call client id. For example,
326/// after `blob_convert` callers look up the converted blob via
327/// `response.created.as_ref().and_then(|m| m.get(jmap_chat_client::methods::CALL_ID))`.
328pub const CALL_ID: &str = "r1";
329
330/// Capability URIs for standard JMAP Chat method calls (RFC 8620 §3.3).
331pub(crate) const USING_CHAT: &[&str] =
332    &["urn:ietf:params:jmap:core", jmap_chat_types::JMAP_CHAT_URI];
333
334/// Capability URIs for Quota method calls.
335pub(crate) const USING_QUOTA: &[&str] =
336    &["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:quota"];
337
338/// Capability URIs for PushSubscription method calls (RFC 8620 §7.2).
339pub(crate) const USING_CORE: &[&str] = &["urn:ietf:params:jmap:core"];
340
341/// Capability URIs for PushSubscription/set with chat push extension.
342pub(crate) const USING_CHAT_PUSH: &[&str] = &[
343    "urn:ietf:params:jmap:core",
344    jmap_chat_types::JMAP_CHAT_PUSH_URI,
345];
346
347// ---------------------------------------------------------------------------
348// build_request helper
349// ---------------------------------------------------------------------------
350
351/// Build a single-method JMAP request.
352///
353/// `using` is the complete `using` array for the request (RFC 8620 §3.3).
354/// Use the pre-defined constants [`USING_CHAT`], [`USING_QUOTA`], or
355/// [`USING_CORE`] to avoid per-call allocations.
356///
357/// The embedded call-id is [`CALL_ID`]; pass it directly to
358/// `jmap_base_client::extract_response`.
359pub(crate) fn build_request(
360    method: &str,
361    args: serde_json::Value,
362    using: &[&str],
363) -> jmap_types::JmapRequest {
364    let using_vec: Vec<String> = using.iter().map(|&s| s.to_owned()).collect();
365    let invocation: jmap_types::Invocation = (method.to_owned(), args, CALL_ID.to_owned());
366    jmap_types::JmapRequest::new(using_vec, vec![invocation], None)
367}
368
369// ---------------------------------------------------------------------------
370// resolve_client_id helper
371// ---------------------------------------------------------------------------
372
373/// Resolve an optional caller-supplied client ID, generating a ULID if absent.
374///
375/// Returns the supplied string unchanged, or a freshly generated ULID when
376/// `None` or empty.
377pub(crate) fn resolve_client_id(id: Option<&str>) -> String {
378    match id {
379        Some(s) if !s.is_empty() => s.to_owned(),
380        _ => ulid::Ulid::new().to_string(),
381    }
382}
383
384// ---------------------------------------------------------------------------
385// SessionClient — session-bound client
386// ---------------------------------------------------------------------------
387
388/// A `JmapClient` bound to a JMAP session.
389///
390/// Obtain via the chat extension methods that accept a `Session`. All JMAP
391/// Chat methods are available on this type without needing to pass `&Session`
392/// on every call.
393///
394/// # Session lifecycle
395///
396/// `SessionClient` captures the `Session` at construction time. JMAP sessions
397/// can expire; after re-fetching the session via `JmapClient::fetch_session`,
398/// construct a new `SessionClient` with the updated session. Reusing a stale
399/// `SessionClient` after session expiry will result in `unknownAccount` or
400/// similar errors from the server.
401///
402/// `Clone` is derived because `JmapClient` is itself cheap-to-clone (it
403/// already implements `Clone` and `with_chat_session` clones one
404/// internally), enabling parallel-task fan-out with one bound session.
405///
406/// # Concurrency
407///
408/// `SessionClient` is `Send + Sync` and may be used from any tokio
409/// task. Cloning is cheap: the underlying `JmapClient` wraps a
410/// `reqwest::Client` that is itself `Clone` over an internal `Arc`,
411/// so every clone shares the same HTTP connection pool. There is no
412/// benefit to constructing independent `SessionClient`s for
413/// per-worker fan-out — clone the existing one.
414///
415/// Concurrent calls on a single `SessionClient` (or on clones) are
416/// safe but **unordered**: `reqwest` does not guarantee that
417/// responses arrive in request order across concurrent
418/// in-flight calls. When ordering matters between method calls (e.g.
419/// a `Foo/set` followed by a `Foo/changes` that must observe the new
420/// state), use a single JMAP batch request with `ResultReference`s
421/// rather than two separate `SessionClient` calls.
422///
423/// All public async methods on `SessionClient` are cancel-safe at
424/// each `.await` point: dropping the future before completion
425/// releases the underlying `reqwest` request without poisoning the
426/// connection pool. The server may still process the request (HTTP
427/// is fire-and-forget on the wire); callers that care about
428/// at-most-once semantics must enforce idempotency at the JMAP layer
429/// (e.g. by sending a stable client-assigned creation key for
430/// `/set` create operations).
431///
432/// `Debug` is implemented manually to redact the inner `JmapClient` (which
433/// holds an HTTP client and is intentionally not `Debug` in
434/// `jmap-base-client`); only the `Session` is shown. This lets callers
435/// embed a `SessionClient` in a `#[derive(Debug)]` struct without manual
436/// impls of their own.
437///
438/// # Thread safety
439///
440/// `SessionClient` is `Send + Sync`. Both
441/// [`jmap_base_client::JmapClient`] (backed by `reqwest::Client`) and
442/// [`jmap_base_client::Session`] (plain serde-derived data) are
443/// `Send + Sync` per jmap-base-client's contract, so this type can be
444/// shared across async tasks via `Arc<SessionClient>` or cloned for
445/// per-task ownership.
446///
447/// A `Send + Sync` regression in a future jmap-base-client release
448/// would be a major-version-breaking change for this crate. A
449/// compile-time assertion in `methods/mod.rs` guards against the
450/// regression landing silently — see
451/// `_assert_session_client_send_sync`.
452#[non_exhaustive]
453#[derive(Clone)]
454pub struct SessionClient {
455    pub(crate) client: jmap_base_client::JmapClient,
456    pub(crate) session: jmap_base_client::Session,
457}
458
459impl std::fmt::Debug for SessionClient {
460    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
461        f.debug_struct("SessionClient")
462            // The inner JmapClient is not Debug — show a placeholder so
463            // callers know it is present without leaking HTTP-client
464            // internals.
465            .field("client", &"<JmapClient>")
466            .field("session", &self.session)
467            .finish()
468    }
469}
470
471impl SessionClient {
472    /// Borrow the underlying [`JmapClient`](jmap_base_client::JmapClient).
473    ///
474    /// Useful for ad-hoc operations outside the typed JMAP method surface —
475    /// for example, calling `JmapClient::upload` / `JmapClient::download_blob`,
476    /// or constructing a `JmapClient::event_source` subscription using the
477    /// bound session's `event_source_url`.
478    pub fn client(&self) -> &jmap_base_client::JmapClient {
479        &self.client
480    }
481
482    /// Borrow the captured [`Session`](jmap_base_client::Session).
483    ///
484    /// `SessionClient` captures the `Session` at construction time. After
485    /// re-fetching the session via `JmapClient::fetch_session`, callers
486    /// should construct a new `SessionClient`. This accessor lets a caller
487    /// compare the captured session's `state` field against a freshly
488    /// fetched session to detect staleness, or inspect
489    /// `accountCapabilities` / `primary_accounts` for capability-specific
490    /// metadata not exposed via the typed JMAP method surface.
491    pub fn session(&self) -> &jmap_base_client::Session {
492        &self.session
493    }
494
495    /// Return the primary account id for `urn:ietf:params:jmap:chat`,
496    /// or `Err(InvalidSession)` if the session has no primary account for
497    /// that capability.
498    ///
499    /// # Errors
500    ///
501    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
502    ///   if the session has no primary account for the chat capability
503    ///   URI. This is the only failure mode — the method is a pure
504    ///   accessor on the captured session and does no network I/O.
505    pub fn chat_account_id(&self) -> Result<&str, jmap_base_client::ClientError> {
506        self.session
507            .primary_account_id(jmap_chat_types::JMAP_CHAT_URI)
508            .ok_or_else(|| {
509                jmap_base_client::ClientError::InvalidSession(
510                    "no primary account for urn:ietf:params:jmap:chat".into(),
511                )
512            })
513    }
514
515    /// Extract `(api_url, chat_account_id)` from the bound session.
516    ///
517    /// Returns `Err(InvalidSession)` if there is no primary account for
518    /// `urn:ietf:params:jmap:chat`.
519    pub(crate) fn session_parts(&self) -> Result<(&str, &str), jmap_base_client::ClientError> {
520        let api_url = self.session.api_url.as_str();
521        let account_id = self
522            .session
523            .primary_account_id(jmap_chat_types::JMAP_CHAT_URI)
524            .ok_or_else(|| {
525                jmap_base_client::ClientError::InvalidSession(
526                    "no primary account for urn:ietf:params:jmap:chat".into(),
527                )
528            })?;
529        Ok((api_url, account_id))
530    }
531
532    /// The JMAP API URL from the bound session.
533    pub(crate) fn api_url(&self) -> &str {
534        self.session.api_url.as_str()
535    }
536
537    /// Forward a JMAP request to the underlying HTTP client.
538    pub(crate) async fn call_internal(
539        &self,
540        api_url: &str,
541        req: &jmap_types::JmapRequest,
542    ) -> Result<jmap_types::JmapResponse, jmap_base_client::ClientError> {
543        self.client.call(api_url, req).await
544    }
545}
546
547/// Compile-time assertion that [`SessionClient`] is `Send + Sync`.
548///
549/// The `# Thread safety` section of [`SessionClient`]'s rustdoc promises
550/// auto-trait inheritance from
551/// [`jmap_base_client::JmapClient`] and
552/// [`jmap_base_client::Session`]. If a future jmap-base-client release
553/// adds a `!Sync` interior-mutability field to either, this assertion
554/// fails at compile time — flagging the regression at the dependency
555/// upgrade rather than at the downstream caller's "cannot send between
556/// threads safely" error.
557#[allow(dead_code)]
558fn _assert_session_client_send_sync() {
559    fn assert_send_sync<T: Send + Sync>() {}
560    assert_send_sync::<SessionClient>();
561}
562
563// ---------------------------------------------------------------------------
564// Input/patch types for methods with many optional parameters
565// ---------------------------------------------------------------------------
566
567/// Input parameters for `Chat/query`.
568#[non_exhaustive]
569#[derive(Debug, Default, Clone)]
570pub struct ChatQueryInput {
571    /// Filter to chats of the given kind (`direct`, `group`, or `channel`).
572    pub filter_kind: Option<jmap_chat_types::ChatKind>,
573    /// Filter to muted (`true`) or unmuted (`false`) chats.
574    pub filter_muted: Option<bool>,
575    /// Zero-based starting offset within the query result.
576    pub position: Option<u64>,
577    /// Maximum number of ids to return.
578    pub limit: Option<u64>,
579}
580
581/// Input parameters for `Message/query`.
582#[non_exhaustive]
583#[derive(Debug, Default, Clone)]
584pub struct MessageQueryInput<'a> {
585    /// Restrict to messages in a specific Chat.
586    pub chat_id: Option<&'a Id>,
587    /// Filter to messages that mention (`true`) or do not mention (`false`) the caller.
588    pub has_mention: Option<bool>,
589    /// Filter to messages that carry (`true`) or do not carry (`false`) attachments.
590    pub has_attachment: Option<bool>,
591    /// Full-text search query against the message body.
592    pub text: Option<&'a str>,
593    /// Restrict to replies under this thread root.
594    pub thread_root_id: Option<&'a Id>,
595    /// Only include messages received after this time (exclusive).
596    pub after: Option<&'a jmap_types::UTCDate>,
597    /// Only include messages received before this time (exclusive).
598    pub before: Option<&'a jmap_types::UTCDate>,
599    /// Zero-based starting offset within the query result.
600    pub position: Option<u64>,
601    /// Maximum number of ids to return.
602    pub limit: Option<u64>,
603    /// Sort by `sentAt` ascending (oldest first) when `true`.
604    /// Defaults to `false` (descending, newest first), so `position:0, limit:N`
605    /// returns the N most recent messages.
606    pub sort_ascending: bool,
607}
608
609impl<'a> MessageQueryInput<'a> {
610    /// Set ascending sort order (oldest first).
611    pub fn with_sort_ascending(mut self, v: bool) -> Self {
612        self.sort_ascending = v;
613        self
614    }
615}
616
617/// Input parameters for `Message/set` create.
618#[non_exhaustive]
619#[derive(Debug, Clone)]
620pub struct MessageCreateInput<'a> {
621    /// Caller-supplied creation key. When `None` — **or `Some("")`** — a ULID
622    /// is generated automatically. An empty `Some("")` is treated the same
623    /// as `None`, not as a rejection; callers must not assume the wire
624    /// echoes a caller-supplied empty string.
625    pub client_id: Option<&'a str>,
626    /// The Chat this message belongs to.
627    pub chat_id: &'a Id,
628    /// Message body text (interpreted per `body_type`).
629    ///
630    /// Length is not checked on the client. The server enforces
631    /// [`ChatCapability::max_body_bytes`](crate::ChatCapability) (UTF-8
632    /// byte count for `text/*` body types, raw byte count otherwise);
633    /// oversize bodies surface as a server-side `tooLarge`
634    /// [`SetError`] on the `created` entry, not as a client-side
635    /// rejection.
636    pub body: &'a str,
637    /// MIME type for the message body.
638    pub body_type: jmap_chat_types::BodyType,
639    /// RFC 3339 timestamp.
640    ///
641    /// [`UTCDate`](jmap_types::UTCDate) is a transparent newtype around
642    /// `String` and does not validate the RFC 3339 format at
643    /// construction time. A malformed value forwards to the server,
644    /// which rejects it with `invalidArguments`. Construct values via a
645    /// validating helper (e.g. `chrono::DateTime::<Utc>::to_rfc3339`)
646    /// rather than from arbitrary strings.
647    pub sent_at: &'a jmap_types::UTCDate,
648    /// When `Some`, marks this message as a reply to the given message id.
649    pub reply_to: Option<&'a Id>,
650}
651
652impl<'a> MessageCreateInput<'a> {
653    /// Create a `MessageCreateInput` with required fields; optional fields default to `None`.
654    pub fn new(
655        chat_id: &'a Id,
656        body: &'a str,
657        body_type: jmap_chat_types::BodyType,
658        sent_at: &'a jmap_types::UTCDate,
659    ) -> Self {
660        Self {
661            client_id: None,
662            chat_id,
663            body,
664            body_type,
665            sent_at,
666            reply_to: None,
667        }
668    }
669
670    /// Set the caller-supplied creation key (overrides the auto-generated ULID).
671    pub fn with_client_id(mut self, id: &'a str) -> Self {
672        self.client_id = Some(id);
673        self
674    }
675
676    /// Set the message this one replies to.
677    pub fn with_reply_to(mut self, id: &'a Id) -> Self {
678        self.reply_to = Some(id);
679        self
680    }
681}
682
683/// A single reaction change in a `Message/set` patch (JMAP Chat §4.5).
684///
685/// The patch key is `reactions/<senderReactionId>` (JSON Pointer).
686/// `senderReactionId` is a caller-generated ID (e.g. ULID) that uniquely
687/// identifies this reaction slot for the sending user in this message.
688///
689/// # Precondition: `sender_reaction_id`
690///
691/// On both variants, `sender_reaction_id` is embedded in a JSON Pointer
692/// (RFC 6901) as the patch key `reactions/<id>`.
693/// [`SessionClient::message_update`](crate::SessionClient::message_update)
694/// rejects an `Add` or `Remove` whose `sender_reaction_id` is empty, or
695/// contains `/` or `~`, with
696/// [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument).
697/// The slash/tilde restriction is unintuitive — emoji-shortcode systems
698/// (e.g. `:slight_smile:/+1`) and any user-supplied string source naturally
699/// produce these characters. Generate IDs from a constrained alphabet
700/// (ULID, UUID v4, base64url) rather than from user input.
701#[non_exhaustive]
702#[derive(Debug)]
703pub enum ReactionChange<'a> {
704    /// Add a reaction. Patch value: `{emoji, sentAt}`.
705    Add {
706        /// Caller-generated id (e.g. ULID) identifying this reaction slot.
707        ///
708        /// **Precondition**: must be non-empty and must NOT contain `/`
709        /// or `~`. See the enum-level "Precondition" section.
710        sender_reaction_id: &'a str,
711        /// Emoji shortcode or Unicode emoji to react with.
712        emoji: &'a str,
713        /// RFC 3339 timestamp when the reaction was made.
714        sent_at: &'a jmap_types::UTCDate,
715    },
716    /// Remove a reaction. Patch value: null.
717    Remove {
718        /// Caller-generated id identifying the reaction slot to remove.
719        ///
720        /// **Precondition**: must be non-empty and must NOT contain `/`
721        /// or `~`. See the enum-level "Precondition" section.
722        sender_reaction_id: &'a str,
723    },
724}
725
726/// Patch parameters for `Message/set` update.
727///
728/// All fields are optional; absent fields (i.e. `None`) are not included in
729/// the patch (the server leaves them unchanged).
730///
731/// Use `..Default::default()` to fill in unused fields.
732#[non_exhaustive]
733#[derive(Debug, Default)]
734pub struct MessagePatch<'a> {
735    /// New message body text (author-only edit).
736    pub body: Option<&'a str>,
737    /// MIME type for `body`. Set alongside `body` in author-only edits.
738    pub body_type: Option<jmap_chat_types::BodyType>,
739    /// Reaction changes to apply. `None` (default) = no reaction changes.
740    pub reaction_changes: Option<&'a [ReactionChange<'a>]>,
741    /// Set the read-receipt timestamp (`Message.readAt`).
742    pub read_at: Option<&'a jmap_types::UTCDate>,
743    /// Set the read disposition recorded alongside `read_at`
744    /// (draft-atwood-jmap-chat-00 §Message/set update, line 1012).
745    ///
746    /// Setting `read_at` without `read_disposition` causes the server to
747    /// store `"displayed"` (§Message line 540). Supplying both lets the
748    /// client pick `Deleted` or `Processed` explicitly, or use
749    /// `Other("...")` for a vendor / future disposition value. Clients
750    /// SHOULD only set this when they also set `read_at`.
751    pub read_disposition: Option<jmap_chat_types::ReadDisposition>,
752    /// Set the deletion timestamp for soft/hard delete.
753    pub deleted_at: Option<&'a jmap_types::UTCDate>,
754    /// When `Some(true)` and `deleted_at` is also set, deletes for all
755    /// participants (server sends `Peer/retract`).
756    pub deleted_for_all: Option<bool>,
757}
758
759/// Patch parameters for `PresenceStatus/set` update.
760///
761/// All fields are optional. A field that is `Patch::Keep` (default) is omitted
762/// from the patch, leaving the server value unchanged. Use `Patch::Set(v)` to
763/// set a value and `Patch::Clear` to null-clear a nullable field.
764///
765/// Use `..Default::default()` to fill in unused fields.
766#[non_exhaustive]
767#[derive(Debug, Default)]
768pub struct PresenceStatusPatch<'a> {
769    /// New presence state. `None` = no change.
770    pub presence: Option<jmap_chat_types::Presence>,
771    /// Free-text status message. [`Patch::Clear`] clears; [`Patch::Set`] sets.
772    pub status_text: Patch<&'a str>,
773    /// Status emoji. [`Patch::Clear`] clears; [`Patch::Set`] sets.
774    pub status_emoji: Patch<&'a str>,
775    /// Set or clear the auto-clear deadline. `Patch::Clear` removes any deadline.
776    pub expires_at: Patch<&'a jmap_types::UTCDate>,
777    /// Whether read receipts are shared with peers. `None` = no change.
778    pub receipt_sharing: Option<bool>,
779}
780
781/// Input parameters for `CustomEmoji/query`.
782#[non_exhaustive]
783#[derive(Debug, Default, Clone)]
784pub struct CustomEmojiQueryInput<'a> {
785    /// Filter to a specific Space's custom emojis. `None` returns all emojis
786    /// visible to the account (Space-specific + server-global).
787    pub filter_space_id: Option<&'a Id>,
788    /// Zero-based starting offset within the query result.
789    pub position: Option<u64>,
790    /// Maximum number of ids to return.
791    pub limit: Option<u64>,
792}
793
794/// Parameters for creating one CustomEmoji via `CustomEmoji/set`.
795#[non_exhaustive]
796#[derive(Debug, Clone)]
797pub struct CustomEmojiCreateInput<'a> {
798    /// Caller-supplied creation key. When `None` — **or `Some("")`** — a ULID
799    /// is generated automatically. An empty `Some("")` is treated the same
800    /// as `None`, not as a rejection; callers must not assume the wire
801    /// echoes a caller-supplied empty string.
802    pub client_id: Option<&'a str>,
803    /// Shortcode name without colons (e.g., `catjam`).
804    pub name: &'a str,
805    /// blobId of the emoji image (already uploaded).
806    pub blob_id: &'a Id,
807    /// If `Some`, limits the emoji to the given Space. `None` = server-global.
808    pub space_id: Option<&'a Id>,
809}
810
811impl<'a> CustomEmojiCreateInput<'a> {
812    /// Create a `CustomEmojiCreateInput` with required fields; optional fields default to `None`.
813    pub fn new(name: &'a str, blob_id: &'a Id) -> Self {
814        Self {
815            client_id: None,
816            name,
817            blob_id,
818            space_id: None,
819        }
820    }
821
822    /// Set the caller-supplied creation key (overrides the auto-generated ULID).
823    pub fn with_client_id(mut self, id: &'a str) -> Self {
824        self.client_id = Some(id);
825        self
826    }
827}
828
829/// Parameters for creating one SpaceInvite via `SpaceInvite/set`.
830#[non_exhaustive]
831#[derive(Debug, Clone)]
832pub struct SpaceInviteCreateInput<'a> {
833    /// Caller-supplied creation key. When `None` — **or `Some("")`** — a ULID
834    /// is generated automatically. An empty `Some("")` is treated the same
835    /// as `None`, not as a rejection; callers must not assume the wire
836    /// echoes a caller-supplied empty string.
837    pub client_id: Option<&'a str>,
838    /// The Space this invite grants access to.
839    pub space_id: &'a Id,
840    /// Channel that joining members land in by default. `None` lets the server choose.
841    pub default_channel_id: Option<&'a Id>,
842    /// Optional expiry time after which the invite is no longer redeemable.
843    pub expires_at: Option<&'a jmap_types::UTCDate>,
844    /// Maximum number of times the invite may be redeemed.
845    pub max_uses: Option<u64>,
846}
847
848impl<'a> SpaceInviteCreateInput<'a> {
849    /// Create a `SpaceInviteCreateInput` with required fields; optional fields default to `None`.
850    pub fn new(space_id: &'a Id) -> Self {
851        Self {
852            client_id: None,
853            space_id,
854            default_channel_id: None,
855            expires_at: None,
856            max_uses: None,
857        }
858    }
859
860    /// Set the caller-supplied creation key (overrides the auto-generated ULID).
861    pub fn with_client_id(mut self, id: &'a str) -> Self {
862        self.client_id = Some(id);
863        self
864    }
865
866    /// Set the maximum number of times this invite may be used.
867    pub fn with_max_uses(mut self, max: u64) -> Self {
868        self.max_uses = Some(max);
869        self
870    }
871}
872
873/// Parameters for creating one SpaceBan via `SpaceBan/set`.
874#[non_exhaustive]
875#[derive(Debug, Clone)]
876pub struct SpaceBanCreateInput<'a> {
877    /// Caller-supplied creation key. When `None` — **or `Some("")`** — a ULID
878    /// is generated automatically. An empty `Some("")` is treated the same
879    /// as `None`, not as a rejection; callers must not assume the wire
880    /// echoes a caller-supplied empty string.
881    pub client_id: Option<&'a str>,
882    /// The Space this ban applies to.
883    pub space_id: &'a Id,
884    /// ChatContact.id of the user to ban.
885    pub user_id: &'a Id,
886    /// Optional human-readable reason for the ban.
887    pub reason: Option<&'a str>,
888    /// Optional expiry time after which the ban is automatically lifted.
889    pub expires_at: Option<&'a jmap_types::UTCDate>,
890}
891
892impl<'a> SpaceBanCreateInput<'a> {
893    /// Create a `SpaceBanCreateInput` with required fields; optional fields default to `None`.
894    pub fn new(space_id: &'a Id, user_id: &'a Id) -> Self {
895        Self {
896            client_id: None,
897            space_id,
898            user_id,
899            reason: None,
900            expires_at: None,
901        }
902    }
903
904    /// Set the caller-supplied creation key (overrides the auto-generated ULID).
905    pub fn with_client_id(mut self, id: &'a str) -> Self {
906        self.client_id = Some(id);
907        self
908    }
909}
910
911/// Patch parameters for `ChatContact/set` update.
912///
913/// All fields are optional; absent fields are omitted from the patch. For the
914/// nullable `display_name` field, use `Patch::Set(s)` to set and `Patch::Clear`
915/// to clear. Use `..Default::default()` to fill in unused fields.
916#[non_exhaustive]
917#[derive(Debug, Default)]
918pub struct ChatContactPatch<'a> {
919    /// Set or unset the blocked flag on this contact. `None` = no change.
920    pub blocked: Option<bool>,
921    /// `Patch::Clear` clears `displayName`; `Patch::Set(s)` sets it.
922    pub display_name: Patch<&'a str>,
923}
924
925/// Sort property for `ChatContact/query`.
926#[non_exhaustive]
927#[derive(Debug, Clone, PartialEq, serde::Serialize)]
928#[serde(rename_all = "camelCase")]
929pub enum ContactSortProperty {
930    /// Sort by the contact's `lastSeenAt` timestamp.
931    LastSeenAt,
932    /// Sort by the contact's `login` identifier.
933    Login,
934    /// Sort by the contact's `lastActiveAt` timestamp.
935    LastActiveAt,
936}
937
938/// Input parameters for `ChatContact/query`.
939///
940/// All fields are optional; an empty filter shows all contacts.
941#[non_exhaustive]
942#[derive(Debug, Default, Clone)]
943pub struct ChatContactQueryInput {
944    /// Filter to blocked (`true`) or non-blocked (`false`) contacts.
945    pub filter_blocked: Option<bool>,
946    /// Filter to contacts with this exact presence state.
947    pub filter_presence: Option<crate::types::ContactPresenceFilter>,
948    /// Zero-based starting offset within the query result.
949    pub position: Option<u64>,
950    /// Maximum number of ids to return.
951    pub limit: Option<u64>,
952    /// Sort property.
953    pub sort_property: Option<ContactSortProperty>,
954    /// When `Some(false)` or `None`, sort descending. `Some(true)` sorts ascending.
955    pub sort_ascending: Option<bool>,
956}
957
958/// Input parameters for `Space/set` create.
959#[non_exhaustive]
960#[derive(Debug, Clone)]
961pub struct SpaceCreateInput<'a> {
962    /// Caller-supplied creation key. When `None` — **or `Some("")`** — a ULID
963    /// is generated automatically. An empty `Some("")` is treated the same
964    /// as `None`, not as a rejection; callers must not assume the wire
965    /// echoes a caller-supplied empty string.
966    pub client_id: Option<&'a str>,
967    /// Display name for the Space.
968    pub name: &'a str,
969    /// Optional human-readable description.
970    pub description: Option<&'a str>,
971    /// Optional blob id of an already-uploaded icon image.
972    pub icon_blob_id: Option<&'a Id>,
973}
974
975impl<'a> SpaceCreateInput<'a> {
976    /// Create a `SpaceCreateInput` with required fields; optional fields default to `None`.
977    pub fn new(name: &'a str) -> Self {
978        Self {
979            client_id: None,
980            name,
981            description: None,
982            icon_blob_id: None,
983        }
984    }
985
986    /// Set the caller-supplied creation key (overrides the auto-generated ULID).
987    pub fn with_client_id(mut self, id: &'a str) -> Self {
988        self.client_id = Some(id);
989        self
990    }
991}
992
993/// Input parameters for `Space/query`.
994#[non_exhaustive]
995#[derive(Debug, Default, Clone)]
996pub struct SpaceQueryInput<'a> {
997    /// Filter by substring match on Space name.
998    pub filter_name: Option<&'a str>,
999    /// Filter to public (`true`) or non-public (`false`) Spaces.
1000    pub filter_is_public: Option<bool>,
1001    /// Zero-based starting offset within the query result.
1002    pub position: Option<u64>,
1003    /// Maximum number of ids to return.
1004    pub limit: Option<u64>,
1005}
1006
1007/// How to join a Space — passed to `Space/join`.
1008///
1009/// The enum makes invalid inputs unrepresentable: exactly one path is always
1010/// selected at construction time.
1011///
1012/// # Debug redaction
1013///
1014/// The `InviteCode` variant wraps the unguessable bearer credential from
1015/// draft-atwood-jmap-chat-00 §4.18 — anyone with the code can redeem it to
1016/// join the Space. The `Debug` impl on this enum redacts the inner string to
1017/// `"[REDACTED]"` so an accidental `{:?}`-format in an application log,
1018/// tracing span, or test fixture cannot leak it. The `SpaceId` variant is
1019/// not a secret (RFC 8620 §1.2) and is rendered verbatim.
1020#[non_exhaustive]
1021pub enum SpaceJoinInput<'a> {
1022    /// Redeem a SpaceInvite by its `code` field (not its `id`).
1023    ///
1024    /// Unguessable secret — redacted by the [`std::fmt::Debug`] impl on this enum.
1025    ///
1026    /// **Precondition**: must be non-empty.
1027    /// [`Self::space_join`](crate::SessionClient::space_join) rejects
1028    /// an empty value client-side with
1029    /// [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument).
1030    InviteCode(&'a str),
1031    /// Join a public Space directly by its JMAP id.
1032    ///
1033    /// **Precondition**: must be a non-empty Id. [`Id::from`] is the
1034    /// lenient constructor and accepts any string including `""`;
1035    /// `Id::new_validated` is the strict one. An empty Id forwards to
1036    /// the server unchanged, which rejects it with `invalidArguments`.
1037    /// Construct Ids via the validating path when the value comes from
1038    /// untrusted input.
1039    SpaceId(&'a Id),
1040}
1041
1042impl<'a> std::fmt::Debug for SpaceJoinInput<'a> {
1043    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1044        match self {
1045            Self::InviteCode(_) => f.debug_tuple("InviteCode").field(&"[REDACTED]").finish(),
1046            Self::SpaceId(id) => f.debug_tuple("SpaceId").field(id).finish(),
1047        }
1048    }
1049}
1050
1051/// One entry in the `addMembers` patch key for `Chat/set` update.
1052#[non_exhaustive]
1053#[derive(Debug, Clone)]
1054pub struct AddMemberInput<'a> {
1055    /// ChatContact.id of the member to add.
1056    pub id: &'a Id,
1057    /// Role for the new member. `None` lets the server apply the default (`"member"`).
1058    pub role: Option<crate::types::ChatMemberRole>,
1059}
1060
1061impl<'a> AddMemberInput<'a> {
1062    /// Create an `AddMemberInput`; `role` defaults to `None` (server assigns default).
1063    pub fn new(id: &'a Id) -> Self {
1064        Self { id, role: None }
1065    }
1066
1067    /// Set the role for this member.
1068    pub fn with_role(mut self, role: crate::types::ChatMemberRole) -> Self {
1069        self.role = Some(role);
1070        self
1071    }
1072}
1073
1074/// One entry in the `updateMemberRoles` patch key for `Chat/set` update.
1075#[non_exhaustive]
1076#[derive(Debug, Clone)]
1077pub struct UpdateMemberRoleInput<'a> {
1078    /// ChatContact.id of the member to update.
1079    pub id: &'a Id,
1080    /// New role for this member.
1081    pub role: crate::types::ChatMemberRole,
1082}
1083
1084impl<'a> UpdateMemberRoleInput<'a> {
1085    /// Create an `UpdateMemberRoleInput` with the target member and their new role.
1086    pub fn new(id: &'a Id, role: crate::types::ChatMemberRole) -> Self {
1087        Self { id, role }
1088    }
1089}
1090
1091/// Input parameters for `Chat/set` create.
1092///
1093/// Discriminates the two user-creatable Chat kinds from the spec. Each
1094/// variant carries the fields required for that kind plus an optional
1095/// `client_id`; when `None` — or `Some("")` — a ULID is generated
1096/// automatically (see the per-variant `client_id` doc).
1097///
1098/// Channel Chats are NOT created via `Chat/set`. Per
1099/// draft-atwood-jmap-chat-00 §Chat (line 436), Channel Chats are created
1100/// as part of a Space via the `addChannels` patch key in `Space/set`
1101/// (see [`SpacePatch::add_channels`] and [`SpaceAddChannelInput`]); the
1102/// server assigns the channel's chatId at that time. A spec-compliant
1103/// server will reject a `Chat/set` create with `kind: "channel"`.
1104#[non_exhaustive]
1105#[derive(Debug)]
1106pub enum ChatCreateInput<'a> {
1107    /// Create a direct (one-to-one) chat.
1108    Direct {
1109        /// Caller-supplied creation key. When `None` — **or `Some("")`** —
1110        /// a ULID is generated automatically. An empty `Some("")` is
1111        /// treated the same as `None`, not as a rejection.
1112        client_id: Option<&'a str>,
1113        /// ChatContact.id of the other participant.
1114        contact_id: &'a Id,
1115    },
1116    /// Create a group chat.
1117    Group {
1118        /// Caller-supplied creation key. When `None` — **or `Some("")`** —
1119        /// a ULID is generated automatically. An empty `Some("")` is
1120        /// treated the same as `None`, not as a rejection.
1121        client_id: Option<&'a str>,
1122        /// Display name for the group.
1123        name: &'a str,
1124        /// ChatContact.ids of initial non-owner members.
1125        member_ids: &'a [Id],
1126        /// Optional human-readable description.
1127        description: Option<&'a str>,
1128        /// Blob id of an already-uploaded avatar image, if any.
1129        avatar_blob_id: Option<&'a Id>,
1130        /// Optional auto-expiry interval applied to new messages.
1131        message_expiry_seconds: Option<u64>,
1132    },
1133}
1134
1135/// Patch parameters for `Chat/set` update.
1136///
1137/// All fields are optional; absent fields are not included in the patch (the
1138/// server leaves them unchanged). For nullable spec fields (`mute_until`,
1139/// `description`, `avatar_blob_id`) use `Patch::Set(v)` to set and
1140/// `Patch::Clear` to null-clear. Slice fields default to `None` (no change).
1141///
1142/// Use `..Default::default()` to fill in unused fields.
1143#[non_exhaustive]
1144#[derive(Debug, Default)]
1145pub struct ChatPatch<'a> {
1146    /// Mute or unmute this chat. `None` = no change.
1147    pub muted: Option<bool>,
1148    /// `Patch::Clear` clears `muteUntil`; `Patch::Set(t)` sets it.
1149    pub mute_until: Patch<&'a jmap_types::UTCDate>,
1150    /// Whether typing indicators from peers are surfaced to the caller. `None` = no change.
1151    pub receive_typing_indicators: Option<bool>,
1152    /// Replace the entire pinned-message list. `Some(&[])` clears all pins.
1153    pub pinned_message_ids: Option<&'a [Id]>,
1154    /// Local message-expiry policy in seconds (draft-atwood-jmap-chat-00
1155    /// §Chat line 505: `messageExpirySeconds (UnsignedInt, optional)`).
1156    /// `Patch::Clear` removes the policy server-side; `Patch::Set(n)` sets
1157    /// it; `Patch::Keep` (default) leaves it unchanged.
1158    pub message_expiry_seconds: Patch<u64>,
1159    /// Whether read receipts are shared with peers. `None` = no change.
1160    pub receipt_sharing: Option<bool>,
1161    /// New display name (group chats, admin only).
1162    pub name: Option<&'a str>,
1163    /// `Patch::Clear` clears; `Patch::Set(s)` sets (group chats, admin only).
1164    pub description: Patch<&'a str>,
1165    /// `Patch::Clear` clears; `Patch::Set(id)` sets (group chats, admin only).
1166    pub avatar_blob_id: Patch<&'a Id>,
1167    /// Members to add (group chats, admin only). `None` = no change.
1168    pub add_members: Option<&'a [AddMemberInput<'a>]>,
1169    /// ChatContact.ids to remove (group chats, admin only). `None` = no change.
1170    pub remove_members: Option<&'a [Id]>,
1171    /// Role changes for existing members (group chats, admin only). `None` = no change.
1172    pub update_member_roles: Option<&'a [UpdateMemberRoleInput<'a>]>,
1173}
1174
1175/// One member to add in the `addMembers` patch key of `Space/set` update.
1176#[non_exhaustive]
1177#[derive(Debug, Clone)]
1178pub struct SpaceAddMemberInput<'a> {
1179    /// ChatContact.id of the member to add.
1180    pub id: &'a Id,
1181    /// Initial role IDs for the new member. `None` grants no extra roles beyond `@everyone`.
1182    pub role_ids: Option<&'a [Id]>,
1183}
1184
1185impl<'a> SpaceAddMemberInput<'a> {
1186    /// Create a `SpaceAddMemberInput`; `role_ids` defaults to `None`.
1187    pub fn new(id: &'a Id) -> Self {
1188        Self { id, role_ids: None }
1189    }
1190}
1191
1192/// One member update in the `updateMembers` patch key of `Space/set` update.
1193#[non_exhaustive]
1194#[derive(Debug, Clone)]
1195pub struct SpaceUpdateMemberInput<'a> {
1196    /// ChatContact.id of the member to update.
1197    pub id: &'a Id,
1198    /// Replace the member's SpaceRole.id list. `None` = no change.
1199    pub role_ids: Option<&'a [Id]>,
1200    /// `Patch::Clear` clears the nick; `Patch::Set(s)` sets it.
1201    pub nick: Patch<&'a str>,
1202}
1203
1204impl<'a> SpaceUpdateMemberInput<'a> {
1205    /// Create a `SpaceUpdateMemberInput`; optional fields default to `None`/`Keep`.
1206    pub fn new(id: &'a Id) -> Self {
1207        Self {
1208            id,
1209            role_ids: None,
1210            nick: Patch::Keep,
1211        }
1212    }
1213}
1214
1215/// One channel to add in the `addChannels` patch key of `Space/set` update.
1216#[non_exhaustive]
1217#[derive(Debug, Clone)]
1218pub struct SpaceAddChannelInput<'a> {
1219    /// Channel display name.
1220    pub name: &'a str,
1221    /// Optional parent category id. `None` places the channel in `uncategorizedChannelIds`.
1222    pub category_id: Option<&'a Id>,
1223    /// Optional position within the category.
1224    pub position: Option<u64>,
1225    /// Optional channel topic.
1226    pub topic: Option<&'a str>,
1227}
1228
1229impl<'a> SpaceAddChannelInput<'a> {
1230    /// Create a `SpaceAddChannelInput`; optional fields default to `None`.
1231    pub fn new(name: &'a str) -> Self {
1232        Self {
1233            name,
1234            category_id: None,
1235            position: None,
1236            topic: None,
1237        }
1238    }
1239}
1240
1241/// One role to add in the `addRoles` patch key of `Space/set` update
1242/// (JMAP Chat §Space/set update / `manage_roles` permission).
1243///
1244/// The server assigns the role's ULID; the request never specifies an `id`.
1245/// Hierarchy enforcement: a member may only add roles whose `position` is
1246/// strictly less than their own highest-position role (server-enforced).
1247#[non_exhaustive]
1248#[derive(Debug, Clone)]
1249pub struct SpaceAddRoleInput<'a> {
1250    /// Human-readable role name.
1251    pub name: &'a str,
1252    /// Permission identifier strings, e.g. `"manage_channels"`.
1253    pub permissions: &'a [&'a str],
1254    /// Position in the role hierarchy. Lower values sort first.
1255    pub position: u64,
1256    /// Optional CSS-style color string (e.g. `"#ff8800"`). Pass `None` to omit.
1257    pub color: Option<&'a str>,
1258}
1259
1260impl<'a> SpaceAddRoleInput<'a> {
1261    /// Create a `SpaceAddRoleInput` with required fields; optional `color` defaults to `None`.
1262    pub fn new(name: &'a str, permissions: &'a [&'a str], position: u64) -> Self {
1263        Self {
1264            name,
1265            permissions,
1266            position,
1267            color: None,
1268        }
1269    }
1270
1271    /// Attach a color to the role.
1272    pub fn with_color(mut self, color: &'a str) -> Self {
1273        self.color = Some(color);
1274        self
1275    }
1276}
1277
1278/// One role update in the `updateRoles` patch key of `Space/set` update
1279/// (JMAP Chat §Space/set update / `manage_roles` permission).
1280///
1281/// Fields left at their default (`None` / [`Patch::Keep`]) are omitted from
1282/// the wire patch and the server leaves the corresponding property unchanged.
1283/// Hierarchy enforcement: a member may only modify roles whose `position` is
1284/// strictly less than their own highest-position role (server-enforced).
1285#[non_exhaustive]
1286#[derive(Debug, Clone)]
1287pub struct SpaceUpdateRoleInput<'a> {
1288    /// SpaceRole.id to update.
1289    pub id: &'a Id,
1290    /// New name. `None` = no change.
1291    pub name: Option<&'a str>,
1292    /// Set or clear the color. [`Patch::Clear`] removes any assigned color.
1293    pub color: Patch<&'a str>,
1294    /// Replace the permissions list. `None` = no change.
1295    pub permissions: Option<&'a [&'a str]>,
1296    /// New position in the role hierarchy. `None` = no change.
1297    pub position: Option<u64>,
1298}
1299
1300impl<'a> SpaceUpdateRoleInput<'a> {
1301    /// Create a `SpaceUpdateRoleInput`; optional fields default to `None`/`Keep`.
1302    pub fn new(id: &'a Id) -> Self {
1303        Self {
1304            id,
1305            name: None,
1306            color: Patch::Keep,
1307            permissions: None,
1308            position: None,
1309        }
1310    }
1311}
1312
1313/// One channel update in the `updateChannels` patch key of `Space/set` update
1314/// (JMAP Chat §Space/set update / `manage_channels` permission).
1315///
1316/// Fields left at their default (`None` / [`Patch::Keep`]) are omitted from
1317/// the wire patch and the server leaves the corresponding property unchanged.
1318#[non_exhaustive]
1319#[derive(Debug, Clone)]
1320pub struct SpaceUpdateChannelInput<'a> {
1321    /// Channel Chat id (kind `"channel"`, `spaceId` is this Space).
1322    pub id: &'a Id,
1323    /// New channel name. `None` = no change.
1324    pub name: Option<&'a str>,
1325    /// Set or clear the channel topic. [`Patch::Clear`] removes any assigned topic.
1326    pub topic: Patch<&'a str>,
1327    /// Set or clear the parent category. [`Patch::Clear`] moves the channel to
1328    /// the `uncategorizedChannelIds` list.
1329    pub category_id: Patch<&'a Id>,
1330    /// New position within its category. `None` = no change.
1331    pub position: Option<u64>,
1332    /// New slow-mode delay in seconds (`0` = disabled). `None` = no change.
1333    pub slow_mode_seconds: Option<u64>,
1334    /// Replace the permission-overrides list. `None` = no change.
1335    pub permission_overrides: Option<&'a [jmap_chat_types::ChannelPermission]>,
1336}
1337
1338impl<'a> SpaceUpdateChannelInput<'a> {
1339    /// Create a `SpaceUpdateChannelInput`; optional fields default to `None`/`Keep`.
1340    pub fn new(id: &'a Id) -> Self {
1341        Self {
1342            id,
1343            name: None,
1344            topic: Patch::Keep,
1345            category_id: Patch::Keep,
1346            position: None,
1347            slow_mode_seconds: None,
1348            permission_overrides: None,
1349        }
1350    }
1351}
1352
1353/// One category to add in the `addCategories` patch key of `Space/set` update
1354/// (JMAP Chat §Space/set update / `manage_channels` permission).
1355///
1356/// The server assigns the category's ULID; the request never specifies an `id`.
1357#[non_exhaustive]
1358#[derive(Debug, Clone)]
1359pub struct SpaceAddCategoryInput<'a> {
1360    /// Category display name.
1361    pub name: &'a str,
1362    /// Position relative to other categories. `None` lets the server append.
1363    pub position: Option<u64>,
1364    /// Initial member channel ids. `None` = empty category.
1365    pub channel_ids: Option<&'a [Id]>,
1366}
1367
1368impl<'a> SpaceAddCategoryInput<'a> {
1369    /// Create a `SpaceAddCategoryInput`; optional fields default to `None`.
1370    pub fn new(name: &'a str) -> Self {
1371        Self {
1372            name,
1373            position: None,
1374            channel_ids: None,
1375        }
1376    }
1377}
1378
1379/// One category update in the `updateCategories` patch key of `Space/set` update
1380/// (JMAP Chat §Space/set update / `manage_channels` permission).
1381///
1382/// Fields left at their default (`None`) are omitted from the wire patch and
1383/// the server leaves the corresponding property unchanged.
1384#[non_exhaustive]
1385#[derive(Debug, Clone)]
1386pub struct SpaceUpdateCategoryInput<'a> {
1387    /// Category id to update.
1388    pub id: &'a Id,
1389    /// New name. `None` = no change.
1390    pub name: Option<&'a str>,
1391    /// New position. `None` = no change.
1392    pub position: Option<u64>,
1393    /// Replace the member channel id list. `None` = no change.
1394    pub channel_ids: Option<&'a [Id]>,
1395}
1396
1397impl<'a> SpaceUpdateCategoryInput<'a> {
1398    /// Create a `SpaceUpdateCategoryInput`; optional fields default to `None`.
1399    pub fn new(id: &'a Id) -> Self {
1400        Self {
1401            id,
1402            name: None,
1403            position: None,
1404            channel_ids: None,
1405        }
1406    }
1407}
1408
1409/// Patch parameters for `Space/set` update.
1410///
1411/// All fields are optional. Absent fields are omitted from the patch.
1412/// Nullable fields (`description`, `icon_blob_id`) use `Patch::Set(v)` to set
1413/// and `Patch::Clear` to null-clear. Slice fields default to `None` (no change).
1414///
1415/// Use `..Default::default()` to fill in unused fields.
1416#[non_exhaustive]
1417#[derive(Debug, Default)]
1418pub struct SpacePatch<'a> {
1419    /// New display name (`manage_space` permission required).
1420    pub name: Option<&'a str>,
1421    /// `Patch::Clear` clears; `Patch::Set(s)` sets.
1422    pub description: Patch<&'a str>,
1423    /// `Patch::Clear` clears; `Patch::Set(id)` sets.
1424    pub icon_blob_id: Patch<&'a Id>,
1425    /// Toggle public-Space visibility. `None` = no change.
1426    pub is_public: Option<bool>,
1427    /// Toggle whether a public Space is previewable to non-members. `None` = no change.
1428    pub is_publicly_previewable: Option<bool>,
1429    /// Members to add (`manage_members` required). `None` = no change.
1430    pub add_members: Option<&'a [SpaceAddMemberInput<'a>]>,
1431    /// ChatContact.ids to remove (`manage_members` required). `None` = no change.
1432    pub remove_members: Option<&'a [Id]>,
1433    /// Member updates (`manage_members` required). `None` = no change.
1434    pub update_members: Option<&'a [SpaceUpdateMemberInput<'a>]>,
1435    /// Channels to add (`manage_channels` required). `None` = no change.
1436    pub add_channels: Option<&'a [SpaceAddChannelInput<'a>]>,
1437    /// Channel Chat ids to remove (`manage_channels` required). `None` = no change.
1438    pub remove_channels: Option<&'a [Id]>,
1439    /// Channel updates (`manage_channels` required). `None` = no change.
1440    pub update_channels: Option<&'a [SpaceUpdateChannelInput<'a>]>,
1441    /// Roles to add (`manage_roles` required). `None` = no change.
1442    pub add_roles: Option<&'a [SpaceAddRoleInput<'a>]>,
1443    /// SpaceRole.ids to remove (`manage_roles` required). `None` = no change.
1444    pub remove_roles: Option<&'a [Id]>,
1445    /// Role updates (`manage_roles` required). `None` = no change.
1446    pub update_roles: Option<&'a [SpaceUpdateRoleInput<'a>]>,
1447    /// Categories to add (`manage_channels` required). `None` = no change.
1448    pub add_categories: Option<&'a [SpaceAddCategoryInput<'a>]>,
1449    /// Category ids to remove (`manage_channels` required). `None` = no change.
1450    pub remove_categories: Option<&'a [Id]>,
1451    /// Category updates (`manage_channels` required). `None` = no change.
1452    pub update_categories: Option<&'a [SpaceUpdateCategoryInput<'a>]>,
1453}
1454
1455/// Input parameters for `PushSubscription/set` create (RFC 8620 §7.2).
1456///
1457/// Creates a PushSubscription with the optional `chatPush` extension
1458/// (draft-atwood-jmap-chat-push-00 §3.1).
1459///
1460/// `device_client_id` and `url` have no safe defaults and must always be supplied.
1461#[non_exhaustive]
1462#[derive(Debug, Clone)]
1463pub struct PushSubscriptionCreateInput<'a> {
1464    /// Caller-supplied creation key. When `None` — **or `Some("")`** — a ULID
1465    /// is generated automatically. An empty `Some("")` is treated the same
1466    /// as `None`, not as a rejection; callers must not assume the wire
1467    /// echoes a caller-supplied empty string.
1468    pub client_id: Option<&'a str>,
1469    /// Stable client device identifier, used by the server to deduplicate subscriptions.
1470    ///
1471    /// **Precondition**: must be non-empty.
1472    /// [`Self::push_subscription_create`](crate::SessionClient::push_subscription_create)
1473    /// rejects an empty value client-side with
1474    /// [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument).
1475    pub device_client_id: &'a str,
1476    /// Push endpoint URL registered with the platform push service.
1477    ///
1478    /// **Precondition**: must be non-empty.
1479    /// [`Self::push_subscription_create`](crate::SessionClient::push_subscription_create)
1480    /// rejects an empty value client-side with
1481    /// [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument).
1482    /// Malformed-but-non-empty URLs forward to the server, which rejects
1483    /// them with a `setError` on the creation entry. No client-side URL
1484    /// syntax check is performed.
1485    pub url: &'a str,
1486    /// Subscription expiry time. `None` lets the server choose.
1487    pub expires: Option<&'a jmap_types::UTCDate>,
1488    /// Data type names to include in StateChange notifications.
1489    /// `None` means the server delivers all changed types.
1490    pub types: Option<&'a [&'a str]>,
1491    /// Per-account ChatPushConfig entries for inline push. Each entry is
1492    /// `(accountId, config)`. Pass `None` to omit the `chatPush` property.
1493    ///
1494    /// # Why a slice-of-pairs, not a `HashMap`?
1495    ///
1496    /// The wire form is a JSON object keyed by accountId (a `HashMap`
1497    /// shape). The slice-of-pairs input form is deliberate:
1498    ///
1499    /// * Most callers know their per-account configs at construction
1500    ///   time and can supply them as an array literal; forcing
1501    ///   `&HashMap<Id, _>` would require a `.collect()` or a
1502    ///   `HashMap::from([...])` site at every call.
1503    /// * [`Id`] does not derive [`Ord`], so a
1504    ///   deterministic-iteration alternative would have to be
1505    ///   `BTreeMap<Id, _>`, which requires a workspace-wide Ord
1506    ///   propagation pass to add. `HashMap<Id, _>` would give
1507    ///   non-deterministic JSON output (test fixtures and snapshot
1508    ///   tests would become order-flaky).
1509    /// * The runtime duplicate-accountId check
1510    ///   ([`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument))
1511    ///   is small, shared between create and update via
1512    ///   `build_chat_push_map`, and has unit-test coverage.
1513    ///
1514    /// The trade-off is one runtime check per call site against an
1515    /// ergonomic gain for the common case. If a future workspace pass
1516    /// adds [`Ord`] to [`Id`], a follow-up bead can re-evaluate.
1517    pub chat_push: Option<&'a [(&'a Id, jmap_chat_types::ChatPushConfig)]>,
1518}
1519
1520impl<'a> PushSubscriptionCreateInput<'a> {
1521    /// Create a `PushSubscriptionCreateInput` with required fields; optional fields default to `None`.
1522    pub fn new(device_client_id: &'a str, url: &'a str) -> Self {
1523        Self {
1524            client_id: None,
1525            device_client_id,
1526            url,
1527            expires: None,
1528            types: None,
1529            chat_push: None,
1530        }
1531    }
1532
1533    /// Set the caller-supplied creation key (overrides the auto-generated ULID).
1534    pub fn with_client_id(mut self, id: &'a str) -> Self {
1535        self.client_id = Some(id);
1536        self
1537    }
1538
1539    /// Restrict StateChange notifications to these data type names.
1540    pub fn with_types(mut self, types: &'a [&'a str]) -> Self {
1541        self.types = Some(types);
1542        self
1543    }
1544
1545    /// Attach per-account ChatPushConfig entries for inline push.
1546    pub fn with_chat_push(
1547        mut self,
1548        chat_push: &'a [(&'a Id, jmap_chat_types::ChatPushConfig)],
1549    ) -> Self {
1550        self.chat_push = Some(chat_push);
1551        self
1552    }
1553}
1554
1555/// Patch shape for `PushSubscription/set` update sub-operations (RFC 8620 §7.2.2).
1556///
1557/// Only the patchable properties are exposed. RFC 8620 §7.2 declares `url`
1558/// and `keys` immutable: to change those, destroy the subscription and create
1559/// a new one. `device_client_id` is also stable for the lifetime of the
1560/// subscription.
1561///
1562/// Fields left as their default ([`Patch::Keep`] / `None` for
1563/// `verification_code`) are omitted from the wire patch and the server leaves
1564/// the corresponding property unchanged.
1565///
1566/// The `types` and `chat_push` fields use the three-way [`Patch<T>`] shape
1567/// so the (set + clear) conflict is unrepresentable at the type level:
1568///   - [`Patch::Set`] replaces the property wholesale (an array of type
1569///     names for `types`; a `chatPush` object built from the supplied
1570///     `(accountId, config)` pairs for `chat_push`).
1571///   - [`Patch::Clear`] sends JSON `null`, removing the property
1572///     server-side. For `types`, `null` means "deliver all types"
1573///     (RFC 8620 §7.2). For `chat_push`, `null` removes all inline-push
1574///     configuration (draft-atwood-jmap-chat-push-00 §3.1).
1575///   - [`Patch::Keep`] (default) omits the key from the patch.
1576///
1577/// The JMAP Chat Push extension does not define per-key patching of
1578/// `chatPush`, so [`Patch::Set`] replaces the full map.
1579///
1580/// # Debug redaction
1581///
1582/// `verification_code` is the RFC 8620 §7.2 push-subscription-ownership
1583/// proof — an attacker who learns the value can claim ownership of the
1584/// subscription. The `Debug` impl on this struct redacts it to
1585/// `Some("[REDACTED]")` / `None` so an accidental `{:?}`-format in an
1586/// application log, tracing span, or test fixture cannot leak it. Other
1587/// fields are not secrets and are rendered verbatim.
1588#[non_exhaustive]
1589#[derive(Default)]
1590pub struct PushSubscriptionPatch<'a> {
1591    /// Replace the verification code (set after receiving a PushVerification
1592    /// payload). `None` = no change.
1593    ///
1594    /// RFC 8620 §7.2 ownership proof — redacted by the
1595    /// [`std::fmt::Debug`] impl on this struct.
1596    pub verification_code: Option<&'a str>,
1597    /// Set or clear the expiry timestamp. [`Patch::Clear`] sets `expires` to
1598    /// `null`; the server SHOULD then choose a default expiry per RFC 8620 §7.2.
1599    pub expires: Patch<&'a jmap_types::UTCDate>,
1600    /// Replace or clear the `types` filter (RFC 8620 §7.2). [`Patch::Set`]
1601    /// installs the array of type names; [`Patch::Clear`] sends JSON `null`
1602    /// so the server delivers all types; [`Patch::Keep`] (default) leaves
1603    /// the property unchanged.
1604    pub types: Patch<&'a [&'a str]>,
1605    /// Replace or clear the `chatPush` extension property
1606    /// (draft-atwood-jmap-chat-push-00 §3.1). [`Patch::Set`] installs the
1607    /// `chatPush` map from the supplied `(accountId, config)` pairs;
1608    /// [`Patch::Clear`] sends JSON `null` so the server removes all inline
1609    /// push config; [`Patch::Keep`] (default) leaves the property
1610    /// unchanged.
1611    ///
1612    /// See the rustdoc on
1613    /// [`PushSubscriptionCreateInput::chat_push`] for the rationale
1614    /// behind the slice-of-pairs input shape (vs `HashMap<Id, _>`).
1615    pub chat_push: Patch<&'a [(&'a Id, jmap_chat_types::ChatPushConfig)]>,
1616}
1617
1618impl<'a> std::fmt::Debug for PushSubscriptionPatch<'a> {
1619    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1620        let redacted_verification_code: Option<&'static str> =
1621            self.verification_code.map(|_| "[REDACTED]");
1622        f.debug_struct("PushSubscriptionPatch")
1623            .field("verification_code", &redacted_verification_code)
1624            .field("expires", &self.expires)
1625            .field("types", &self.types)
1626            .field("chat_push", &self.chat_push)
1627            .finish()
1628    }
1629}
1630
1631// ---------------------------------------------------------------------------
1632// Method sub-modules
1633// ---------------------------------------------------------------------------
1634
1635pub mod chat;
1636pub mod message;
1637pub mod space;
1638
1639// ---------------------------------------------------------------------------
1640// SetError extension accessors (JMAP Chat slow-mode)
1641// ---------------------------------------------------------------------------
1642
1643/// Error from [`server_retry_after`] when the `serverRetryAfter` field is
1644/// present but doesn't parse as a [`jmap_types::UTCDate`].
1645///
1646/// The raw JSON value is preserved for diagnostics — callers can log it
1647/// to surface server-side bugs (e.g. emitting a numeric-seconds form
1648/// when the workspace convention is a UTCDate string).
1649#[non_exhaustive]
1650#[derive(Debug, Clone, PartialEq, Eq)]
1651pub enum ServerRetryAfterError {
1652    /// The `serverRetryAfter` field was present in the `SetError.extra`
1653    /// map but its value did not deserialise into a `UTCDate`. The raw
1654    /// JSON value is included for diagnostics.
1655    Malformed(serde_json::Value),
1656}
1657
1658impl std::fmt::Display for ServerRetryAfterError {
1659    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1660        match self {
1661            ServerRetryAfterError::Malformed(raw) => {
1662                write!(f, "serverRetryAfter present but malformed: {raw}")
1663            }
1664        }
1665    }
1666}
1667
1668impl std::error::Error for ServerRetryAfterError {}
1669
1670/// Read the `serverRetryAfter` extension field from a [`SetError`] per the
1671/// JMAP Chat slow-mode workspace convention.
1672///
1673/// The base [`jmap_types::SetError`] type captures unknown extension
1674/// fields in its `extra` map via `#[serde(flatten)]`. JMAP Chat's
1675/// `rateLimited` error includes a `serverRetryAfter` UTCDate telling
1676/// the client when it may retry. This accessor distinguishes the three
1677/// possible outcomes so callers can react appropriately:
1678///
1679/// - `Ok(None)` — the field is absent. The server is using `rateLimited`
1680///   without a retry hint; the caller may apply a default backoff.
1681/// - `Ok(Some(t))` — the field is present and parsed; honour `t`.
1682/// - `Err(ServerRetryAfterError::Malformed(raw))` — the field is
1683///   present but its value is not a valid `UTCDate`. The raw JSON is
1684///   returned so the caller can log a server-side bug; the caller
1685///   should still apply a default backoff (the bug is server-side).
1686///
1687/// Prior to bd:JMAP-26di.50 this function returned a single `Option`
1688/// that conflated absent and malformed; the new shape gives the caller
1689/// the diagnostic information it needs to distinguish them.
1690pub fn server_retry_after(
1691    err: &SetError,
1692) -> Result<Option<jmap_types::UTCDate>, ServerRetryAfterError> {
1693    match err.extra.get("serverRetryAfter") {
1694        None => Ok(None),
1695        Some(v) => match jmap_types::UTCDate::deserialize(v) {
1696            Ok(d) => Ok(Some(d)),
1697            Err(_) => Err(ServerRetryAfterError::Malformed(v.clone())),
1698        },
1699    }
1700}
1701
1702// ---------------------------------------------------------------------------
1703// Tests
1704// ---------------------------------------------------------------------------
1705
1706#[cfg(test)]
1707mod tests {
1708    use super::*;
1709
1710    /// Compile-time assertion that `SessionClient: Send + Sync`, matching
1711    /// the contract documented on the `SessionClient` rustdoc
1712    /// (bd:JMAP-26di.41). If `JmapClient` or `Session` ever loses
1713    /// `Send` / `Sync` upstream, this stops the build instead of
1714    /// silently breaking the contract at consumer-build time.
1715    const _: fn() = || {
1716        fn _assert_send_sync<T: Send + Sync>() {}
1717        _assert_send_sync::<SessionClient>();
1718    };
1719
1720    /// Oracle: `Patch::Keep` via `map_entry()` returns `None` (key omitted from patch).
1721    /// This is the canonical pattern used by all patch methods in this crate.
1722    /// The expected value `None` is derived directly from the spec: a field not
1723    /// present in the patch leaves the server value unchanged (RFC 8620 §5.3).
1724    #[test]
1725    fn patch_keep_via_map_entry() {
1726        let p: Patch<String> = Patch::Keep;
1727        let result = p.map_entry().expect("map_entry must not fail for Keep");
1728        assert!(
1729            result.is_none(),
1730            "Patch::Keep must produce None from map_entry (key omitted from patch)"
1731        );
1732    }
1733
1734    /// Oracle: `Patch::Set(v)` via `map_entry()` returns `Some(json_value)`.
1735    /// Expected JSON is derived from the literal value "hello", not from the code.
1736    #[test]
1737    fn patch_set_via_map_entry() {
1738        let p = Patch::Set("hello".to_string());
1739        let result = p.map_entry().expect("map_entry must not fail for Set");
1740        assert_eq!(
1741            result,
1742            Some(serde_json::Value::String("hello".to_string())),
1743            "Patch::Set must produce Some(json_value) from map_entry"
1744        );
1745    }
1746
1747    /// Oracle: `Patch::Clear` via `map_entry()` returns `Some(Value::Null)`.
1748    /// Clearing a nullable field sends explicit JSON null (RFC 8620 §5.3).
1749    #[test]
1750    fn patch_clear_via_map_entry() {
1751        let p: Patch<String> = Patch::Clear;
1752        let result = p.map_entry().expect("map_entry must not fail for Clear");
1753        assert_eq!(
1754            result,
1755            Some(serde_json::Value::Null),
1756            "Patch::Clear must produce Some(null) from map_entry"
1757        );
1758    }
1759
1760    /// Oracle: `SpaceJoinInput::InviteCode` Debug must NOT contain the raw
1761    /// invite-code secret. The canary is a self-defined literal under the
1762    /// test's control, never derived from SpaceJoinInput's internal state.
1763    /// Same tripwire shape as the BearerAuth/BasicAuth redaction tests in
1764    /// jmap-base-client::auth (JMAP-sc1b.79).
1765    ///
1766    /// draft-atwood-jmap-chat-00 §4.18 defines the invite code as the
1767    /// unguessable bearer credential for Space/join.
1768    #[test]
1769    fn space_join_input_invite_code_debug_does_not_leak() {
1770        const CANARY: &str = "CANARY-JOIN-CODE-DO-NOT-LEAK-A1B2C3";
1771        let input = SpaceJoinInput::InviteCode(CANARY);
1772        let dbg = format!("{input:?}");
1773        assert!(
1774            !dbg.contains(CANARY),
1775            "SpaceJoinInput::InviteCode Debug must not contain the raw code; got: {dbg}"
1776        );
1777    }
1778
1779    /// Oracle: `SpaceJoinInput::SpaceId` Debug renders the id verbatim — Id is
1780    /// not a secret per RFC 8620 §1.2, and existing diagnostic uses depend on
1781    /// the id being visible in logs. This is a positive assertion paired with
1782    /// the redaction test above to prove the redaction is variant-scoped.
1783    #[test]
1784    fn space_join_input_space_id_debug_shows_id() {
1785        let id = Id::from("s-public-space");
1786        let input = SpaceJoinInput::SpaceId(&id);
1787        let dbg = format!("{input:?}");
1788        assert!(
1789            dbg.contains("s-public-space"),
1790            "SpaceJoinInput::SpaceId Debug must expose the public id; got: {dbg}"
1791        );
1792    }
1793
1794    /// Oracle: `PushSubscriptionPatch` Debug must NOT contain the raw
1795    /// `verification_code`. RFC 8620 §7.2 defines this value as the
1796    /// push-subscription ownership-proof secret; an attacker who learns it
1797    /// can hijack the subscription.
1798    #[test]
1799    fn push_subscription_patch_debug_does_not_leak_verification_code() {
1800        const CANARY: &str = "CANARY-VERIFICATION-CODE-DO-NOT-LEAK-D4E5F6";
1801        let patch = PushSubscriptionPatch {
1802            verification_code: Some(CANARY),
1803            ..PushSubscriptionPatch::default()
1804        };
1805        let dbg = format!("{patch:?}");
1806        assert!(
1807            !dbg.contains(CANARY),
1808            "PushSubscriptionPatch Debug must not contain the raw verification_code; got: {dbg}"
1809        );
1810        // Sanity: the field is still present in the Debug output as REDACTED,
1811        // so structural inspection (presence/absence of the field) still works.
1812        assert!(
1813            dbg.contains("verification_code"),
1814            "PushSubscriptionPatch Debug must still mention the verification_code field name; got: {dbg}"
1815        );
1816    }
1817
1818    /// Oracle: `PushSubscriptionPatch` with `verification_code: None` renders
1819    /// as `None` (not `Some("[REDACTED]")`). Paired with the above, this
1820    /// proves the redaction does not corrupt the Some/None signal that a
1821    /// reader of the Debug output relies on.
1822    #[test]
1823    fn push_subscription_patch_debug_none_verification_code() {
1824        let patch = PushSubscriptionPatch::default();
1825        let dbg = format!("{patch:?}");
1826        assert!(
1827            dbg.contains("verification_code: None"),
1828            "PushSubscriptionPatch Debug with None verification_code must render as None; got: {dbg}"
1829        );
1830        assert!(
1831            !dbg.contains("REDACTED"),
1832            "PushSubscriptionPatch Debug with None verification_code must not show REDACTED; got: {dbg}"
1833        );
1834    }
1835
1836    // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
1837    //
1838    // Each test deserialises wire JSON containing a synthetic `acmeCorp*`
1839    // vendor field and asserts it survives in `extra`. The vendor field
1840    // names cannot collide with any field defined in RFC 8620 §7.2 or in
1841    // the draft-atwood-jmap-chat-00 method responses, so the tests are
1842    // independent of the code under test (workspace test-integrity rule).
1843
1844    /// `PushSubscriptionCreateResponse.extra` captures unknown fields on deserialize.
1845    #[test]
1846    fn push_subscription_create_response_preserves_vendor_extras() {
1847        let raw = serde_json::json!({
1848            "accountId": null,
1849            "created": {},
1850            "notCreated": {},
1851            "acmeCorpPushBackend": "fcm"
1852        });
1853        let obj: PushSubscriptionCreateResponse =
1854            serde_json::from_value(raw).expect("PushSubscriptionCreateResponse must deserialize");
1855        assert_eq!(
1856            obj.extra
1857                .get("acmeCorpPushBackend")
1858                .and_then(|v| v.as_str()),
1859            Some("fcm")
1860        );
1861    }
1862
1863    /// `TypingResponse.extra` captures unknown fields on deserialize.
1864    #[test]
1865    fn typing_response_preserves_vendor_extras() {
1866        let raw = serde_json::json!({
1867            "accountId": "acc1",
1868            "acmeCorpEchoLatencyMs": 12
1869        });
1870        let obj: TypingResponse =
1871            serde_json::from_value(raw).expect("TypingResponse must deserialize");
1872        assert_eq!(
1873            obj.extra
1874                .get("acmeCorpEchoLatencyMs")
1875                .and_then(|v| v.as_u64()),
1876            Some(12)
1877        );
1878    }
1879
1880    /// `SpaceJoinResponse.extra` captures unknown fields on deserialize.
1881    #[test]
1882    fn space_join_response_preserves_vendor_extras() {
1883        let raw = serde_json::json!({
1884            "accountId": "acc1",
1885            "spaceId": "S1",
1886            "acmeCorpWelcomeChannelId": "C-welcome"
1887        });
1888        let obj: SpaceJoinResponse =
1889            serde_json::from_value(raw).expect("SpaceJoinResponse must deserialize");
1890        assert_eq!(
1891            obj.extra
1892                .get("acmeCorpWelcomeChannelId")
1893                .and_then(|v| v.as_str()),
1894            Some("C-welcome")
1895        );
1896    }
1897}