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}