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}