jmap_base_client/request.rs
1//! Base JMAP request and session types: [`JmapRequestBuilder`], [`Session`],
2//! [`AccountInfo`], [`WebSocketCapability`].
3//!
4//! Types that belong to the base JMAP client layer (RFC 8620 §2, §3.3, RFC 8887).
5//! Chat-specific and Mail-specific types live in their own crates.
6//!
7//! Types already in `jmap-types` and NOT redefined here:
8//! `Id`, `UTCDate`, `State`, `Date`, `JmapRequest`, `JmapResponse`, `Invocation`,
9//! `ResultReference`.
10
11use std::collections::BTreeMap;
12use std::collections::HashSet;
13
14use serde::{Deserialize, Serialize};
15
16use jmap_types::{Invocation, JmapRequest, State};
17
18use crate::error::ClientError;
19
20// ---------------------------------------------------------------------------
21// JmapUrl / JmapUrlTemplate (bd:JMAP-6r7c.40)
22// ---------------------------------------------------------------------------
23
24/// A plain JMAP URL — no RFC 6570 template variables expected.
25///
26/// This is the typed counterpart to [`JmapUrlTemplate`] (which requires
27/// expansion before use). The Session document distinguishes the two at
28/// the type level so callers cannot accidentally pass an unexpanded
29/// template (e.g. `https://server/download/{accountId}/{blobId}/{name}`)
30/// to a function that wants a plain URL.
31///
32/// Construct via [`JmapUrl::new`]. The string is taken as-is — no URL
33/// parsing or validation; downstream consumers (reqwest, http crate)
34/// validate at the actual request site. Borrow the inner string via
35/// [`as_str`](Self::as_str) for `&str`-accepting APIs.
36///
37/// Deliberately does NOT implement `Deref<Target = str>`. Auto-coercion
38/// would defeat the type distinction with [`JmapUrlTemplate`]: both
39/// would coerce to `&str` and pass any `&str`-accepting function. Use
40/// `.as_str()` at the call site so the type transition is visible in
41/// code review.
42#[non_exhaustive]
43#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
44#[serde(transparent)]
45pub struct JmapUrl(String);
46
47impl JmapUrl {
48 /// Wrap a string as a plain JMAP URL.
49 pub fn new(url: impl Into<String>) -> Self {
50 Self(url.into())
51 }
52
53 /// Borrow the inner URL string.
54 pub fn as_str(&self) -> &str {
55 &self.0
56 }
57
58 /// Consume the wrapper and return the inner `String`.
59 pub fn into_inner(self) -> String {
60 self.0
61 }
62}
63
64impl AsRef<str> for JmapUrl {
65 fn as_ref(&self) -> &str {
66 &self.0
67 }
68}
69
70impl std::fmt::Display for JmapUrl {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 f.write_str(&self.0)
73 }
74}
75
76impl PartialEq<str> for JmapUrl {
77 fn eq(&self, other: &str) -> bool {
78 self.0 == other
79 }
80}
81
82impl PartialEq<&str> for JmapUrl {
83 fn eq(&self, other: &&str) -> bool {
84 self.0 == *other
85 }
86}
87
88impl PartialEq<JmapUrl> for str {
89 fn eq(&self, other: &JmapUrl) -> bool {
90 self == other.0
91 }
92}
93
94impl PartialEq<JmapUrl> for &str {
95 fn eq(&self, other: &JmapUrl) -> bool {
96 *self == other.0
97 }
98}
99
100/// An RFC 6570 Level-1 URI template — requires variable substitution
101/// before use as a request URL.
102///
103/// Typed counterpart to [`JmapUrl`]. The template carries placeholders
104/// like `{accountId}` or `{blobId}` that must be expanded via
105/// [`expand_url_template`](crate::expand_url_template) before the result
106/// can be sent to an HTTP client. Passing the unexpanded template
107/// verbatim would produce a request URL containing literal `{...}`
108/// braces, which reqwest percent-encodes to `%7B...%7D` and the server
109/// rejects.
110///
111/// Construct via [`JmapUrlTemplate::new`]. See [`JmapUrl`] for the
112/// rationale behind not implementing `Deref<Target = str>`.
113#[non_exhaustive]
114#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
115#[serde(transparent)]
116pub struct JmapUrlTemplate(String);
117
118impl JmapUrlTemplate {
119 /// Wrap a string as a JMAP URL template.
120 pub fn new(template: impl Into<String>) -> Self {
121 Self(template.into())
122 }
123
124 /// Borrow the inner template string.
125 pub fn as_str(&self) -> &str {
126 &self.0
127 }
128
129 /// Consume the wrapper and return the inner `String`.
130 pub fn into_inner(self) -> String {
131 self.0
132 }
133}
134
135impl AsRef<str> for JmapUrlTemplate {
136 fn as_ref(&self) -> &str {
137 &self.0
138 }
139}
140
141impl std::fmt::Display for JmapUrlTemplate {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 f.write_str(&self.0)
144 }
145}
146
147impl PartialEq<str> for JmapUrlTemplate {
148 fn eq(&self, other: &str) -> bool {
149 self.0 == other
150 }
151}
152
153impl PartialEq<&str> for JmapUrlTemplate {
154 fn eq(&self, other: &&str) -> bool {
155 self.0 == *other
156 }
157}
158
159impl PartialEq<JmapUrlTemplate> for str {
160 fn eq(&self, other: &JmapUrlTemplate) -> bool {
161 self == other.0
162 }
163}
164
165impl PartialEq<JmapUrlTemplate> for &str {
166 fn eq(&self, other: &JmapUrlTemplate) -> bool {
167 *self == other.0
168 }
169}
170
171// ---------------------------------------------------------------------------
172// Username / AccountName (bd:JMAP-6r7c.63)
173// ---------------------------------------------------------------------------
174
175/// The authenticated user's username (RFC 8620 §2 `username` field).
176///
177/// Typically an email address, and therefore PII under GDPR / CCPA.
178/// The wrapper exists to centralise PII handling at the type level:
179///
180/// - **`Display` redacts to `"[REDACTED]"`.** `println!("{}", username)`,
181/// `format!("{username}")`, and `tracing::info!(user = %username, ...)`
182/// all hit `Display` and therefore the redaction. The pre-bd:JMAP-6r7c.63
183/// shape (`pub username: String`) leaked the raw value through every
184/// `Display`-bearing path.
185/// - **`Debug` redacts to `Username("[REDACTED]")`.** Pre-existing
186/// `Session::Debug` already redacted explicitly; keeping the wrapper's
187/// own redaction makes the field safe to print even outside the
188/// `Session::Debug` path (e.g. if a caller stores the `Username` in
189/// a struct of their own and derives `Debug` on it).
190/// - **`Serialize` emits the raw value verbatim** because the wire
191/// format requires it for round-trip. Callers who want to scrub the
192/// field before serialising MUST do so explicitly.
193/// - **[`expose_unredacted`](Self::expose_unredacted)** is the only path
194/// to the raw string. The accessor name is deliberately explicit so
195/// the intent is visible at the call site in code review.
196///
197/// Construct via [`Username::new`] or via deserialize.
198#[non_exhaustive]
199#[derive(Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
200#[serde(transparent)]
201pub struct Username(String);
202
203impl Username {
204 /// Wrap a string as a [`Username`].
205 pub fn new(username: impl Into<String>) -> Self {
206 Self(username.into())
207 }
208
209 /// Return the raw, un-redacted username string.
210 ///
211 /// **Do not log this return value.** This is the only path to the
212 /// raw PII; the explicit accessor name surfaces the intent in code
213 /// review. Use only when the wire format requires the raw value
214 /// (e.g. constructing an `Authorization` header that re-uses the
215 /// username, building an audit log under a separate PII-handling
216 /// policy).
217 pub fn expose_unredacted(&self) -> &str {
218 &self.0
219 }
220
221 /// Consume the wrapper and return the inner `String`.
222 ///
223 /// Same handling guidance as
224 /// [`expose_unredacted`](Self::expose_unredacted) — the caller now
225 /// owns a raw `String` and must handle PII manually from that
226 /// point on.
227 pub fn into_inner(self) -> String {
228 self.0
229 }
230}
231
232impl std::fmt::Display for Username {
233 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 f.write_str("[REDACTED]")
235 }
236}
237
238impl std::fmt::Debug for Username {
239 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240 f.debug_tuple("Username").field(&"[REDACTED]").finish()
241 }
242}
243
244impl PartialEq<str> for Username {
245 fn eq(&self, other: &str) -> bool {
246 self.0 == other
247 }
248}
249
250impl PartialEq<&str> for Username {
251 fn eq(&self, other: &&str) -> bool {
252 self.0 == *other
253 }
254}
255
256impl PartialEq<Username> for str {
257 fn eq(&self, other: &Username) -> bool {
258 self == other.0
259 }
260}
261
262impl PartialEq<Username> for &str {
263 fn eq(&self, other: &Username) -> bool {
264 *self == other.0
265 }
266}
267
268/// The human-readable account name (RFC 8620 §2 `name` field on the
269/// per-account object).
270///
271/// Typically the owner's email address, and therefore PII. Shape
272/// mirrors [`Username`]; see that type for the PII-handling rationale.
273#[non_exhaustive]
274#[derive(Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
275#[serde(transparent)]
276pub struct AccountName(String);
277
278impl AccountName {
279 /// Wrap a string as an [`AccountName`].
280 pub fn new(name: impl Into<String>) -> Self {
281 Self(name.into())
282 }
283
284 /// Return the raw, un-redacted account name. Same handling
285 /// guidance as [`Username::expose_unredacted`].
286 pub fn expose_unredacted(&self) -> &str {
287 &self.0
288 }
289
290 /// Consume the wrapper and return the inner `String`. Same
291 /// handling guidance as [`Username::into_inner`].
292 pub fn into_inner(self) -> String {
293 self.0
294 }
295}
296
297impl std::fmt::Display for AccountName {
298 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299 f.write_str("[REDACTED]")
300 }
301}
302
303impl std::fmt::Debug for AccountName {
304 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
305 f.debug_tuple("AccountName").field(&"[REDACTED]").finish()
306 }
307}
308
309impl PartialEq<str> for AccountName {
310 fn eq(&self, other: &str) -> bool {
311 self.0 == other
312 }
313}
314
315impl PartialEq<&str> for AccountName {
316 fn eq(&self, other: &&str) -> bool {
317 self.0 == *other
318 }
319}
320
321impl PartialEq<AccountName> for str {
322 fn eq(&self, other: &AccountName) -> bool {
323 self == other.0
324 }
325}
326
327impl PartialEq<AccountName> for &str {
328 fn eq(&self, other: &AccountName) -> bool {
329 *self == other.0
330 }
331}
332
333// ---------------------------------------------------------------------------
334// JmapRequestBuilder (RFC 8620 §3.3)
335// ---------------------------------------------------------------------------
336
337/// Fluent builder for multi-method [`JmapRequest`] objects.
338///
339/// Collects method calls and produces a [`JmapRequest`] ready for dispatch.
340///
341/// The `using` capability URIs passed to `new` apply to the whole request;
342/// callers must include every capability required by the methods they add.
343///
344/// Spec: RFC 8620 §3.3
345#[derive(Debug)]
346pub struct JmapRequestBuilder {
347 using: Vec<String>,
348 method_calls: Vec<Invocation>,
349 call_ids: HashSet<String>,
350}
351
352impl JmapRequestBuilder {
353 /// Create a new builder with the given capability URIs.
354 ///
355 /// The `using` list MUST include `"urn:ietf:params:jmap:core"` (always
356 /// required by RFC 8620 §3.3) plus every capability URI needed by the
357 /// methods added via [`add_call`](JmapRequestBuilder::add_call). An
358 /// incorrect or empty `using` list will cause the server to return an
359 /// `"unknownCapability"` error — the builder does not validate it.
360 pub fn new(using: &[&str]) -> Self {
361 Self {
362 using: using.iter().map(|&s| s.to_owned()).collect(),
363 method_calls: Vec::new(),
364 call_ids: HashSet::new(),
365 }
366 }
367
368 /// Add one method call to the request.
369 ///
370 /// `call_id` must be unique within this request; callers use it to match
371 /// responses back to the originating call.
372 ///
373 /// Returns `Err(ClientError::InvalidArgument)` if `call_id` has already
374 /// been used in this builder. Duplicate call IDs violate RFC 8620 §3.5.
375 pub fn add_call(
376 &mut self,
377 method: impl Into<String>,
378 args: serde_json::Value,
379 call_id: impl Into<String>,
380 ) -> Result<&mut Self, ClientError> {
381 let call_id = call_id.into();
382 if !self.call_ids.insert(call_id.clone()) {
383 return Err(ClientError::InvalidArgument(format!(
384 "JmapRequestBuilder: duplicate call_id {call_id:?}"
385 )));
386 }
387 self.method_calls
388 .push(Invocation::new(method, args, call_id));
389 Ok(self)
390 }
391
392 /// Consume the builder and produce the [`JmapRequest`].
393 ///
394 /// Returns `Err(ClientError::InvalidArgument)` if no method calls have
395 /// been added. An empty `methodCalls` array is invalid per RFC 8620 §3.3.
396 pub fn build(self) -> Result<JmapRequest, ClientError> {
397 if self.method_calls.is_empty() {
398 return Err(ClientError::InvalidArgument("no method calls added".into()));
399 }
400 Ok(JmapRequest::new(self.using, self.method_calls, None))
401 }
402}
403
404// ---------------------------------------------------------------------------
405// Session (RFC 8620 §2)
406// ---------------------------------------------------------------------------
407
408/// JMAP Session object returned by `GET /.well-known/jmap` (RFC 8620 §2).
409///
410/// Contains only the base RFC 8620 fields. Extension-specific fields
411/// (e.g. JMAP Chat `ownerUserId`) are surfaced by extension crates that
412/// parse the `capabilities` and `accounts` maps.
413///
414/// # `extra` equality is feature-flag-dependent (bd:JMAP-6r7c.43)
415///
416/// The derived `PartialEq` / `Eq` impl's behaviour on the `extra` field
417/// depends on the global `serde_json/preserve_order` feature flag — see
418/// the [crate-level note](crate#extra-field-equality-and-the-serde_jsonpreserve_order-feature-bdjmap-6r7c43)
419/// for the canonical statement.
420#[non_exhaustive]
421#[derive(Clone, PartialEq, Eq, Deserialize)]
422#[serde(rename_all = "camelCase")]
423pub struct Session {
424 /// Map of capability URI → capability object (RFC 8620 §2).
425 ///
426 /// Values are kept as raw JSON so callers can extract extension-specific
427 /// capability objects without this crate knowing their schema.
428 pub capabilities: BTreeMap<String, serde_json::Value>,
429
430 /// Map of account ID → [`AccountInfo`] (RFC 8620 §2).
431 pub accounts: BTreeMap<String, AccountInfo>,
432
433 /// Map of capability URI → primary account ID (RFC 8620 §2).
434 pub primary_accounts: BTreeMap<String, String>,
435
436 /// Username associated with the current credentials (RFC 8620 §2).
437 ///
438 /// # ⚠ PII — handle with the same care as a credential (bd:JMAP-6r7c.35, bd:JMAP-6r7c.63)
439 ///
440 /// This field is typically an email address and is therefore PII
441 /// under GDPR / CCPA. The [`Username`] wrapper redacts both
442 /// `Display` and `Debug`:
443 ///
444 /// - `println!("User: {}", session.username)` — `Display` renders
445 /// `"[REDACTED]"` (per [`Username`]'s impl).
446 /// - `format!("hello {}", session.username)` — same.
447 /// - `tracing::info!(user = %session.username, ...)` — same.
448 /// - `format!("{:?}", session.username)` — `Debug` renders
449 /// `Username("[REDACTED]")`.
450 ///
451 /// Two paths still expose the raw value, both deliberately
452 /// explicit at the call site:
453 ///
454 /// - [`Username::expose_unredacted`] returns `&str`. Use only when
455 /// the wire requires it (constructing an `Authorization` header
456 /// that re-uses the username, audit logging under a separate
457 /// policy).
458 /// - `serde_json::to_string(&session)?` — `Session` derives
459 /// `Serialize` for wire round-trip and emits the raw value
460 /// verbatim. Callers who want to scrub PII before serialising
461 /// MUST replace or clear the field first.
462 ///
463 /// If you need a non-PII session-scoped identifier, prefer
464 /// [`primary_accounts`](Session::primary_accounts) account IDs
465 /// (RFC 8620 §2's `accountId` is server-opaque and is not PII).
466 pub username: Username,
467
468 /// URL for JMAP API POST requests (RFC 8620 §2).
469 ///
470 /// Typed as [`JmapUrl`] (plain URL — no template variables) to
471 /// distinguish from the template-shaped URL fields below
472 /// (bd:JMAP-6r7c.40). Borrow as `&str` via
473 /// [`JmapUrl::as_str`](crate::JmapUrl::as_str) when calling
474 /// `&str`-accepting APIs.
475 pub api_url: JmapUrl,
476
477 /// URL template for blob downloads (RFC 8620 §2).
478 ///
479 /// URI Template (level 1) containing variables `accountId`, `blobId`,
480 /// `type`, and `name`. Typed as [`JmapUrlTemplate`] so it cannot be
481 /// confused with [`api_url`](Self::api_url) at the type level
482 /// (bd:JMAP-6r7c.40); expand via
483 /// [`expand_url_template`](crate::expand_url_template) before use.
484 pub download_url: JmapUrlTemplate,
485
486 /// URL template for blob uploads (RFC 8620 §2).
487 ///
488 /// URI Template (level 1) containing variable `accountId`. Typed
489 /// as [`JmapUrlTemplate`] (bd:JMAP-6r7c.40); see
490 /// [`download_url`](Self::download_url) for the type-distinction
491 /// rationale.
492 pub upload_url: JmapUrlTemplate,
493
494 /// URL template for SSE push event stream (RFC 8620 §2, §7.3).
495 ///
496 /// URI Template (level 1) containing variables `types`, `closeafter`,
497 /// and `ping`. Typed as [`JmapUrlTemplate`] (bd:JMAP-6r7c.40); see
498 /// [`download_url`](Self::download_url) for the type-distinction
499 /// rationale.
500 pub event_source_url: JmapUrlTemplate,
501
502 /// Opaque session state token (RFC 8620 §2).
503 ///
504 /// Changes whenever any session property changes. Returned in every API
505 /// response as `sessionState`; clients compare to detect staleness.
506 pub state: State,
507
508 /// Catch-all for vendor / site / private extension fields not covered
509 /// by the typed fields above. Preserves unknown fields across
510 /// deserialize/serialize round-trip per workspace extras-preservation
511 /// policy (see workspace AGENTS.md).
512 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
513 pub extra: serde_json::Map<String, serde_json::Value>,
514}
515
516impl Session {
517 /// Returns the primary account ID for the given capability URI, if set.
518 ///
519 /// Example: `session.primary_account_id("urn:ietf:params:jmap:mail")`
520 pub fn primary_account_id(&self, capability: &str) -> Option<&str> {
521 self.primary_accounts.get(capability).map(String::as_str)
522 }
523
524 /// Returns the parsed [`WebSocketCapability`] for the JMAP WebSocket
525 /// transport, if advertised (RFC 8887).
526 ///
527 /// - `Ok(None)` — server does not advertise JMAP WebSocket support.
528 /// - `Ok(Some(...))` — WebSocket is supported; use `result.url` to connect.
529 /// - `Err` — capability key is present but the value is malformed.
530 pub fn websocket_capability(&self) -> Result<Option<WebSocketCapability>, ClientError> {
531 self.extension_capability("urn:ietf:params:jmap:websocket")
532 }
533
534 /// Returns the parsed extension-capability object for `capability_uri`,
535 /// deserialized into the caller-supplied type `T` (bd:JMAP-6r7c.22).
536 ///
537 /// Use this when an extension defines a typed capability struct (the
538 /// way `urn:ietf:params:jmap:websocket` maps to [`WebSocketCapability`])
539 /// and you want a typed view instead of poking at the raw
540 /// `serde_json::Value` in [`Session::capabilities`]. Each extension
541 /// `*-client` crate should expose a typed `XxxCapability` struct and
542 /// a thin wrapper like:
543 ///
544 /// ```rust,ignore
545 /// pub fn mail_capability(session: &Session) -> Result<Option<MailCapability>, ClientError> {
546 /// session.extension_capability("urn:ietf:params:jmap:mail")
547 /// }
548 /// ```
549 ///
550 /// # Returns
551 ///
552 /// - `Ok(None)` — server does not advertise this capability.
553 /// - `Ok(Some(_))` — capability is advertised AND the value parsed into `T`.
554 /// - `Err(ClientError::Parse)` — capability is advertised but the value
555 /// could not be deserialised into `T`. Indicates either a server bug,
556 /// a schema-version mismatch, or a `T` type that does not match the
557 /// spec for `capability_uri`.
558 ///
559 /// The function only inspects the value when the key is present; an
560 /// absent key always returns `Ok(None)` regardless of `T`.
561 pub fn extension_capability<T>(&self, capability_uri: &str) -> Result<Option<T>, ClientError>
562 where
563 T: serde::de::DeserializeOwned,
564 {
565 let Some(raw) = self.capabilities.get(capability_uri) else {
566 return Ok(None);
567 };
568 T::deserialize(raw)
569 .map(Some)
570 .map_err(ClientError::from_parse)
571 }
572
573 /// Returns `true` if the server advertises the JMAP Blob Content
574 /// Identifiers extension (draft-atwood-jmap-cid-00).
575 ///
576 /// Checks for presence of `capabilities["urn:ietf:params:jmap:cid"]`.
577 /// The capability value object is empty per the draft (§2: "no
578 /// capability fields defined at this time"), so the presence of the
579 /// key is sufficient — no value-shape check is required.
580 ///
581 /// When `true`, the server commits to including a `sha256` field
582 /// (the 64-character lowercase-hex SHA-256 digest of the uploaded
583 /// content) on Blob upload responses, and on FileNode objects when
584 /// the JMAP FileNode extension is also supported. See
585 /// [`jmap_cid_types::Sha256`] for the typed wire shape.
586 ///
587 /// Mirrors the `supports_*` capability-probe pattern established by
588 /// `ChatSessionExt::supports_quotas` and
589 /// `ChatSessionExt::supports_refplus` in `jmap-chat-client`.
590 ///
591 /// [`jmap_cid_types::Sha256`]: https://docs.rs/jmap-cid-types
592 pub fn supports_cid(&self) -> bool {
593 self.capabilities.contains_key("urn:ietf:params:jmap:cid")
594 }
595}
596
597/// Manual `Debug` impl that redacts privacy-sensitive fields (bd:JMAP-sc1b.99).
598///
599/// `Session.username` is the authenticated user's identifier — typically a
600/// full email address, which is PII under GDPR/CCPA. `Session.state` is the
601/// opaque RFC 8620 §2 session-state token; it is not an auth credential, but
602/// it uniquely identifies the client's session and is the same shape of leak
603/// as logging a session cookie. Both are replaced with `"[REDACTED]"` /
604/// `"[opaque]"` in the Debug output.
605///
606/// All other URL/map fields are surfaced — they are deployment metadata and
607/// not credential-grade. `AccountInfo.name` is redacted by `AccountInfo`'s
608/// own manual `Debug` impl, so the `accounts` map below does not leak
609/// owner emails transitively (bd:JMAP-sc1b.104).
610impl std::fmt::Debug for Session {
611 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
612 f.debug_struct("Session")
613 .field("capabilities", &self.capabilities)
614 .field("accounts", &self.accounts)
615 .field("primary_accounts", &self.primary_accounts)
616 .field("username", &"[REDACTED]")
617 .field("api_url", &self.api_url)
618 .field("download_url", &self.download_url)
619 .field("upload_url", &self.upload_url)
620 .field("event_source_url", &self.event_source_url)
621 .field("state", &"[opaque]")
622 .field("extra", &self.extra)
623 .finish()
624 }
625}
626
627// ---------------------------------------------------------------------------
628// AccountInfo (RFC 8620 §2 Account object)
629// ---------------------------------------------------------------------------
630
631/// Per-account metadata in a JMAP Session (RFC 8620 §2).
632///
633/// `Debug` is hand-written to redact `name` because the field's own
634/// definition identifies it as "typically the owner's email address"
635/// (PII under GDPR/CCPA). The other fields are non-credential metadata
636/// and are surfaced directly. See bd:JMAP-sc1b.104.
637///
638/// # `extra` equality is feature-flag-dependent (bd:JMAP-6r7c.43)
639///
640/// The derived `PartialEq` / `Eq` impl's behaviour on the `extra` field
641/// depends on the global `serde_json/preserve_order` feature flag — see
642/// the [crate-level note](crate#extra-field-equality-and-the-serde_jsonpreserve_order-feature-bdjmap-6r7c43)
643/// for the canonical statement.
644#[non_exhaustive]
645#[derive(Clone, PartialEq, Eq, Deserialize)]
646#[serde(rename_all = "camelCase")]
647pub struct AccountInfo {
648 /// Human-readable account name (e.g. the owner's email address).
649 ///
650 /// # ⚠ PII — same handling rules as [`Session::username`] (bd:JMAP-6r7c.35, bd:JMAP-6r7c.63)
651 ///
652 /// Typed as [`AccountName`] (PII wrapper) so `Display`, `Debug`,
653 /// `format!`, and `tracing::*` paths all redact to `"[REDACTED]"`
654 /// rather than leaking the raw value. The only paths that surface
655 /// the raw string are [`AccountName::expose_unredacted`] (explicit
656 /// caller intent) and `serde_json::to_string(&account)?` (wire
657 /// round-trip).
658 ///
659 /// See [`Session::username`] for the full PII discussion and
660 /// recommended non-PII replacement identifiers.
661 pub name: AccountName,
662
663 /// `true` if this is the authenticated user's own personal account.
664 pub is_personal: bool,
665
666 /// `true` if the entire account is read-only for the current user.
667 pub is_read_only: bool,
668
669 /// Map of capability URI → capability object for this account.
670 ///
671 /// Values are kept as raw JSON so extension crates can extract
672 /// their own capability objects.
673 pub account_capabilities: BTreeMap<String, serde_json::Value>,
674
675 /// Catch-all for vendor / site / private extension fields not covered
676 /// by the typed fields above. Preserves unknown fields across
677 /// deserialize/serialize round-trip per workspace extras-preservation
678 /// policy (see workspace AGENTS.md).
679 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
680 pub extra: serde_json::Map<String, serde_json::Value>,
681}
682
683impl AccountInfo {
684 /// Returns the parsed per-account extension-capability object for
685 /// `capability_uri`, deserialized into the caller-supplied type `T`
686 /// (bd:JMAP-6r7c.22).
687 ///
688 /// Per-account counterpart of [`Session::extension_capability`]. Used
689 /// when an extension defines an account-scoped capability shape (e.g.
690 /// per-account quotas, per-account folder roots) rather than a
691 /// server-wide one.
692 ///
693 /// # Returns
694 ///
695 /// - `Ok(None)` — this account does not advertise this capability.
696 /// - `Ok(Some(_))` — capability is advertised AND the value parsed into `T`.
697 /// - `Err(ClientError::Parse)` — capability is advertised but the value
698 /// could not be deserialised into `T`.
699 pub fn account_extension_capability<T>(
700 &self,
701 capability_uri: &str,
702 ) -> Result<Option<T>, ClientError>
703 where
704 T: serde::de::DeserializeOwned,
705 {
706 let Some(raw) = self.account_capabilities.get(capability_uri) else {
707 return Ok(None);
708 };
709 T::deserialize(raw)
710 .map(Some)
711 .map_err(ClientError::from_parse)
712 }
713}
714
715/// Manual `Debug` impl that redacts `name` (bd:JMAP-sc1b.104).
716///
717/// `AccountInfo.name` is typically the owner's email address, which is
718/// PII under GDPR/CCPA. The other fields (`is_personal`, `is_read_only`,
719/// `account_capabilities`) are non-credential metadata and are surfaced
720/// directly so `{:?}` output remains useful for debugging.
721///
722/// This redaction closes the transitive leak through `Session.accounts`
723/// — `Session`'s own Debug impl (bd:JMAP-sc1b.99) only redacted
724/// `username` and `state` directly and was silent about the accounts
725/// map. With `AccountInfo` redacting itself, any `{:?}` of a `Session`
726/// is now safe with respect to the canonical email-shaped PII.
727impl std::fmt::Debug for AccountInfo {
728 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
729 f.debug_struct("AccountInfo")
730 .field("name", &"[REDACTED]")
731 .field("is_personal", &self.is_personal)
732 .field("is_read_only", &self.is_read_only)
733 .field("account_capabilities", &self.account_capabilities)
734 .field("extra", &self.extra)
735 .finish()
736 }
737}
738
739// ---------------------------------------------------------------------------
740// WebSocketCapability (RFC 8887)
741// ---------------------------------------------------------------------------
742
743/// Capability object for `"urn:ietf:params:jmap:websocket"` (RFC 8887).
744///
745/// Advertised in `Session.capabilities` when the server supports JMAP over
746/// WebSocket. The `url` field is the `wss://` endpoint to connect to.
747///
748/// # `extra` equality is feature-flag-dependent (bd:JMAP-6r7c.43)
749///
750/// The derived `PartialEq` / `Eq` impl's behaviour on the `extra` field
751/// depends on the global `serde_json/preserve_order` feature flag — see
752/// the [crate-level note](crate#extra-field-equality-and-the-serde_jsonpreserve_order-feature-bdjmap-6r7c43)
753/// for the canonical statement.
754#[non_exhaustive]
755#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
756#[serde(rename_all = "camelCase")]
757pub struct WebSocketCapability {
758 /// The WebSocket endpoint URL (`wss://`).
759 pub url: String,
760
761 /// Whether the server supports push notifications over this WebSocket.
762 #[serde(default)]
763 pub supports_push: bool,
764
765 /// Catch-all for vendor / site / private extension fields not covered
766 /// by the typed fields above. Preserves unknown fields across
767 /// deserialize/serialize round-trip per workspace extras-preservation
768 /// policy (see workspace AGENTS.md).
769 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
770 pub extra: serde_json::Map<String, serde_json::Value>,
771}
772
773// ---------------------------------------------------------------------------
774// Tests
775// ---------------------------------------------------------------------------
776
777#[cfg(test)]
778mod tests {
779 use super::*;
780 use serde_json::json;
781
782 // -----------------------------------------------------------------------
783 // JmapRequestBuilder
784 // -----------------------------------------------------------------------
785
786 /// Oracle: RFC 8620 §3.3 — a request with two method calls serializes to
787 /// a JSON object with a "methodCalls" array containing two 3-element arrays.
788 /// The expected JSON shape is derived directly from the RFC §3.3 example.
789 #[test]
790 fn builder_two_calls_serializes_correctly() {
791 let mut builder =
792 JmapRequestBuilder::new(&["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"]);
793 builder
794 .add_call(
795 "Mailbox/get",
796 json!({"accountId": "A13824", "ids": null}),
797 "r1",
798 )
799 .expect("add_call r1 must succeed");
800 builder
801 .add_call(
802 "Email/get",
803 json!({"accountId": "A13824", "ids": ["e001"]}),
804 "r2",
805 )
806 .expect("add_call r2 must succeed");
807 let req = builder.build().expect("build must succeed with two calls");
808
809 let v = serde_json::to_value(&req).expect("serialize JmapRequest");
810
811 // Oracle: RFC 8620 §3.3 — "using" must be present
812 assert!(v.get("using").is_some(), "must have 'using' field");
813 let using = v["using"].as_array().expect("using must be array");
814 assert_eq!(using.len(), 2);
815 assert!(using.contains(&json!("urn:ietf:params:jmap:core")));
816 assert!(using.contains(&json!("urn:ietf:params:jmap:mail")));
817
818 // Oracle: RFC 8620 §3.3 — "methodCalls" must be present
819 let calls = v["methodCalls"]
820 .as_array()
821 .expect("methodCalls must be array");
822 assert_eq!(calls.len(), 2, "must have exactly 2 method calls");
823
824 // Oracle: RFC 8620 §3.2 — each invocation is [methodName, args, callId]
825 assert_eq!(calls[0][0], json!("Mailbox/get"));
826 assert_eq!(calls[0][2], json!("r1"));
827 assert_eq!(calls[1][0], json!("Email/get"));
828 assert_eq!(calls[1][2], json!("r2"));
829 }
830
831 /// Oracle: RFC 8620 §3.3 — build() with no method calls is invalid;
832 /// must return Err(InvalidArgument) rather than produce an empty batch.
833 #[test]
834 fn builder_returns_err_on_empty_build() {
835 let result = JmapRequestBuilder::new(&["urn:ietf:params:jmap:core"]).build();
836 assert!(
837 matches!(result, Err(ClientError::InvalidArgument(_))),
838 "empty build must return Err(InvalidArgument), got {result:?}"
839 );
840 }
841
842 /// Oracle: RFC 8620 §3.5 — call IDs must be unique within a request.
843 /// Duplicate call ID returns Err(ClientError::InvalidArgument).
844 #[test]
845 fn builder_returns_err_on_duplicate_call_id() {
846 let mut builder = JmapRequestBuilder::new(&["urn:ietf:params:jmap:core"]);
847 builder
848 .add_call("Foo/get", json!({}), "r1")
849 .expect("first add_call must succeed");
850 let result = builder.add_call("Bar/get", json!({}), "r1"); // duplicate
851 assert!(
852 matches!(result, Err(ClientError::InvalidArgument(_))),
853 "duplicate call_id must return Err(InvalidArgument), got {result:?}"
854 );
855 }
856
857 // -----------------------------------------------------------------------
858 // Session
859 // -----------------------------------------------------------------------
860
861 /// Oracle: RFC 8620 §2.1 example Session JSON, transcribed from the RFC text.
862 /// All field names and values come from the RFC, not from the code under test.
863 #[test]
864 fn session_deserializes_rfc8620_example() {
865 // RFC 8620 §2.1 example — hand-transcribed from spec text.
866 let raw = r#"{
867 "capabilities": {
868 "urn:ietf:params:jmap:core": {
869 "maxSizeUpload": 50000000,
870 "maxConcurrentUpload": 8,
871 "maxSizeRequest": 10000000,
872 "maxConcurrentRequest": 8,
873 "maxCallsInRequest": 32,
874 "maxObjectsInGet": 256,
875 "maxObjectsInSet": 128,
876 "collationAlgorithms": [
877 "i;ascii-numeric",
878 "i;ascii-casemap",
879 "i;unicode-casemap"
880 ]
881 },
882 "urn:ietf:params:jmap:mail": {},
883 "urn:ietf:params:jmap:contacts": {},
884 "https://example.com/apis/foobar": {
885 "maxFoosFinangled": 42
886 }
887 },
888 "accounts": {
889 "A13824": {
890 "name": "john@example.com",
891 "isPersonal": true,
892 "isReadOnly": false,
893 "accountCapabilities": {
894 "urn:ietf:params:jmap:mail": {
895 "maxMailboxesPerEmail": null,
896 "maxMailboxDepth": 10
897 },
898 "urn:ietf:params:jmap:contacts": {}
899 }
900 },
901 "A97813": {
902 "name": "jane@example.com",
903 "isPersonal": false,
904 "isReadOnly": true,
905 "accountCapabilities": {
906 "urn:ietf:params:jmap:mail": {
907 "maxMailboxesPerEmail": 1,
908 "maxMailboxDepth": 10
909 }
910 }
911 }
912 },
913 "primaryAccounts": {
914 "urn:ietf:params:jmap:mail": "A13824",
915 "urn:ietf:params:jmap:contacts": "A13824"
916 },
917 "username": "john@example.com",
918 "apiUrl": "https://jmap.example.com/api/",
919 "downloadUrl": "https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}",
920 "uploadUrl": "https://jmap.example.com/upload/{accountId}/",
921 "eventSourceUrl": "https://jmap.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
922 "state": "75128aab4b1b"
923 }"#;
924
925 let session: Session =
926 serde_json::from_str(raw).expect("RFC 8620 §2.1 example must deserialize");
927
928 // Oracle: RFC 8620 §2.1
929 assert_eq!(session.username, "john@example.com");
930 assert_eq!(session.api_url, "https://jmap.example.com/api/");
931 assert_eq!(
932 session.upload_url,
933 "https://jmap.example.com/upload/{accountId}/"
934 );
935 assert_eq!(
936 session.download_url,
937 "https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}"
938 );
939 assert_eq!(
940 session.event_source_url,
941 "https://jmap.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}"
942 );
943 assert_eq!(session.state, "75128aab4b1b");
944
945 // Oracle: RFC 8620 §2.1 — capabilities map
946 assert!(
947 session
948 .capabilities
949 .contains_key("urn:ietf:params:jmap:core"),
950 "must have core capability"
951 );
952 assert!(
953 session
954 .capabilities
955 .contains_key("urn:ietf:params:jmap:mail"),
956 "must have mail capability"
957 );
958 assert!(
959 session
960 .capabilities
961 .contains_key("https://example.com/apis/foobar"),
962 "must have vendor capability"
963 );
964
965 // Oracle: RFC 8620 §2.1 — accounts map
966 assert!(
967 session.accounts.contains_key("A13824"),
968 "must have account A13824"
969 );
970 assert!(
971 session.accounts.contains_key("A97813"),
972 "must have account A97813"
973 );
974
975 // Oracle: RFC 8620 §2.1 — primaryAccounts
976 assert_eq!(
977 session.primary_account_id("urn:ietf:params:jmap:mail"),
978 Some("A13824")
979 );
980 assert_eq!(
981 session.primary_account_id("urn:ietf:params:jmap:contacts"),
982 Some("A13824")
983 );
984 assert_eq!(
985 session.primary_account_id("urn:ietf:params:jmap:core"),
986 None
987 );
988 }
989
990 // -----------------------------------------------------------------------
991 // AccountInfo
992 // -----------------------------------------------------------------------
993
994 /// Oracle: RFC 8620 §2.1 example — account A13824 (john@example.com).
995 /// Field names and values transcribed directly from the RFC.
996 #[test]
997 fn account_info_deserializes_rfc8620_example() {
998 // RFC 8620 §2.1 example account entry
999 let raw = r#"{
1000 "name": "john@example.com",
1001 "isPersonal": true,
1002 "isReadOnly": false,
1003 "accountCapabilities": {
1004 "urn:ietf:params:jmap:mail": {
1005 "maxMailboxesPerEmail": null,
1006 "maxMailboxDepth": 10
1007 },
1008 "urn:ietf:params:jmap:contacts": {}
1009 }
1010 }"#;
1011
1012 let account: AccountInfo =
1013 serde_json::from_str(raw).expect("RFC 8620 §2.1 AccountInfo must deserialize");
1014
1015 // Oracle: RFC 8620 §2 Account object fields
1016 assert_eq!(account.name, "john@example.com");
1017 assert!(account.is_personal, "isPersonal must be true");
1018 assert!(!account.is_read_only, "isReadOnly must be false");
1019 assert!(
1020 account
1021 .account_capabilities
1022 .contains_key("urn:ietf:params:jmap:mail"),
1023 "must have mail capability"
1024 );
1025 assert!(
1026 account
1027 .account_capabilities
1028 .contains_key("urn:ietf:params:jmap:contacts"),
1029 "must have contacts capability"
1030 );
1031
1032 // Oracle: RFC 8620 §2.1 — read-only account (A97813 / jane@example.com)
1033 let raw2 = r#"{
1034 "name": "jane@example.com",
1035 "isPersonal": false,
1036 "isReadOnly": true,
1037 "accountCapabilities": {
1038 "urn:ietf:params:jmap:mail": {
1039 "maxMailboxesPerEmail": 1,
1040 "maxMailboxDepth": 10
1041 }
1042 }
1043 }"#;
1044 let account2: AccountInfo = serde_json::from_str(raw2)
1045 .expect("RFC 8620 §2.1 read-only AccountInfo must deserialize");
1046
1047 assert_eq!(account2.name, "jane@example.com");
1048 assert!(!account2.is_personal, "isPersonal must be false");
1049 assert!(account2.is_read_only, "isReadOnly must be true");
1050 }
1051
1052 // -----------------------------------------------------------------------
1053 // WebSocketCapability
1054 // -----------------------------------------------------------------------
1055
1056 /// Oracle: RFC 8887 §3 — WebSocketCapability has url and supportsPush fields.
1057 /// Transcribed from the RFC 8887 capability object definition.
1058 #[test]
1059 fn websocket_capability_deserializes() {
1060 let raw = r#"{"url": "wss://jmap.example.com/ws", "supportsPush": true}"#;
1061 let cap: WebSocketCapability =
1062 serde_json::from_str(raw).expect("WebSocketCapability must deserialize");
1063 assert_eq!(cap.url, "wss://jmap.example.com/ws");
1064 assert!(cap.supports_push);
1065 }
1066
1067 /// Oracle: RFC 8887 §3 — supportsPush defaults to false when absent.
1068 #[test]
1069 fn websocket_capability_supports_push_defaults_false() {
1070 let raw = r#"{"url": "wss://jmap.example.com/ws"}"#;
1071 let cap: WebSocketCapability =
1072 serde_json::from_str(raw).expect("WebSocketCapability must deserialize");
1073 assert_eq!(cap.url, "wss://jmap.example.com/ws");
1074 assert!(!cap.supports_push, "supportsPush must default to false");
1075 }
1076
1077 /// Oracle: Session.websocket_capability() returns Ok(None) when key absent.
1078 #[test]
1079 fn session_websocket_capability_absent_returns_ok_none() {
1080 let raw = r#"{
1081 "capabilities": {},
1082 "accounts": {},
1083 "primaryAccounts": {},
1084 "username": "u@example.com",
1085 "apiUrl": "https://jmap.example.com/api/",
1086 "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
1087 "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
1088 "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
1089 "state": "s1"
1090 }"#;
1091 let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
1092 let result = session.websocket_capability();
1093 assert!(
1094 matches!(result, Ok(None)),
1095 "expected Ok(None), got {result:?}"
1096 );
1097 }
1098
1099 /// Oracle: Session.websocket_capability() returns Ok(Some) when key present and valid.
1100 #[test]
1101 fn session_websocket_capability_present_and_valid() {
1102 let raw = r#"{
1103 "capabilities": {
1104 "urn:ietf:params:jmap:websocket": {
1105 "url": "wss://jmap.example.com/ws",
1106 "supportsPush": true
1107 }
1108 },
1109 "accounts": {},
1110 "primaryAccounts": {},
1111 "username": "u@example.com",
1112 "apiUrl": "https://jmap.example.com/api/",
1113 "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
1114 "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
1115 "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
1116 "state": "s1"
1117 }"#;
1118 let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
1119 let ws = session
1120 .websocket_capability()
1121 .expect("must not error")
1122 .expect("websocket capability must be present");
1123 assert_eq!(ws.url, "wss://jmap.example.com/ws");
1124 assert!(ws.supports_push);
1125 }
1126
1127 /// Oracle: `Session::supports_cid()` returns `false` when the JMAP
1128 /// CID capability URI is not present in the capabilities map
1129 /// (bd:JMAP-v9py.14).
1130 ///
1131 /// Mirrors the absent-key precedent of
1132 /// `session_websocket_capability_absent_returns_ok_none`. The test
1133 /// fixture has an empty capabilities map; the negative answer must
1134 /// be `false`, not `Err` or panic.
1135 #[test]
1136 fn supports_cid_returns_false_when_capability_absent() {
1137 let raw = r#"{
1138 "capabilities": {},
1139 "accounts": {},
1140 "primaryAccounts": {},
1141 "username": "u@example.com",
1142 "apiUrl": "https://jmap.example.com/api/",
1143 "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
1144 "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
1145 "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
1146 "state": "s1"
1147 }"#;
1148 let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
1149 assert!(!session.supports_cid());
1150 }
1151
1152 /// Oracle: `Session::supports_cid()` returns `true` when the JMAP
1153 /// CID capability URI is present in the capabilities map, even
1154 /// though the value object is empty per draft-atwood-jmap-cid-00
1155 /// §2 ("no capability fields defined at this time")
1156 /// (bd:JMAP-v9py.14).
1157 #[test]
1158 fn supports_cid_returns_true_when_capability_present_empty_value() {
1159 let raw = r#"{
1160 "capabilities": {
1161 "urn:ietf:params:jmap:cid": {}
1162 },
1163 "accounts": {},
1164 "primaryAccounts": {},
1165 "username": "u@example.com",
1166 "apiUrl": "https://jmap.example.com/api/",
1167 "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
1168 "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
1169 "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
1170 "state": "s1"
1171 }"#;
1172 let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
1173 assert!(session.supports_cid());
1174 }
1175
1176 /// Oracle: `Session::supports_cid()` checks only for the URI key —
1177 /// presence with a non-empty value object (vendor extras inside the
1178 /// CID capability) still returns `true`. The draft reserves the
1179 /// shape of the capability value but does not currently define any
1180 /// fields; a server that pre-populates vendor fields under the URI
1181 /// must still be detected as supporting CID.
1182 #[test]
1183 fn supports_cid_returns_true_when_capability_present_with_extra_fields() {
1184 let raw = r#"{
1185 "capabilities": {
1186 "urn:ietf:params:jmap:cid": {
1187 "x-vendor-flag": "future-shape"
1188 }
1189 },
1190 "accounts": {},
1191 "primaryAccounts": {},
1192 "username": "u@example.com",
1193 "apiUrl": "https://jmap.example.com/api/",
1194 "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
1195 "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
1196 "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
1197 "state": "s1"
1198 }"#;
1199 let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
1200 assert!(session.supports_cid());
1201 }
1202
1203 /// Oracle: Session's manual Debug impl never reveals the authenticated
1204 /// `username` or the opaque `state` token (bd:JMAP-sc1b.99), AND the
1205 /// `accounts` map does not transitively leak `AccountInfo.name`
1206 /// (bd:JMAP-sc1b.104). Mirrors the canary tripwire pattern used by
1207 /// `bearer_auth_debug_does_not_leak_token` and
1208 /// `basic_auth_debug_does_not_leak_credentials` in auth.rs.
1209 ///
1210 /// The canary literals are independent of the Session's internal state —
1211 /// the test is the oracle, not the code under test. A regression that
1212 /// re-derives `Debug` on `Session` or `AccountInfo`, or that prints the
1213 /// username/state/name via a manual impl, would fail the assertion.
1214 ///
1215 /// We deliberately reuse `CANARY_USER` in two distinct locations
1216 /// (`username` and `accounts["a1"].name`) so a single negative
1217 /// `assert!(!dbg.contains(...))` catches a leak from either path —
1218 /// the same kind of email-shaped PII surfacing through either field
1219 /// is the failure we want to fail loudly.
1220 #[test]
1221 fn session_debug_does_not_leak_username_or_state() {
1222 const CANARY_USER: &str = "CANARY-USERNAME-DO-NOT-LEAK@example.com";
1223 const CANARY_STATE: &str = "CANARY-STATE-TOKEN-DO-NOT-LEAK";
1224 let raw = format!(
1225 r#"{{
1226 "capabilities": {{}},
1227 "accounts": {{
1228 "a1": {{
1229 "name": "{CANARY_USER}",
1230 "isPersonal": true,
1231 "isReadOnly": false,
1232 "accountCapabilities": {{}}
1233 }}
1234 }},
1235 "primaryAccounts": {{}},
1236 "username": "{CANARY_USER}",
1237 "apiUrl": "https://jmap.example.com/api/",
1238 "downloadUrl": "https://jmap.example.com/dl/{{accountId}}/",
1239 "uploadUrl": "https://jmap.example.com/ul/{{accountId}}/",
1240 "eventSourceUrl": "https://jmap.example.com/sse/",
1241 "state": "{CANARY_STATE}"
1242 }}"#
1243 );
1244 let session: Session = serde_json::from_str(&raw).expect("Session must deserialize");
1245
1246 // Sanity-check: the canary really did land in the AccountInfo —
1247 // otherwise an empty accounts map would silently make the
1248 // transitive-leak assertion below tautologically pass.
1249 let account = session
1250 .accounts
1251 .get("a1")
1252 .expect("accounts['a1'] must deserialize");
1253 assert_eq!(account.name, CANARY_USER);
1254
1255 let dbg = format!("{session:?}");
1256 assert!(
1257 !dbg.contains(CANARY_USER),
1258 "Session Debug must not contain the raw username or AccountInfo.name; got: {dbg}"
1259 );
1260 assert!(
1261 !dbg.contains(CANARY_STATE),
1262 "Session Debug must not contain the raw state token; got: {dbg}"
1263 );
1264 }
1265
1266 /// Oracle: AccountInfo's manual Debug impl never reveals the raw
1267 /// `name` field (bd:JMAP-sc1b.104). Independent of the Session-level
1268 /// test above: a regression on AccountInfo alone (e.g. re-deriving
1269 /// `#[derive(Debug)]`) would be caught here without needing the
1270 /// Session wrapper.
1271 #[test]
1272 fn account_info_debug_does_not_leak_name() {
1273 const CANARY_NAME: &str = "CANARY-ACCOUNT-NAME-DO-NOT-LEAK@example.com";
1274 let raw = format!(
1275 r#"{{
1276 "name": "{CANARY_NAME}",
1277 "isPersonal": true,
1278 "isReadOnly": false,
1279 "accountCapabilities": {{}}
1280 }}"#
1281 );
1282 let account: AccountInfo =
1283 serde_json::from_str(&raw).expect("AccountInfo must deserialize");
1284 // Sanity-check that the canary really did populate `name`.
1285 assert_eq!(account.name, CANARY_NAME);
1286
1287 let dbg = format!("{account:?}");
1288 assert!(
1289 !dbg.contains(CANARY_NAME),
1290 "AccountInfo Debug must not contain the raw name; got: {dbg}"
1291 );
1292 }
1293
1294 // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
1295 //
1296 // Synthetic `acmeCorp*` vendor keys cannot collide with any RFC 8620 /
1297 // RFC 8887 typed field, so the tests are independent of the code under
1298 // test (workspace test-integrity rule).
1299
1300 /// `Session.extra` captures unknown fields on deserialize.
1301 #[test]
1302 fn session_preserves_vendor_extras() {
1303 let raw = json!({
1304 "capabilities": {},
1305 "accounts": {},
1306 "primaryAccounts": {},
1307 "username": "u@example.com",
1308 "apiUrl": "https://jmap.example.com/api/",
1309 "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
1310 "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
1311 "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
1312 "state": "s1",
1313 "acmeCorpDeployment": "prod-eu-west-1"
1314 });
1315 let obj: Session = serde_json::from_value(raw).expect("Session must deserialize");
1316 assert_eq!(
1317 obj.extra.get("acmeCorpDeployment").and_then(|v| v.as_str()),
1318 Some("prod-eu-west-1")
1319 );
1320 }
1321
1322 /// `AccountInfo.extra` captures unknown fields on deserialize.
1323 #[test]
1324 fn account_info_preserves_vendor_extras() {
1325 let raw = json!({
1326 "name": "u@example.com",
1327 "isPersonal": true,
1328 "isReadOnly": false,
1329 "accountCapabilities": {},
1330 "acmeCorpQuotaTier": "gold"
1331 });
1332 let obj: AccountInfo = serde_json::from_value(raw).expect("AccountInfo must deserialize");
1333 assert_eq!(
1334 obj.extra.get("acmeCorpQuotaTier").and_then(|v| v.as_str()),
1335 Some("gold")
1336 );
1337 }
1338
1339 /// `WebSocketCapability.extra` captures unknown fields on deserialize.
1340 #[test]
1341 fn websocket_capability_preserves_vendor_extras() {
1342 let raw = json!({
1343 "url": "wss://jmap.example.com/ws",
1344 "supportsPush": true,
1345 "acmeCorpHeartbeatMs": 30000
1346 });
1347 let obj: WebSocketCapability =
1348 serde_json::from_value(raw).expect("WebSocketCapability must deserialize");
1349 assert_eq!(
1350 obj.extra
1351 .get("acmeCorpHeartbeatMs")
1352 .and_then(|v| v.as_u64()),
1353 Some(30000)
1354 );
1355 }
1356
1357 // -----------------------------------------------------------------------
1358 // Session::extension_capability / AccountInfo::account_extension_capability
1359 // (bd:JMAP-6r7c.22)
1360 // -----------------------------------------------------------------------
1361
1362 /// Hand-written capability struct standing in for any future extension
1363 /// (e.g. JMAP Mail / Calendars / Tasks). Has the same shape as
1364 /// `WebSocketCapability` deliberately — the helper is generic, the
1365 /// caller supplies the schema.
1366 #[derive(Debug, Deserialize, PartialEq)]
1367 #[serde(rename_all = "camelCase")]
1368 struct FakeMailCapability {
1369 max_size_upload: u64,
1370 max_size_request: u64,
1371 }
1372
1373 fn build_session_with_capability(uri: &str, value: serde_json::Value) -> Session {
1374 let raw = json!({
1375 "capabilities": { uri: value },
1376 "accounts": {},
1377 "primaryAccounts": {},
1378 "username": "u@example.com",
1379 "apiUrl": "https://jmap.example.com/api/",
1380 "downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
1381 "uploadUrl": "https://jmap.example.com/ul/{accountId}/",
1382 "eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
1383 "state": "s1",
1384 });
1385 serde_json::from_value(raw).expect("Session must deserialize")
1386 }
1387
1388 /// Oracle: `Session::extension_capability::<T>` returns `Ok(None)` when
1389 /// the capability key is absent, regardless of `T`.
1390 #[test]
1391 fn extension_capability_absent_returns_ok_none() {
1392 let session = build_session_with_capability(
1393 "urn:ietf:params:jmap:other",
1394 json!({"unrelated": "value"}),
1395 );
1396 let result: Result<Option<FakeMailCapability>, _> =
1397 session.extension_capability("urn:ietf:params:jmap:mail");
1398 assert!(
1399 matches!(result, Ok(None)),
1400 "absent capability key must return Ok(None), got {result:?}"
1401 );
1402 }
1403
1404 /// Oracle: `Session::extension_capability::<T>` returns `Ok(Some(T))`
1405 /// when the capability is present and the value matches `T`.
1406 #[test]
1407 fn extension_capability_present_and_valid_returns_ok_some() {
1408 let session = build_session_with_capability(
1409 "urn:ietf:params:jmap:mail",
1410 json!({"maxSizeUpload": 50000000, "maxSizeRequest": 10000000}),
1411 );
1412 let cap: FakeMailCapability = session
1413 .extension_capability("urn:ietf:params:jmap:mail")
1414 .expect("must not error")
1415 .expect("capability must be present");
1416 assert_eq!(cap.max_size_upload, 50_000_000);
1417 assert_eq!(cap.max_size_request, 10_000_000);
1418 }
1419
1420 /// Oracle: `Session::extension_capability::<T>` returns
1421 /// `Err(ClientError::Parse)` when the capability is present but the
1422 /// value cannot deserialise into `T` (server bug or schema mismatch).
1423 #[test]
1424 fn extension_capability_present_but_malformed_returns_parse_err() {
1425 let session = build_session_with_capability(
1426 "urn:ietf:params:jmap:mail",
1427 // Wrong shape — missing required maxSizeRequest field.
1428 json!({"maxSizeUpload": 50000000}),
1429 );
1430 let result: Result<Option<FakeMailCapability>, _> =
1431 session.extension_capability("urn:ietf:params:jmap:mail");
1432 assert!(
1433 matches!(result, Err(ClientError::Parse(_))),
1434 "malformed capability value must surface as ClientError::Parse, got {result:?}"
1435 );
1436 }
1437
1438 /// Oracle: `Session::websocket_capability()` delegates to
1439 /// `extension_capability` and the existing semantics are preserved
1440 /// (regression test for the refactor).
1441 #[test]
1442 fn websocket_capability_still_works_after_refactor() {
1443 let session = build_session_with_capability(
1444 "urn:ietf:params:jmap:websocket",
1445 json!({"url": "wss://jmap.example.com/ws", "supportsPush": true}),
1446 );
1447 let ws = session
1448 .websocket_capability()
1449 .expect("must not error")
1450 .expect("websocket capability must be present");
1451 assert_eq!(ws.url, "wss://jmap.example.com/ws");
1452 assert!(ws.supports_push);
1453 }
1454
1455 /// Oracle: `AccountInfo::account_extension_capability::<T>` returns
1456 /// `Ok(None)` when the per-account capability key is absent.
1457 #[test]
1458 fn account_extension_capability_absent_returns_ok_none() {
1459 let raw = json!({
1460 "name": "alice@example.com",
1461 "isPersonal": true,
1462 "isReadOnly": false,
1463 "accountCapabilities": {},
1464 });
1465 let acct: AccountInfo = serde_json::from_value(raw).expect("AccountInfo must deserialize");
1466 let result: Result<Option<FakeMailCapability>, _> =
1467 acct.account_extension_capability("urn:ietf:params:jmap:mail");
1468 assert!(
1469 matches!(result, Ok(None)),
1470 "absent per-account capability must return Ok(None), got {result:?}"
1471 );
1472 }
1473
1474 /// Oracle: `AccountInfo::account_extension_capability::<T>` returns
1475 /// `Ok(Some(T))` when the per-account capability is present and valid.
1476 #[test]
1477 fn account_extension_capability_present_and_valid() {
1478 let raw = json!({
1479 "name": "alice@example.com",
1480 "isPersonal": true,
1481 "isReadOnly": false,
1482 "accountCapabilities": {
1483 "urn:ietf:params:jmap:mail": {
1484 "maxSizeUpload": 50000000,
1485 "maxSizeRequest": 10000000,
1486 },
1487 },
1488 });
1489 let acct: AccountInfo = serde_json::from_value(raw).expect("AccountInfo must deserialize");
1490 let cap: FakeMailCapability = acct
1491 .account_extension_capability("urn:ietf:params:jmap:mail")
1492 .expect("must not error")
1493 .expect("capability must be present");
1494 assert_eq!(cap.max_size_upload, 50_000_000);
1495 }
1496
1497 // bd:JMAP-6r7c.40 — Typed URL wrappers (JmapUrl, JmapUrlTemplate)
1498
1499 /// `JmapUrl` and `JmapUrlTemplate` are distinct types at the type
1500 /// level. A function that takes `&JmapUrlTemplate` MUST refuse a
1501 /// `&JmapUrl` argument and vice versa. This is the compile-time
1502 /// guard that prevents callers from accidentally passing
1503 /// `session.api_url` (a plain URL) to a function expecting a
1504 /// template, or `session.upload_url` (a template) to a function
1505 /// expecting a plain URL.
1506 ///
1507 /// Implemented as compile-time witness: the function bodies do
1508 /// nothing useful; if either signature compiled with the other
1509 /// type, the test would break the type-distinction invariant.
1510 #[test]
1511 fn jmap_url_and_template_are_distinct_types() {
1512 fn _takes_plain_url(_u: &JmapUrl) {}
1513 fn _takes_template(_t: &JmapUrlTemplate) {}
1514
1515 let plain = JmapUrl::new("https://example.com/api/");
1516 let template = JmapUrlTemplate::new("https://example.com/upload/{accountId}/");
1517
1518 _takes_plain_url(&plain);
1519 _takes_template(&template);
1520
1521 // The interesting non-compilation cases:
1522 // _takes_plain_url(&template); // FAILS: expected JmapUrl, got JmapUrlTemplate
1523 // _takes_template(&plain); // FAILS: expected JmapUrlTemplate, got JmapUrl
1524 // These cannot be expressed as runtime assertions; the test's
1525 // value is locking in the distinct-types invariant so a future
1526 // refactor that accidentally collapses the wrappers (e.g. a
1527 // `type JmapUrlTemplate = JmapUrl;` alias) breaks the function
1528 // signatures above and the build fails.
1529 }
1530
1531 /// `JmapUrl` round-trips through serde_json as a transparent
1532 /// string. Oracle: hand-written JSON containing a quoted string.
1533 #[test]
1534 fn jmap_url_serde_round_trip() {
1535 let original = JmapUrl::new("https://example.com/api/");
1536 let json = serde_json::to_value(&original).expect("must serialise");
1537 assert_eq!(json, serde_json::json!("https://example.com/api/"));
1538 let restored: JmapUrl = serde_json::from_value(json).expect("must deserialise");
1539 assert_eq!(restored, original);
1540 }
1541
1542 /// `JmapUrlTemplate` round-trips through serde_json as a transparent
1543 /// string.
1544 #[test]
1545 fn jmap_url_template_serde_round_trip() {
1546 let original = JmapUrlTemplate::new("https://example.com/upload/{accountId}/");
1547 let json = serde_json::to_value(&original).expect("must serialise");
1548 assert_eq!(
1549 json,
1550 serde_json::json!("https://example.com/upload/{accountId}/")
1551 );
1552 let restored: JmapUrlTemplate = serde_json::from_value(json).expect("must deserialise");
1553 assert_eq!(restored, original);
1554 }
1555
1556 /// `PartialEq<&str>` and `PartialEq<str>` ergonomics for
1557 /// `assert_eq!(session.api_url, "...")` style assertions in
1558 /// downstream tests.
1559 #[test]
1560 fn jmap_url_partial_eq_str() {
1561 let url = JmapUrl::new("https://example.com/api/");
1562 assert_eq!(url, "https://example.com/api/");
1563 assert_eq!("https://example.com/api/", url);
1564 assert_ne!(url, "https://other.example.com/api/");
1565 }
1566}