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#[derive(Debug, Clone, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct PushSubscriptionCreateResponse {
49    /// The account this response refers to. Always `None` for `PushSubscription`
50    /// (not account-scoped); preserved as `Option<Id>` for servers that echo it.
51    #[serde(default)]
52    pub account_id: Option<Id>,
53    /// Successfully created subscriptions, keyed by the caller-supplied creation key.
54    pub created: Option<HashMap<String, serde_json::Value>>,
55    /// Creation failures, keyed by the caller-supplied creation key.
56    #[serde(default)]
57    pub not_created: Option<HashMap<String, SetError>>,
58    /// Catch-all for vendor / site / private extension fields not covered
59    /// by the typed fields above. Preserves unknown fields across
60    /// deserialize/serialize round-trip per workspace extras-preservation
61    /// policy (see workspace AGENTS.md).
62    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
63    pub extra: serde_json::Map<String, serde_json::Value>,
64}
65
66/// Response to a `Chat/typing` call (JMAP Chat §Chat/typing).
67///
68/// The server echoes only `accountId`.
69#[derive(Debug, Clone, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct TypingResponse {
72    /// The account this response refers to.
73    pub account_id: Id,
74    /// Catch-all for vendor / site / private extension fields not covered
75    /// by the typed fields above. Preserves unknown fields across
76    /// deserialize/serialize round-trip per workspace extras-preservation
77    /// policy (see workspace AGENTS.md).
78    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
79    pub extra: serde_json::Map<String, serde_json::Value>,
80}
81
82/// Response to a `Space/join` call (JMAP Chat §Space/join).
83#[derive(Debug, Clone, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct SpaceJoinResponse {
86    /// The account this response refers to.
87    pub account_id: Id,
88    /// The JMAP id of the Space the caller is now a member of.
89    pub space_id: Id,
90    /// Catch-all for vendor / site / private extension fields not covered
91    /// by the typed fields above. Preserves unknown fields across
92    /// deserialize/serialize round-trip per workspace extras-preservation
93    /// policy (see workspace AGENTS.md).
94    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
95    pub extra: serde_json::Map<String, serde_json::Value>,
96}
97
98// ---------------------------------------------------------------------------
99// Patch<T>: three-way update value for nullable fields
100// ---------------------------------------------------------------------------
101
102/// Three-way patch value for nullable JMAP fields.
103///
104/// - `Keep` (default): the field is omitted from the patch — server leaves it unchanged.
105/// - `Set(v)`: the field is included with value `v`.
106/// - `Clear`: the field is included as JSON `null` (clears the server-side value).
107///
108/// Use `Patch::from(v)` to construct `Set(v)`. Use `Default::default()` or
109/// `Patch::Keep` to leave the field unchanged. Use `Patch::Clear` to set a
110/// nullable field to null explicitly.
111///
112/// # Serde usage
113///
114/// Fields of type `Patch<T>` **must** carry both attributes:
115/// ```ignore
116/// #[serde(default, skip_serializing_if = "Patch::is_keep")]
117/// pub my_field: Patch<String>,
118/// ```
119/// - `default`: absent JSON key → `Patch::Keep` (no change).
120/// - `skip_serializing_if`: omits the key from the output when the value is `Keep`.
121///
122/// Without `skip_serializing_if`, `Patch::Keep` serializes as a runtime error.
123///
124/// # Deserialization
125///
126/// `Patch::Keep` is **not reachable from JSON deserialization**. The custom
127/// `Deserialize` impl maps JSON `null` → `Clear` and a JSON value → `Set(v)`.
128/// An absent key (via `#[serde(default)]`) produces `Keep` via `Default`.
129#[derive(Debug, Default, Clone, PartialEq)]
130pub enum Patch<T> {
131    /// Omit the field from the patch — server leaves it unchanged.
132    #[default]
133    Keep,
134    /// Include the field with value `T`.
135    Set(T),
136    /// Include the field as JSON `null` (clears the server-side value).
137    Clear,
138}
139
140impl<T> Patch<T> {
141    /// Returns `true` if this is `Patch::Keep` (field should be omitted from serialization).
142    pub fn is_keep(&self) -> bool {
143        matches!(self, Patch::Keep)
144    }
145}
146
147impl<T> From<T> for Patch<T> {
148    fn from(v: T) -> Self {
149        Patch::Set(v)
150    }
151}
152
153impl<T: serde::Serialize> Patch<T> {
154    /// Returns `None` when `Keep` (omit key from patch),
155    /// `Some(Value::Null)` when `Clear`, or `Some(serialized_value)` when `Set`.
156    pub fn map_entry(&self) -> Result<Option<serde_json::Value>, serde_json::Error> {
157        match self {
158            Patch::Keep => Ok(None),
159            Patch::Clear => Ok(Some(serde_json::Value::Null)),
160            Patch::Set(v) => serde_json::to_value(v).map(Some),
161        }
162    }
163}
164
165impl<T: serde::Serialize> serde::Serialize for Patch<T> {
166    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
167        match self {
168            Patch::Keep => Err(serde::ser::Error::custom(
169                "Patch::Keep cannot be serialized; add \
170                 #[serde(skip_serializing_if = \"Patch::is_keep\")] to the field",
171            )),
172            Patch::Clear => s.serialize_none(),
173            Patch::Set(v) => v.serialize(s),
174        }
175    }
176}
177
178impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Patch<T> {
179    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
180        // JSON absent (via #[serde(default)]) → Keep (default).
181        // JSON null → Clear. JSON value → Set(v).
182        Option::<T>::deserialize(d).map(|opt| match opt {
183            None => Patch::Clear,
184            Some(v) => Patch::Set(v),
185        })
186    }
187}
188
189// ---------------------------------------------------------------------------
190// Constants
191// ---------------------------------------------------------------------------
192
193/// The call-id embedded in every single-method JMAP request produced by
194/// [`build_request`]. Pass directly to `jmap_base_client::extract_response`.
195pub(crate) const CALL_ID: &str = "r1";
196
197/// Capability URIs for standard JMAP Chat method calls (RFC 8620 §3.3).
198pub(crate) const USING_CHAT: &[&str] = &["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:chat"];
199
200/// Capability URIs for Quota method calls.
201pub(crate) const USING_QUOTA: &[&str] =
202    &["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:quota"];
203
204/// Capability URIs for PushSubscription method calls (RFC 8620 §7.2).
205pub(crate) const USING_CORE: &[&str] = &["urn:ietf:params:jmap:core"];
206
207/// Capability URIs for PushSubscription/set with chat push extension.
208pub(crate) const USING_CHAT_PUSH: &[&str] = &[
209    "urn:ietf:params:jmap:core",
210    "urn:ietf:params:jmap:chat:push",
211];
212
213// ---------------------------------------------------------------------------
214// build_request helper
215// ---------------------------------------------------------------------------
216
217/// Build a single-method JMAP request.
218///
219/// `using` is the complete `using` array for the request (RFC 8620 §3.3).
220/// Use the pre-defined constants [`USING_CHAT`], [`USING_QUOTA`], or
221/// [`USING_CORE`] to avoid per-call allocations.
222///
223/// The embedded call-id is [`CALL_ID`]; pass it directly to
224/// `jmap_base_client::extract_response`.
225pub(crate) fn build_request(
226    method: &str,
227    args: serde_json::Value,
228    using: &[&str],
229) -> jmap_types::JmapRequest {
230    let using_vec: Vec<String> = using.iter().map(|&s| s.to_owned()).collect();
231    let invocation: jmap_types::Invocation = (method.to_owned(), args, CALL_ID.to_owned());
232    jmap_types::JmapRequest::new(using_vec, vec![invocation], None)
233}
234
235// ---------------------------------------------------------------------------
236// resolve_client_id helper
237// ---------------------------------------------------------------------------
238
239/// Resolve an optional caller-supplied client ID, generating a ULID if absent.
240///
241/// Returns the supplied string unchanged, or a freshly generated ULID when
242/// `None` or empty.
243pub(crate) fn resolve_client_id(id: Option<&str>) -> String {
244    match id {
245        Some(s) if !s.is_empty() => s.to_owned(),
246        _ => ulid::Ulid::new().to_string(),
247    }
248}
249
250// ---------------------------------------------------------------------------
251// SessionClient — session-bound client
252// ---------------------------------------------------------------------------
253
254/// A `JmapClient` bound to a JMAP session.
255///
256/// Obtain via the chat extension methods that accept a `Session`. All JMAP
257/// Chat methods are available on this type without needing to pass `&Session`
258/// on every call.
259///
260/// # Session lifecycle
261///
262/// `SessionClient` captures the `Session` at construction time. JMAP sessions
263/// can expire; after re-fetching the session via `JmapClient::fetch_session`,
264/// construct a new `SessionClient` with the updated session. Reusing a stale
265/// `SessionClient` after session expiry will result in `unknownAccount` or
266/// similar errors from the server.
267///
268/// `Clone` is derived because `JmapClient` is itself cheap-to-clone (it
269/// already implements `Clone` and `with_chat_session` clones one
270/// internally), enabling parallel-task fan-out with one bound session.
271///
272/// `Debug` is implemented manually to redact the inner `JmapClient` (which
273/// holds an HTTP client and is intentionally not `Debug` in
274/// `jmap-base-client`); only the `Session` is shown. This lets callers
275/// embed a `SessionClient` in a `#[derive(Debug)]` struct without manual
276/// impls of their own.
277#[non_exhaustive]
278#[derive(Clone)]
279pub struct SessionClient {
280    pub(crate) client: jmap_base_client::JmapClient,
281    pub(crate) session: jmap_base_client::Session,
282}
283
284impl std::fmt::Debug for SessionClient {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        f.debug_struct("SessionClient")
287            // The inner JmapClient is not Debug — show a placeholder so
288            // callers know it is present without leaking HTTP-client
289            // internals.
290            .field("client", &"<JmapClient>")
291            .field("session", &self.session)
292            .finish()
293    }
294}
295
296impl SessionClient {
297    /// Extract `(api_url, chat_account_id)` from the bound session.
298    ///
299    /// Returns `Err(InvalidSession)` if there is no primary account for
300    /// `urn:ietf:params:jmap:chat`.
301    pub(crate) fn session_parts(&self) -> Result<(&str, &str), jmap_base_client::ClientError> {
302        let api_url = self.session.api_url.as_str();
303        let account_id = self
304            .session
305            .primary_account_id("urn:ietf:params:jmap:chat")
306            .ok_or_else(|| {
307                jmap_base_client::ClientError::InvalidSession(
308                    "no primary account for urn:ietf:params:jmap:chat".into(),
309                )
310            })?;
311        Ok((api_url, account_id))
312    }
313
314    /// The JMAP API URL from the bound session.
315    pub(crate) fn api_url(&self) -> &str {
316        self.session.api_url.as_str()
317    }
318
319    /// Forward a JMAP request to the underlying HTTP client.
320    pub(crate) async fn call_internal(
321        &self,
322        api_url: &str,
323        req: &jmap_types::JmapRequest,
324    ) -> Result<jmap_types::JmapResponse, jmap_base_client::ClientError> {
325        self.client.call(api_url, req).await
326    }
327}
328
329// ---------------------------------------------------------------------------
330// Input/patch types for methods with many optional parameters
331// ---------------------------------------------------------------------------
332
333/// Input parameters for `Chat/query`.
334#[non_exhaustive]
335#[derive(Debug, Default)]
336pub struct ChatQueryInput {
337    /// Filter to chats of the given kind (`direct`, `group`, or `channel`).
338    pub filter_kind: Option<jmap_chat_types::ChatKind>,
339    /// Filter to muted (`true`) or unmuted (`false`) chats.
340    pub filter_muted: Option<bool>,
341    /// Zero-based starting offset within the query result.
342    pub position: Option<u64>,
343    /// Maximum number of ids to return.
344    pub limit: Option<u64>,
345}
346
347/// Input parameters for `Message/query`.
348#[non_exhaustive]
349#[derive(Debug, Default)]
350pub struct MessageQueryInput<'a> {
351    /// Restrict to messages in a specific Chat.
352    pub chat_id: Option<&'a Id>,
353    /// Filter to messages that mention (`true`) or do not mention (`false`) the caller.
354    pub has_mention: Option<bool>,
355    /// Filter to messages that carry (`true`) or do not carry (`false`) attachments.
356    pub has_attachment: Option<bool>,
357    /// Full-text search query against the message body.
358    pub text: Option<&'a str>,
359    /// Restrict to replies under this thread root.
360    pub thread_root_id: Option<&'a Id>,
361    /// Only include messages received after this time (exclusive).
362    pub after: Option<&'a jmap_types::UTCDate>,
363    /// Only include messages received before this time (exclusive).
364    pub before: Option<&'a jmap_types::UTCDate>,
365    /// Zero-based starting offset within the query result.
366    pub position: Option<u64>,
367    /// Maximum number of ids to return.
368    pub limit: Option<u64>,
369    /// Sort by `sentAt` ascending (oldest first) when `true`.
370    /// Defaults to `false` (descending, newest first), so `position:0, limit:N`
371    /// returns the N most recent messages.
372    pub sort_ascending: bool,
373}
374
375impl<'a> MessageQueryInput<'a> {
376    /// Set ascending sort order (oldest first).
377    pub fn with_sort_ascending(mut self, v: bool) -> Self {
378        self.sort_ascending = v;
379        self
380    }
381}
382
383/// Input parameters for `Message/set` create.
384#[non_exhaustive]
385#[derive(Debug)]
386pub struct MessageCreateInput<'a> {
387    /// Caller-supplied creation key. When `None`, a ULID is generated automatically.
388    pub client_id: Option<&'a str>,
389    /// The Chat this message belongs to.
390    pub chat_id: &'a Id,
391    /// Message body text (interpreted per `body_type`).
392    pub body: &'a str,
393    /// MIME type for the message body.
394    pub body_type: crate::types::BodyType,
395    /// RFC 3339 timestamp.
396    pub sent_at: &'a jmap_types::UTCDate,
397    /// When `Some`, marks this message as a reply to the given message id.
398    pub reply_to: Option<&'a Id>,
399}
400
401impl<'a> MessageCreateInput<'a> {
402    /// Create a `MessageCreateInput` with required fields; optional fields default to `None`.
403    pub fn new(
404        chat_id: &'a Id,
405        body: &'a str,
406        body_type: crate::types::BodyType,
407        sent_at: &'a jmap_types::UTCDate,
408    ) -> Self {
409        Self {
410            client_id: None,
411            chat_id,
412            body,
413            body_type,
414            sent_at,
415            reply_to: None,
416        }
417    }
418
419    /// Set the caller-supplied creation key (overrides the auto-generated ULID).
420    pub fn with_client_id(mut self, id: &'a str) -> Self {
421        self.client_id = Some(id);
422        self
423    }
424
425    /// Set the message this one replies to.
426    pub fn with_reply_to(mut self, id: &'a Id) -> Self {
427        self.reply_to = Some(id);
428        self
429    }
430}
431
432/// A single reaction change in a `Message/set` patch (JMAP Chat §4.5).
433///
434/// The patch key is `reactions/<senderReactionId>` (JSON Pointer).
435/// `senderReactionId` is a caller-generated ID (e.g. ULID) that uniquely
436/// identifies this reaction slot for the sending user in this message.
437#[non_exhaustive]
438#[derive(Debug)]
439pub enum ReactionChange<'a> {
440    /// Add a reaction. Patch value: `{emoji, sentAt}`.
441    Add {
442        /// Caller-generated id (e.g. ULID) identifying this reaction slot.
443        sender_reaction_id: &'a str,
444        /// Emoji shortcode or Unicode emoji to react with.
445        emoji: &'a str,
446        /// RFC 3339 timestamp when the reaction was made.
447        sent_at: &'a jmap_types::UTCDate,
448    },
449    /// Remove a reaction. Patch value: null.
450    Remove {
451        /// Caller-generated id identifying the reaction slot to remove.
452        sender_reaction_id: &'a str,
453    },
454}
455
456/// Patch parameters for `Message/set` update.
457///
458/// All fields are optional; absent fields (i.e. `None`) are not included in
459/// the patch (the server leaves them unchanged).
460///
461/// Use `..Default::default()` to fill in unused fields.
462#[non_exhaustive]
463#[derive(Debug, Default)]
464pub struct MessagePatch<'a> {
465    /// New message body text (author-only edit).
466    pub body: Option<&'a str>,
467    /// MIME type for `body`. Set alongside `body` in author-only edits.
468    pub body_type: Option<crate::types::BodyType>,
469    /// Reaction changes to apply. `None` (default) = no reaction changes.
470    pub reaction_changes: Option<&'a [ReactionChange<'a>]>,
471    /// Set the read-receipt timestamp (`Message.readAt`).
472    pub read_at: Option<&'a jmap_types::UTCDate>,
473    /// Set the read disposition recorded alongside `read_at`
474    /// (draft-atwood-jmap-chat-00 §Message/set update, line 1012).
475    ///
476    /// Setting `read_at` without `read_disposition` causes the server to
477    /// store `"displayed"` (§Message line 540). Supplying both lets the
478    /// client pick `Deleted` or `Processed` explicitly, or use
479    /// `Other("...")` for a vendor / future disposition value. Clients
480    /// SHOULD only set this when they also set `read_at`.
481    pub read_disposition: Option<jmap_chat_types::ReadDisposition>,
482    /// Set the deletion timestamp for soft/hard delete.
483    pub deleted_at: Option<&'a jmap_types::UTCDate>,
484    /// When `Some(true)` and `deleted_at` is also set, deletes for all
485    /// participants (server sends `Peer/retract`).
486    pub deleted_for_all: Option<bool>,
487}
488
489/// Patch parameters for `PresenceStatus/set` update.
490///
491/// All fields are optional. A field that is `Patch::Keep` (default) is omitted
492/// from the patch, leaving the server value unchanged. Use `Patch::Set(v)` to
493/// set a value and `Patch::Clear` to null-clear a nullable field.
494///
495/// Use `..Default::default()` to fill in unused fields.
496#[non_exhaustive]
497#[derive(Debug, Default)]
498pub struct PresenceStatusPatch<'a> {
499    /// New presence state. `None` = no change.
500    pub presence: Option<jmap_chat_types::Presence>,
501    /// Free-text status message. [`Patch::Clear`] clears; [`Patch::Set`] sets.
502    pub status_text: Patch<&'a str>,
503    /// Status emoji. [`Patch::Clear`] clears; [`Patch::Set`] sets.
504    pub status_emoji: Patch<&'a str>,
505    /// Set or clear the auto-clear deadline. `Patch::Clear` removes any deadline.
506    pub expires_at: Patch<&'a jmap_types::UTCDate>,
507    /// Whether read receipts are shared with peers. `None` = no change.
508    pub receipt_sharing: Option<bool>,
509}
510
511/// Input parameters for `CustomEmoji/query`.
512#[non_exhaustive]
513#[derive(Debug, Default)]
514pub struct CustomEmojiQueryInput<'a> {
515    /// Filter to a specific Space's custom emojis. `None` returns all emojis
516    /// visible to the account (Space-specific + server-global).
517    pub filter_space_id: Option<&'a Id>,
518    /// Zero-based starting offset within the query result.
519    pub position: Option<u64>,
520    /// Maximum number of ids to return.
521    pub limit: Option<u64>,
522}
523
524/// Parameters for creating one CustomEmoji via `CustomEmoji/set`.
525#[non_exhaustive]
526#[derive(Debug)]
527pub struct CustomEmojiCreateInput<'a> {
528    /// Caller-supplied creation key. When `None`, a ULID is generated automatically.
529    pub client_id: Option<&'a str>,
530    /// Shortcode name without colons (e.g., `catjam`).
531    pub name: &'a str,
532    /// blobId of the emoji image (already uploaded).
533    pub blob_id: &'a Id,
534    /// If `Some`, limits the emoji to the given Space. `None` = server-global.
535    pub space_id: Option<&'a Id>,
536}
537
538impl<'a> CustomEmojiCreateInput<'a> {
539    /// Create a `CustomEmojiCreateInput` with required fields; optional fields default to `None`.
540    pub fn new(name: &'a str, blob_id: &'a Id) -> Self {
541        Self {
542            client_id: None,
543            name,
544            blob_id,
545            space_id: None,
546        }
547    }
548
549    /// Set the caller-supplied creation key (overrides the auto-generated ULID).
550    pub fn with_client_id(mut self, id: &'a str) -> Self {
551        self.client_id = Some(id);
552        self
553    }
554}
555
556/// Parameters for creating one SpaceInvite via `SpaceInvite/set`.
557#[non_exhaustive]
558#[derive(Debug)]
559pub struct SpaceInviteCreateInput<'a> {
560    /// Caller-supplied creation key. When `None`, a ULID is generated automatically.
561    pub client_id: Option<&'a str>,
562    /// The Space this invite grants access to.
563    pub space_id: &'a Id,
564    /// Channel that joining members land in by default. `None` lets the server choose.
565    pub default_channel_id: Option<&'a Id>,
566    /// Optional expiry time after which the invite is no longer redeemable.
567    pub expires_at: Option<&'a jmap_types::UTCDate>,
568    /// Maximum number of times the invite may be redeemed.
569    pub max_uses: Option<u64>,
570}
571
572impl<'a> SpaceInviteCreateInput<'a> {
573    /// Create a `SpaceInviteCreateInput` with required fields; optional fields default to `None`.
574    pub fn new(space_id: &'a Id) -> Self {
575        Self {
576            client_id: None,
577            space_id,
578            default_channel_id: None,
579            expires_at: None,
580            max_uses: None,
581        }
582    }
583
584    /// Set the caller-supplied creation key (overrides the auto-generated ULID).
585    pub fn with_client_id(mut self, id: &'a str) -> Self {
586        self.client_id = Some(id);
587        self
588    }
589
590    /// Set the maximum number of times this invite may be used.
591    pub fn with_max_uses(mut self, max: u64) -> Self {
592        self.max_uses = Some(max);
593        self
594    }
595}
596
597/// Parameters for creating one SpaceBan via `SpaceBan/set`.
598#[non_exhaustive]
599#[derive(Debug)]
600pub struct SpaceBanCreateInput<'a> {
601    /// Caller-supplied creation key. When `None`, a ULID is generated automatically.
602    pub client_id: Option<&'a str>,
603    /// The Space this ban applies to.
604    pub space_id: &'a Id,
605    /// ChatContact.id of the user to ban.
606    pub user_id: &'a Id,
607    /// Optional human-readable reason for the ban.
608    pub reason: Option<&'a str>,
609    /// Optional expiry time after which the ban is automatically lifted.
610    pub expires_at: Option<&'a jmap_types::UTCDate>,
611}
612
613impl<'a> SpaceBanCreateInput<'a> {
614    /// Create a `SpaceBanCreateInput` with required fields; optional fields default to `None`.
615    pub fn new(space_id: &'a Id, user_id: &'a Id) -> Self {
616        Self {
617            client_id: None,
618            space_id,
619            user_id,
620            reason: None,
621            expires_at: None,
622        }
623    }
624
625    /// Set the caller-supplied creation key (overrides the auto-generated ULID).
626    pub fn with_client_id(mut self, id: &'a str) -> Self {
627        self.client_id = Some(id);
628        self
629    }
630}
631
632/// Patch parameters for `ChatContact/set` update.
633///
634/// All fields are optional; absent fields are omitted from the patch. For the
635/// nullable `display_name` field, use `Patch::Set(s)` to set and `Patch::Clear`
636/// to clear. Use `..Default::default()` to fill in unused fields.
637#[non_exhaustive]
638#[derive(Debug, Default)]
639pub struct ChatContactPatch<'a> {
640    /// Set or unset the blocked flag on this contact. `None` = no change.
641    pub blocked: Option<bool>,
642    /// `Patch::Clear` clears `displayName`; `Patch::Set(s)` sets it.
643    pub display_name: Patch<&'a str>,
644}
645
646/// Sort property for `ChatContact/query`.
647#[non_exhaustive]
648#[derive(Debug, Clone, PartialEq, serde::Serialize)]
649#[serde(rename_all = "camelCase")]
650pub enum ContactSortProperty {
651    /// Sort by the contact's `lastSeenAt` timestamp.
652    LastSeenAt,
653    /// Sort by the contact's `login` identifier.
654    Login,
655    /// Sort by the contact's `lastActiveAt` timestamp.
656    LastActiveAt,
657}
658
659/// Input parameters for `ChatContact/query`.
660///
661/// All fields are optional; an empty filter shows all contacts.
662#[non_exhaustive]
663#[derive(Debug, Default)]
664pub struct ChatContactQueryInput {
665    /// Filter to blocked (`true`) or non-blocked (`false`) contacts.
666    pub filter_blocked: Option<bool>,
667    /// Filter to contacts with this exact presence state.
668    pub filter_presence: Option<crate::types::ContactPresenceFilter>,
669    /// Zero-based starting offset within the query result.
670    pub position: Option<u64>,
671    /// Maximum number of ids to return.
672    pub limit: Option<u64>,
673    /// Sort property.
674    pub sort_property: Option<ContactSortProperty>,
675    /// When `Some(false)` or `None`, sort descending. `Some(true)` sorts ascending.
676    pub sort_ascending: Option<bool>,
677}
678
679/// Input parameters for `Space/set` create.
680#[non_exhaustive]
681#[derive(Debug)]
682pub struct SpaceCreateInput<'a> {
683    /// Caller-supplied creation key. When `None`, a ULID is generated automatically.
684    pub client_id: Option<&'a str>,
685    /// Display name for the Space.
686    pub name: &'a str,
687    /// Optional human-readable description.
688    pub description: Option<&'a str>,
689    /// Optional blob id of an already-uploaded icon image.
690    pub icon_blob_id: Option<&'a Id>,
691}
692
693impl<'a> SpaceCreateInput<'a> {
694    /// Create a `SpaceCreateInput` with required fields; optional fields default to `None`.
695    pub fn new(name: &'a str) -> Self {
696        Self {
697            client_id: None,
698            name,
699            description: None,
700            icon_blob_id: None,
701        }
702    }
703
704    /// Set the caller-supplied creation key (overrides the auto-generated ULID).
705    pub fn with_client_id(mut self, id: &'a str) -> Self {
706        self.client_id = Some(id);
707        self
708    }
709}
710
711/// Input parameters for `Space/query`.
712#[non_exhaustive]
713#[derive(Debug, Default)]
714pub struct SpaceQueryInput<'a> {
715    /// Filter by substring match on Space name.
716    pub filter_name: Option<&'a str>,
717    /// Filter to public (`true`) or non-public (`false`) Spaces.
718    pub filter_is_public: Option<bool>,
719    /// Zero-based starting offset within the query result.
720    pub position: Option<u64>,
721    /// Maximum number of ids to return.
722    pub limit: Option<u64>,
723}
724
725/// How to join a Space — passed to `Space/join`.
726///
727/// The enum makes invalid inputs unrepresentable: exactly one path is always
728/// selected at construction time.
729///
730/// # Debug redaction
731///
732/// The `InviteCode` variant wraps the unguessable bearer credential from
733/// draft-atwood-jmap-chat-00 §4.18 — anyone with the code can redeem it to
734/// join the Space. The `Debug` impl on this enum redacts the inner string to
735/// `"[REDACTED]"` so an accidental `{:?}`-format in an application log,
736/// tracing span, or test fixture cannot leak it. The `SpaceId` variant is
737/// not a secret (RFC 8620 §1.2) and is rendered verbatim.
738#[non_exhaustive]
739pub enum SpaceJoinInput<'a> {
740    /// Redeem a SpaceInvite by its `code` field (not its `id`).
741    ///
742    /// Unguessable secret — redacted by the [`std::fmt::Debug`] impl on this enum.
743    InviteCode(&'a str),
744    /// Join a public Space directly by its JMAP id.
745    SpaceId(&'a Id),
746}
747
748impl<'a> std::fmt::Debug for SpaceJoinInput<'a> {
749    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
750        match self {
751            Self::InviteCode(_) => f.debug_tuple("InviteCode").field(&"[REDACTED]").finish(),
752            Self::SpaceId(id) => f.debug_tuple("SpaceId").field(id).finish(),
753        }
754    }
755}
756
757/// One entry in the `addMembers` patch key for `Chat/set` update.
758#[non_exhaustive]
759#[derive(Debug)]
760pub struct AddMemberInput<'a> {
761    /// ChatContact.id of the member to add.
762    pub id: &'a Id,
763    /// Role for the new member. `None` lets the server apply the default (`"member"`).
764    pub role: Option<crate::types::ChatMemberRole>,
765}
766
767impl<'a> AddMemberInput<'a> {
768    /// Create an `AddMemberInput`; `role` defaults to `None` (server assigns default).
769    pub fn new(id: &'a Id) -> Self {
770        Self { id, role: None }
771    }
772
773    /// Set the role for this member.
774    pub fn with_role(mut self, role: crate::types::ChatMemberRole) -> Self {
775        self.role = Some(role);
776        self
777    }
778}
779
780/// One entry in the `updateMemberRoles` patch key for `Chat/set` update.
781#[non_exhaustive]
782#[derive(Debug)]
783pub struct UpdateMemberRoleInput<'a> {
784    /// ChatContact.id of the member to update.
785    pub id: &'a Id,
786    /// New role for this member.
787    pub role: crate::types::ChatMemberRole,
788}
789
790impl<'a> UpdateMemberRoleInput<'a> {
791    /// Create an `UpdateMemberRoleInput` with the target member and their new role.
792    pub fn new(id: &'a Id, role: crate::types::ChatMemberRole) -> Self {
793        Self { id, role }
794    }
795}
796
797/// Input parameters for `Chat/set` create.
798///
799/// Discriminates the three Chat creation kinds from the spec. Each variant
800/// carries the fields required for that kind plus an optional `client_id`;
801/// when `None`, a ULID is generated automatically.
802#[non_exhaustive]
803#[derive(Debug)]
804pub enum ChatCreateInput<'a> {
805    /// Create a direct (one-to-one) chat.
806    Direct {
807        /// Caller-supplied creation key. When `None`, a ULID is generated automatically.
808        client_id: Option<&'a str>,
809        /// ChatContact.id of the other participant.
810        contact_id: &'a Id,
811    },
812    /// Create a group chat.
813    Group {
814        /// Caller-supplied creation key. When `None`, a ULID is generated automatically.
815        client_id: Option<&'a str>,
816        /// Display name for the group.
817        name: &'a str,
818        /// ChatContact.ids of initial non-owner members.
819        member_ids: &'a [Id],
820        /// Optional human-readable description.
821        description: Option<&'a str>,
822        /// Blob id of an already-uploaded avatar image, if any.
823        avatar_blob_id: Option<&'a Id>,
824        /// Optional auto-expiry interval applied to new messages.
825        message_expiry_seconds: Option<u64>,
826    },
827    /// Create a channel chat inside a Space.
828    Channel {
829        /// Caller-supplied creation key. When `None`, a ULID is generated automatically.
830        client_id: Option<&'a str>,
831        /// The Space this channel belongs to.
832        space_id: &'a Id,
833        /// Display name for the channel.
834        name: &'a str,
835        /// Optional channel description / topic.
836        description: Option<&'a str>,
837    },
838}
839
840/// Patch parameters for `Chat/set` update.
841///
842/// All fields are optional; absent fields are not included in the patch (the
843/// server leaves them unchanged). For nullable spec fields (`mute_until`,
844/// `description`, `avatar_blob_id`) use `Patch::Set(v)` to set and
845/// `Patch::Clear` to null-clear. Slice fields default to `None` (no change).
846///
847/// Use `..Default::default()` to fill in unused fields.
848#[non_exhaustive]
849#[derive(Debug, Default)]
850pub struct ChatPatch<'a> {
851    /// Mute or unmute this chat. `None` = no change.
852    pub muted: Option<bool>,
853    /// `Patch::Clear` clears `muteUntil`; `Patch::Set(t)` sets it.
854    pub mute_until: Patch<&'a jmap_types::UTCDate>,
855    /// Whether typing indicators from peers are surfaced to the caller. `None` = no change.
856    pub receive_typing_indicators: Option<bool>,
857    /// Replace the entire pinned-message list. `Some(&[])` clears all pins.
858    pub pinned_message_ids: Option<&'a [Id]>,
859    /// Spec defines this as `UnsignedInt` (non-nullable).
860    pub message_expiry_seconds: Option<u64>,
861    /// Whether read receipts are shared with peers. `None` = no change.
862    pub receipt_sharing: Option<bool>,
863    /// New display name (group chats, admin only).
864    pub name: Option<&'a str>,
865    /// `Patch::Clear` clears; `Patch::Set(s)` sets (group chats, admin only).
866    pub description: Patch<&'a str>,
867    /// `Patch::Clear` clears; `Patch::Set(id)` sets (group chats, admin only).
868    pub avatar_blob_id: Patch<&'a Id>,
869    /// Members to add (group chats, admin only). `None` = no change.
870    pub add_members: Option<&'a [AddMemberInput<'a>]>,
871    /// ChatContact.ids to remove (group chats, admin only). `None` = no change.
872    pub remove_members: Option<&'a [Id]>,
873    /// Role changes for existing members (group chats, admin only). `None` = no change.
874    pub update_member_roles: Option<&'a [UpdateMemberRoleInput<'a>]>,
875}
876
877/// One member to add in the `addMembers` patch key of `Space/set` update.
878#[non_exhaustive]
879#[derive(Debug)]
880pub struct SpaceAddMemberInput<'a> {
881    /// ChatContact.id of the member to add.
882    pub id: &'a Id,
883    /// Initial role IDs for the new member. `None` grants no extra roles beyond `@everyone`.
884    pub role_ids: Option<&'a [Id]>,
885}
886
887impl<'a> SpaceAddMemberInput<'a> {
888    /// Create a `SpaceAddMemberInput`; `role_ids` defaults to `None`.
889    pub fn new(id: &'a Id) -> Self {
890        Self { id, role_ids: None }
891    }
892}
893
894/// One member update in the `updateMembers` patch key of `Space/set` update.
895#[non_exhaustive]
896#[derive(Debug)]
897pub struct SpaceUpdateMemberInput<'a> {
898    /// ChatContact.id of the member to update.
899    pub id: &'a Id,
900    /// Replace the member's SpaceRole.id list. `None` = no change.
901    pub role_ids: Option<&'a [Id]>,
902    /// `Patch::Clear` clears the nick; `Patch::Set(s)` sets it.
903    pub nick: Patch<&'a str>,
904}
905
906impl<'a> SpaceUpdateMemberInput<'a> {
907    /// Create a `SpaceUpdateMemberInput`; optional fields default to `None`/`Keep`.
908    pub fn new(id: &'a Id) -> Self {
909        Self {
910            id,
911            role_ids: None,
912            nick: Patch::Keep,
913        }
914    }
915}
916
917/// One channel to add in the `addChannels` patch key of `Space/set` update.
918#[non_exhaustive]
919#[derive(Debug)]
920pub struct SpaceAddChannelInput<'a> {
921    /// Channel display name.
922    pub name: &'a str,
923    /// Optional parent category id. `None` places the channel in `uncategorizedChannelIds`.
924    pub category_id: Option<&'a Id>,
925    /// Optional position within the category.
926    pub position: Option<u64>,
927    /// Optional channel topic.
928    pub topic: Option<&'a str>,
929}
930
931impl<'a> SpaceAddChannelInput<'a> {
932    /// Create a `SpaceAddChannelInput`; optional fields default to `None`.
933    pub fn new(name: &'a str) -> Self {
934        Self {
935            name,
936            category_id: None,
937            position: None,
938            topic: None,
939        }
940    }
941}
942
943/// One role to add in the `addRoles` patch key of `Space/set` update
944/// (JMAP Chat §Space/set update / `manage_roles` permission).
945///
946/// The server assigns the role's ULID; the request never specifies an `id`.
947/// Hierarchy enforcement: a member may only add roles whose `position` is
948/// strictly less than their own highest-position role (server-enforced).
949#[non_exhaustive]
950#[derive(Debug)]
951pub struct SpaceAddRoleInput<'a> {
952    /// Human-readable role name.
953    pub name: &'a str,
954    /// Permission identifier strings, e.g. `"manage_channels"`.
955    pub permissions: &'a [&'a str],
956    /// Position in the role hierarchy. Lower values sort first.
957    pub position: u64,
958    /// Optional CSS-style color string (e.g. `"#ff8800"`). Pass `None` to omit.
959    pub color: Option<&'a str>,
960}
961
962impl<'a> SpaceAddRoleInput<'a> {
963    /// Create a `SpaceAddRoleInput` with required fields; optional `color` defaults to `None`.
964    pub fn new(name: &'a str, permissions: &'a [&'a str], position: u64) -> Self {
965        Self {
966            name,
967            permissions,
968            position,
969            color: None,
970        }
971    }
972
973    /// Attach a color to the role.
974    pub fn with_color(mut self, color: &'a str) -> Self {
975        self.color = Some(color);
976        self
977    }
978}
979
980/// One role update in the `updateRoles` patch key of `Space/set` update
981/// (JMAP Chat §Space/set update / `manage_roles` permission).
982///
983/// Fields left at their default (`None` / [`Patch::Keep`]) are omitted from
984/// the wire patch and the server leaves the corresponding property unchanged.
985/// Hierarchy enforcement: a member may only modify roles whose `position` is
986/// strictly less than their own highest-position role (server-enforced).
987#[non_exhaustive]
988#[derive(Debug)]
989pub struct SpaceUpdateRoleInput<'a> {
990    /// SpaceRole.id to update.
991    pub id: &'a Id,
992    /// New name. `None` = no change.
993    pub name: Option<&'a str>,
994    /// Set or clear the color. [`Patch::Clear`] removes any assigned color.
995    pub color: Patch<&'a str>,
996    /// Replace the permissions list. `None` = no change.
997    pub permissions: Option<&'a [&'a str]>,
998    /// New position in the role hierarchy. `None` = no change.
999    pub position: Option<u64>,
1000}
1001
1002impl<'a> SpaceUpdateRoleInput<'a> {
1003    /// Create a `SpaceUpdateRoleInput`; optional fields default to `None`/`Keep`.
1004    pub fn new(id: &'a Id) -> Self {
1005        Self {
1006            id,
1007            name: None,
1008            color: Patch::Keep,
1009            permissions: None,
1010            position: None,
1011        }
1012    }
1013}
1014
1015/// One channel update in the `updateChannels` patch key of `Space/set` update
1016/// (JMAP Chat §Space/set update / `manage_channels` permission).
1017///
1018/// Fields left at their default (`None` / [`Patch::Keep`]) are omitted from
1019/// the wire patch and the server leaves the corresponding property unchanged.
1020#[non_exhaustive]
1021#[derive(Debug)]
1022pub struct SpaceUpdateChannelInput<'a> {
1023    /// Channel Chat id (kind `"channel"`, `spaceId` is this Space).
1024    pub id: &'a Id,
1025    /// New channel name. `None` = no change.
1026    pub name: Option<&'a str>,
1027    /// Set or clear the channel topic. [`Patch::Clear`] removes any assigned topic.
1028    pub topic: Patch<&'a str>,
1029    /// Set or clear the parent category. [`Patch::Clear`] moves the channel to
1030    /// the `uncategorizedChannelIds` list.
1031    pub category_id: Patch<&'a Id>,
1032    /// New position within its category. `None` = no change.
1033    pub position: Option<u64>,
1034    /// New slow-mode delay in seconds (`0` = disabled). `None` = no change.
1035    pub slow_mode_seconds: Option<u64>,
1036    /// Replace the permission-overrides list. `None` = no change.
1037    pub permission_overrides: Option<&'a [jmap_chat_types::ChannelPermission]>,
1038}
1039
1040impl<'a> SpaceUpdateChannelInput<'a> {
1041    /// Create a `SpaceUpdateChannelInput`; optional fields default to `None`/`Keep`.
1042    pub fn new(id: &'a Id) -> Self {
1043        Self {
1044            id,
1045            name: None,
1046            topic: Patch::Keep,
1047            category_id: Patch::Keep,
1048            position: None,
1049            slow_mode_seconds: None,
1050            permission_overrides: None,
1051        }
1052    }
1053}
1054
1055/// One category to add in the `addCategories` patch key of `Space/set` update
1056/// (JMAP Chat §Space/set update / `manage_channels` permission).
1057///
1058/// The server assigns the category's ULID; the request never specifies an `id`.
1059#[non_exhaustive]
1060#[derive(Debug)]
1061pub struct SpaceAddCategoryInput<'a> {
1062    /// Category display name.
1063    pub name: &'a str,
1064    /// Position relative to other categories. `None` lets the server append.
1065    pub position: Option<u64>,
1066    /// Initial member channel ids. `None` = empty category.
1067    pub channel_ids: Option<&'a [Id]>,
1068}
1069
1070impl<'a> SpaceAddCategoryInput<'a> {
1071    /// Create a `SpaceAddCategoryInput`; optional fields default to `None`.
1072    pub fn new(name: &'a str) -> Self {
1073        Self {
1074            name,
1075            position: None,
1076            channel_ids: None,
1077        }
1078    }
1079}
1080
1081/// One category update in the `updateCategories` patch key of `Space/set` update
1082/// (JMAP Chat §Space/set update / `manage_channels` permission).
1083///
1084/// Fields left at their default (`None`) are omitted from the wire patch and
1085/// the server leaves the corresponding property unchanged.
1086#[non_exhaustive]
1087#[derive(Debug)]
1088pub struct SpaceUpdateCategoryInput<'a> {
1089    /// Category id to update.
1090    pub id: &'a Id,
1091    /// New name. `None` = no change.
1092    pub name: Option<&'a str>,
1093    /// New position. `None` = no change.
1094    pub position: Option<u64>,
1095    /// Replace the member channel id list. `None` = no change.
1096    pub channel_ids: Option<&'a [Id]>,
1097}
1098
1099impl<'a> SpaceUpdateCategoryInput<'a> {
1100    /// Create a `SpaceUpdateCategoryInput`; optional fields default to `None`.
1101    pub fn new(id: &'a Id) -> Self {
1102        Self {
1103            id,
1104            name: None,
1105            position: None,
1106            channel_ids: None,
1107        }
1108    }
1109}
1110
1111/// Patch parameters for `Space/set` update.
1112///
1113/// All fields are optional. Absent fields are omitted from the patch.
1114/// Nullable fields (`description`, `icon_blob_id`) use `Patch::Set(v)` to set
1115/// and `Patch::Clear` to null-clear. Slice fields default to `None` (no change).
1116///
1117/// Use `..Default::default()` to fill in unused fields.
1118#[non_exhaustive]
1119#[derive(Debug, Default)]
1120pub struct SpacePatch<'a> {
1121    /// New display name (`manage_space` permission required).
1122    pub name: Option<&'a str>,
1123    /// `Patch::Clear` clears; `Patch::Set(s)` sets.
1124    pub description: Patch<&'a str>,
1125    /// `Patch::Clear` clears; `Patch::Set(id)` sets.
1126    pub icon_blob_id: Patch<&'a Id>,
1127    /// Toggle public-Space visibility. `None` = no change.
1128    pub is_public: Option<bool>,
1129    /// Toggle whether a public Space is previewable to non-members. `None` = no change.
1130    pub is_publicly_previewable: Option<bool>,
1131    /// Members to add (`manage_members` required). `None` = no change.
1132    pub add_members: Option<&'a [SpaceAddMemberInput<'a>]>,
1133    /// ChatContact.ids to remove (`manage_members` required). `None` = no change.
1134    pub remove_members: Option<&'a [Id]>,
1135    /// Member updates (`manage_members` required). `None` = no change.
1136    pub update_members: Option<&'a [SpaceUpdateMemberInput<'a>]>,
1137    /// Channels to add (`manage_channels` required). `None` = no change.
1138    pub add_channels: Option<&'a [SpaceAddChannelInput<'a>]>,
1139    /// Channel Chat ids to remove (`manage_channels` required). `None` = no change.
1140    pub remove_channels: Option<&'a [Id]>,
1141    /// Channel updates (`manage_channels` required). `None` = no change.
1142    pub update_channels: Option<&'a [SpaceUpdateChannelInput<'a>]>,
1143    /// Roles to add (`manage_roles` required). `None` = no change.
1144    pub add_roles: Option<&'a [SpaceAddRoleInput<'a>]>,
1145    /// SpaceRole.ids to remove (`manage_roles` required). `None` = no change.
1146    pub remove_roles: Option<&'a [Id]>,
1147    /// Role updates (`manage_roles` required). `None` = no change.
1148    pub update_roles: Option<&'a [SpaceUpdateRoleInput<'a>]>,
1149    /// Categories to add (`manage_channels` required). `None` = no change.
1150    pub add_categories: Option<&'a [SpaceAddCategoryInput<'a>]>,
1151    /// Category ids to remove (`manage_channels` required). `None` = no change.
1152    pub remove_categories: Option<&'a [Id]>,
1153    /// Category updates (`manage_channels` required). `None` = no change.
1154    pub update_categories: Option<&'a [SpaceUpdateCategoryInput<'a>]>,
1155}
1156
1157/// Input parameters for `PushSubscription/set` create (RFC 8620 §7.2).
1158///
1159/// Creates a PushSubscription with the optional `chatPush` extension
1160/// (draft-atwood-jmap-chat-push-00 §3.1).
1161///
1162/// `device_client_id` and `url` have no safe defaults and must always be supplied.
1163#[non_exhaustive]
1164#[derive(Debug)]
1165pub struct PushSubscriptionCreateInput<'a> {
1166    /// Caller-supplied creation key. When `None`, a ULID is generated automatically.
1167    pub client_id: Option<&'a str>,
1168    /// Stable client device identifier, used by the server to deduplicate subscriptions.
1169    pub device_client_id: &'a str,
1170    /// Push endpoint URL registered with the platform push service.
1171    pub url: &'a str,
1172    /// Subscription expiry time. `None` lets the server choose.
1173    pub expires: Option<&'a jmap_types::UTCDate>,
1174    /// Data type names to include in StateChange notifications.
1175    /// `None` means the server delivers all changed types.
1176    pub types: Option<&'a [&'a str]>,
1177    /// Per-account ChatPushConfig entries for inline push. Each entry is
1178    /// `(accountId, config)`. Pass `None` to omit the `chatPush` property.
1179    pub chat_push: Option<&'a [(&'a Id, jmap_chat_types::ChatPushConfig)]>,
1180}
1181
1182impl<'a> PushSubscriptionCreateInput<'a> {
1183    /// Create a `PushSubscriptionCreateInput` with required fields; optional fields default to `None`.
1184    pub fn new(device_client_id: &'a str, url: &'a str) -> Self {
1185        Self {
1186            client_id: None,
1187            device_client_id,
1188            url,
1189            expires: None,
1190            types: None,
1191            chat_push: None,
1192        }
1193    }
1194
1195    /// Set the caller-supplied creation key (overrides the auto-generated ULID).
1196    pub fn with_client_id(mut self, id: &'a str) -> Self {
1197        self.client_id = Some(id);
1198        self
1199    }
1200
1201    /// Restrict StateChange notifications to these data type names.
1202    pub fn with_types(mut self, types: &'a [&'a str]) -> Self {
1203        self.types = Some(types);
1204        self
1205    }
1206
1207    /// Attach per-account ChatPushConfig entries for inline push.
1208    pub fn with_chat_push(
1209        mut self,
1210        chat_push: &'a [(&'a Id, jmap_chat_types::ChatPushConfig)],
1211    ) -> Self {
1212        self.chat_push = Some(chat_push);
1213        self
1214    }
1215}
1216
1217/// Patch shape for `PushSubscription/set` update sub-operations (RFC 8620 §7.2.2).
1218///
1219/// Only the patchable properties are exposed. RFC 8620 §7.2 declares `url`
1220/// and `keys` immutable: to change those, destroy the subscription and create
1221/// a new one. `device_client_id` is also stable for the lifetime of the
1222/// subscription.
1223///
1224/// Fields left as their default (`None` / [`Patch::Keep`]) are omitted from
1225/// the wire patch and the server leaves the corresponding property unchanged.
1226///
1227/// The `chat_push` patch follows the JMAP Chat Push extension
1228/// (draft-atwood-jmap-chat-push-00 §3.1): callers pass `Some(slice)` to
1229/// replace the full `chatPush` property, or [`Patch::Clear`] semantics via
1230/// the dedicated `clear_chat_push` flag to set it to JSON `null`. The
1231/// extension does not define per-key patching, so the value is set
1232/// wholesale.
1233///
1234/// # Debug redaction
1235///
1236/// `verification_code` is the RFC 8620 §7.2 push-subscription-ownership
1237/// proof — an attacker who learns the value can claim ownership of the
1238/// subscription. The `Debug` impl on this struct redacts it to
1239/// `Some("[REDACTED]")` / `None` so an accidental `{:?}`-format in an
1240/// application log, tracing span, or test fixture cannot leak it. Other
1241/// fields are not secrets and are rendered verbatim.
1242#[non_exhaustive]
1243#[derive(Default)]
1244pub struct PushSubscriptionPatch<'a> {
1245    /// Replace the verification code (set after receiving a PushVerification
1246    /// payload). `None` = no change.
1247    ///
1248    /// RFC 8620 §7.2 ownership proof — redacted by the
1249    /// [`std::fmt::Debug`] impl on this struct.
1250    pub verification_code: Option<&'a str>,
1251    /// Set or clear the expiry timestamp. [`Patch::Clear`] sets `expires` to
1252    /// `null`; the server SHOULD then choose a default expiry per RFC 8620 §7.2.
1253    pub expires: Patch<&'a jmap_types::UTCDate>,
1254    /// Replace the `types` filter. `None` = no change. To set the property
1255    /// to `null` (deliver all types), use `clear_types: true`.
1256    pub types: Option<&'a [&'a str]>,
1257    /// When `true`, set `types` to JSON `null` (deliver all types). Mutually
1258    /// exclusive with `types: Some(_)` — providing both is rejected as
1259    /// `InvalidArgument`.
1260    pub clear_types: bool,
1261    /// Replace the `chatPush` extension property wholesale. `None` = no change.
1262    pub chat_push: Option<&'a [(&'a Id, jmap_chat_types::ChatPushConfig)]>,
1263    /// When `true`, set `chatPush` to JSON `null` (remove all inline push).
1264    /// Mutually exclusive with `chat_push: Some(_)`.
1265    pub clear_chat_push: bool,
1266}
1267
1268impl<'a> std::fmt::Debug for PushSubscriptionPatch<'a> {
1269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1270        let redacted_verification_code: Option<&'static str> =
1271            self.verification_code.map(|_| "[REDACTED]");
1272        f.debug_struct("PushSubscriptionPatch")
1273            .field("verification_code", &redacted_verification_code)
1274            .field("expires", &self.expires)
1275            .field("types", &self.types)
1276            .field("clear_types", &self.clear_types)
1277            .field("chat_push", &self.chat_push)
1278            .field("clear_chat_push", &self.clear_chat_push)
1279            .finish()
1280    }
1281}
1282
1283// ---------------------------------------------------------------------------
1284// Method sub-modules
1285// ---------------------------------------------------------------------------
1286
1287pub mod chat;
1288pub mod message;
1289pub mod space;
1290
1291// ---------------------------------------------------------------------------
1292// SetError extension accessors (JMAP Chat slow-mode)
1293// ---------------------------------------------------------------------------
1294
1295/// Read the `serverRetryAfter` extension field from a [`SetError`] per the
1296/// JMAP Chat slow-mode draft.
1297///
1298/// The base [`jmap_types::SetError`] type captures unknown extension fields
1299/// in its `extra` map via `#[serde(flatten)]`. JMAP Chat's `rateLimited`
1300/// error includes a `serverRetryAfter` UTCDate telling the client when it
1301/// may retry. Returns `None` when the field is absent or not parseable as a
1302/// `UTCDate`.
1303pub fn server_retry_after(err: &SetError) -> Option<jmap_types::UTCDate> {
1304    err.extra
1305        .get("serverRetryAfter")
1306        .and_then(|v| jmap_types::UTCDate::deserialize(v).ok())
1307}
1308
1309// ---------------------------------------------------------------------------
1310// Tests
1311// ---------------------------------------------------------------------------
1312
1313#[cfg(test)]
1314mod tests {
1315    use super::*;
1316
1317    /// Oracle: `Patch::Keep` via `map_entry()` returns `None` (key omitted from patch).
1318    /// This is the canonical pattern used by all patch methods in this crate.
1319    /// The expected value `None` is derived directly from the spec: a field not
1320    /// present in the patch leaves the server value unchanged (RFC 8620 §5.3).
1321    #[test]
1322    fn patch_keep_via_map_entry() {
1323        let p: Patch<String> = Patch::Keep;
1324        let result = p.map_entry().expect("map_entry must not fail for Keep");
1325        assert!(
1326            result.is_none(),
1327            "Patch::Keep must produce None from map_entry (key omitted from patch)"
1328        );
1329    }
1330
1331    /// Oracle: `Patch::Set(v)` via `map_entry()` returns `Some(json_value)`.
1332    /// Expected JSON is derived from the literal value "hello", not from the code.
1333    #[test]
1334    fn patch_set_via_map_entry() {
1335        let p = Patch::Set("hello".to_string());
1336        let result = p.map_entry().expect("map_entry must not fail for Set");
1337        assert_eq!(
1338            result,
1339            Some(serde_json::Value::String("hello".to_string())),
1340            "Patch::Set must produce Some(json_value) from map_entry"
1341        );
1342    }
1343
1344    /// Oracle: `Patch::Clear` via `map_entry()` returns `Some(Value::Null)`.
1345    /// Clearing a nullable field sends explicit JSON null (RFC 8620 §5.3).
1346    #[test]
1347    fn patch_clear_via_map_entry() {
1348        let p: Patch<String> = Patch::Clear;
1349        let result = p.map_entry().expect("map_entry must not fail for Clear");
1350        assert_eq!(
1351            result,
1352            Some(serde_json::Value::Null),
1353            "Patch::Clear must produce Some(null) from map_entry"
1354        );
1355    }
1356
1357    /// Oracle: `SpaceJoinInput::InviteCode` Debug must NOT contain the raw
1358    /// invite-code secret. The canary is a self-defined literal under the
1359    /// test's control, never derived from SpaceJoinInput's internal state.
1360    /// Same tripwire shape as the BearerAuth/BasicAuth redaction tests in
1361    /// jmap-base-client::auth (JMAP-sc1b.79).
1362    ///
1363    /// draft-atwood-jmap-chat-00 §4.18 defines the invite code as the
1364    /// unguessable bearer credential for Space/join.
1365    #[test]
1366    fn space_join_input_invite_code_debug_does_not_leak() {
1367        const CANARY: &str = "CANARY-JOIN-CODE-DO-NOT-LEAK-A1B2C3";
1368        let input = SpaceJoinInput::InviteCode(CANARY);
1369        let dbg = format!("{input:?}");
1370        assert!(
1371            !dbg.contains(CANARY),
1372            "SpaceJoinInput::InviteCode Debug must not contain the raw code; got: {dbg}"
1373        );
1374    }
1375
1376    /// Oracle: `SpaceJoinInput::SpaceId` Debug renders the id verbatim — Id is
1377    /// not a secret per RFC 8620 §1.2, and existing diagnostic uses depend on
1378    /// the id being visible in logs. This is a positive assertion paired with
1379    /// the redaction test above to prove the redaction is variant-scoped.
1380    #[test]
1381    fn space_join_input_space_id_debug_shows_id() {
1382        let id = Id::from("s-public-space");
1383        let input = SpaceJoinInput::SpaceId(&id);
1384        let dbg = format!("{input:?}");
1385        assert!(
1386            dbg.contains("s-public-space"),
1387            "SpaceJoinInput::SpaceId Debug must expose the public id; got: {dbg}"
1388        );
1389    }
1390
1391    /// Oracle: `PushSubscriptionPatch` Debug must NOT contain the raw
1392    /// `verification_code`. RFC 8620 §7.2 defines this value as the
1393    /// push-subscription ownership-proof secret; an attacker who learns it
1394    /// can hijack the subscription.
1395    #[test]
1396    fn push_subscription_patch_debug_does_not_leak_verification_code() {
1397        const CANARY: &str = "CANARY-VERIFICATION-CODE-DO-NOT-LEAK-D4E5F6";
1398        let patch = PushSubscriptionPatch {
1399            verification_code: Some(CANARY),
1400            ..PushSubscriptionPatch::default()
1401        };
1402        let dbg = format!("{patch:?}");
1403        assert!(
1404            !dbg.contains(CANARY),
1405            "PushSubscriptionPatch Debug must not contain the raw verification_code; got: {dbg}"
1406        );
1407        // Sanity: the field is still present in the Debug output as REDACTED,
1408        // so structural inspection (presence/absence of the field) still works.
1409        assert!(
1410            dbg.contains("verification_code"),
1411            "PushSubscriptionPatch Debug must still mention the verification_code field name; got: {dbg}"
1412        );
1413    }
1414
1415    /// Oracle: `PushSubscriptionPatch` with `verification_code: None` renders
1416    /// as `None` (not `Some("[REDACTED]")`). Paired with the above, this
1417    /// proves the redaction does not corrupt the Some/None signal that a
1418    /// reader of the Debug output relies on.
1419    #[test]
1420    fn push_subscription_patch_debug_none_verification_code() {
1421        let patch = PushSubscriptionPatch::default();
1422        let dbg = format!("{patch:?}");
1423        assert!(
1424            dbg.contains("verification_code: None"),
1425            "PushSubscriptionPatch Debug with None verification_code must render as None; got: {dbg}"
1426        );
1427        assert!(
1428            !dbg.contains("REDACTED"),
1429            "PushSubscriptionPatch Debug with None verification_code must not show REDACTED; got: {dbg}"
1430        );
1431    }
1432
1433    // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
1434    //
1435    // Each test deserialises wire JSON containing a synthetic `acmeCorp*`
1436    // vendor field and asserts it survives in `extra`. The vendor field
1437    // names cannot collide with any field defined in RFC 8620 §7.2 or in
1438    // the draft-atwood-jmap-chat-00 method responses, so the tests are
1439    // independent of the code under test (workspace test-integrity rule).
1440
1441    /// `PushSubscriptionCreateResponse.extra` captures unknown fields on deserialize.
1442    #[test]
1443    fn push_subscription_create_response_preserves_vendor_extras() {
1444        let raw = serde_json::json!({
1445            "accountId": null,
1446            "created": {},
1447            "notCreated": {},
1448            "acmeCorpPushBackend": "fcm"
1449        });
1450        let obj: PushSubscriptionCreateResponse =
1451            serde_json::from_value(raw).expect("PushSubscriptionCreateResponse must deserialize");
1452        assert_eq!(
1453            obj.extra
1454                .get("acmeCorpPushBackend")
1455                .and_then(|v| v.as_str()),
1456            Some("fcm")
1457        );
1458    }
1459
1460    /// `TypingResponse.extra` captures unknown fields on deserialize.
1461    #[test]
1462    fn typing_response_preserves_vendor_extras() {
1463        let raw = serde_json::json!({
1464            "accountId": "acc1",
1465            "acmeCorpEchoLatencyMs": 12
1466        });
1467        let obj: TypingResponse =
1468            serde_json::from_value(raw).expect("TypingResponse must deserialize");
1469        assert_eq!(
1470            obj.extra
1471                .get("acmeCorpEchoLatencyMs")
1472                .and_then(|v| v.as_u64()),
1473            Some(12)
1474        );
1475    }
1476
1477    /// `SpaceJoinResponse.extra` captures unknown fields on deserialize.
1478    #[test]
1479    fn space_join_response_preserves_vendor_extras() {
1480        let raw = serde_json::json!({
1481            "accountId": "acc1",
1482            "spaceId": "S1",
1483            "acmeCorpWelcomeChannelId": "C-welcome"
1484        });
1485        let obj: SpaceJoinResponse =
1486            serde_json::from_value(raw).expect("SpaceJoinResponse must deserialize");
1487        assert_eq!(
1488            obj.extra
1489                .get("acmeCorpWelcomeChannelId")
1490                .and_then(|v| v.as_str()),
1491            Some("C-welcome")
1492        );
1493    }
1494}