Skip to main content

jmap_chat_client/methods/
quota.rs

1//! Quota/get — urn:ietf:params:jmap:quota
2//!
3//! Retrieves storage quota information from the server.  Only call when
4//! `ChatSessionExt::supports_quotas()` returns true.
5//!
6//! Spec: RFC 9425
7
8use serde::Deserialize;
9
10use jmap_types::{Id, State};
11
12use super::{ChangesResponse, GetResponse};
13
14/// A single JMAP Quota object (RFC 9425 §4).
15///
16/// Describes a storage limit that applies to one or more data types within
17/// a given scope.  Poll with [`SessionClient::quota_get`] to display storage
18/// usage in the UI and warn the user when approaching limits.
19///
20/// [`SessionClient::quota_get`]: super::SessionClient::quota_get
21#[non_exhaustive]
22#[derive(Debug, Clone, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct Quota {
25    /// Server-assigned identifier.
26    pub id: Id,
27    /// Human-readable name for this quota (e.g. `"Message Storage"`).
28    pub name: String,
29    /// Scope of the quota: `"account"`, `"domain"`, or `"global"`.
30    pub scope: crate::types::QuotaScope,
31    /// Resource type — `Count` (object-count-based) or `Octets`
32    /// (byte-based) per RFC 9425 §3.2. Element type is
33    /// [`crate::types::QuotaResourceType`] so callers can match on
34    /// typed variants directly; unknown wire strings land in
35    /// `QuotaResourceType::Other(s)` per the `impl_string_enum!`
36    /// round-trip contract.
37    pub resource_type: crate::types::QuotaResourceType,
38    /// Data type names covered by this quota (e.g. `["Message", "Chat"]`).
39    ///
40    /// Element type is `String` rather than a typed `DataTypeName` enum
41    /// because no such enum exists in the workspace foundation today —
42    /// JMAP data-type names span every extension (RFC 8621 Email,
43    /// Mailbox, Thread; draft-atwood-jmap-chat Chat, Message, Space;
44    /// RFC 8984/9425 Calendars; RFC 9553 Contacts; etc.) and a
45    /// cross-cutting enum would belong in `jmap-types` rather than
46    /// any single extension. Compare against the literal wire-form
47    /// type name (e.g. `"Message"`, `"Chat"`), or against constants
48    /// the consuming application maintains.
49    pub types: Vec<String>,
50    /// Bytes currently consumed.
51    pub used: u64,
52    /// Hard limit in bytes; requests that would exceed this MUST fail.
53    pub hard_limit: u64,
54    /// Warning threshold in bytes; clients SHOULD warn the user above this.
55    #[serde(default)]
56    pub warn_limit: Option<u64>,
57    /// Soft limit in bytes (server may begin rejecting requests above this).
58    #[serde(default)]
59    pub soft_limit: Option<u64>,
60    /// Optional human-readable description.
61    #[serde(default)]
62    pub description: Option<String>,
63    /// Catch-all for vendor / site / private extension fields not covered
64    /// by the typed fields above. Preserves unknown fields across
65    /// deserialize/serialize round-trip per workspace extras-preservation
66    /// policy (see workspace AGENTS.md).
67    ///
68    /// **Constraint**: keys in `extra` MUST NOT collide with the
69    /// typed-field wire names above (the camelCase spelling — e.g.
70    /// `"accountId"`, `"ids"`, `"properties"`, `"blobIds"`,
71    /// `"fromAccountId"`, etc.). On collision the typed-field value
72    /// wins on the wire and the `extra` value is silently dropped at
73    /// serialization. Place vendor extensions under vendor-prefixed
74    /// keys (e.g. `"acmeCorpFoo"`) to avoid the collision class.
75    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
76    pub extra: serde_json::Map<String, serde_json::Value>,
77}
78
79impl super::SessionClient {
80    /// Fetch all Quota objects for the account (RFC 9425 §4.2 Quota/get).
81    ///
82    /// Returns all quota records for the primary JMAP Chat account.  Each
83    /// [`Quota`] includes `used`, `hard_limit`, and optional `warn_limit` fields
84    /// that callers can use to display storage bars and warnings.
85    ///
86    /// The returned [`GetResponse::state`] token is preserved for
87    /// [`quota_changes`](Self::quota_changes) delta-sync support.
88    ///
89    /// Only call when [`crate::session::ChatSessionExt::supports_quotas`]
90    /// returns `true`.
91    ///
92    /// # Errors
93    ///
94    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
95    ///   if the bound session has no primary account for
96    ///   `urn:ietf:params:jmap:chat`.
97    /// - Any transport / protocol variant returned by
98    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call):
99    ///   [`Http`](jmap_base_client::ClientError::Http),
100    ///   [`Parse`](jmap_base_client::ClientError::Parse),
101    ///   [`AuthFailed`](jmap_base_client::ClientError::AuthFailed),
102    ///   [`MethodError`](jmap_base_client::ClientError::MethodError)
103    ///   (wraps RFC 8620 §3.6.2 method-level errors such as
104    ///   `accountNotFound`, `invalidArguments`, `serverFail`; servers
105    ///   that do not advertise `urn:ietf:params:jmap:quota` return
106    ///   `unknownCapability`),
107    ///   [`MethodNotFound`](jmap_base_client::ClientError::MethodNotFound),
108    ///   [`ResponseTooLarge`](jmap_base_client::ClientError::ResponseTooLarge),
109    ///   or
110    ///   [`UnexpectedResponse`](jmap_base_client::ClientError::UnexpectedResponse).
111    pub async fn quota_get(&self) -> Result<GetResponse<Quota>, jmap_base_client::ClientError> {
112        let (api_url, account_id) = self.session_parts()?;
113        let args = serde_json::json!({
114            "accountId": account_id,
115            "ids": serde_json::Value::Null,
116        });
117        let req = super::build_request("Quota/get", args, super::USING_QUOTA);
118        let resp = self.call_internal(api_url, &req).await?;
119        jmap_base_client::extract_response(&resp, super::CALL_ID)
120    }
121
122    /// Fetch changes to Quota objects since `since_state` (RFC 8620 §5.2 / Quota/changes).
123    ///
124    /// Returns ids of Quota objects created, updated, or destroyed since the
125    /// caller-supplied `since_state` token (typically the
126    /// [`GetResponse::state`] returned by an earlier
127    /// [`quota_get`](Self::quota_get) call).
128    ///
129    /// If [`ChangesResponse::has_more_changes`] is `true`, call again with
130    /// [`ChangesResponse::new_state`] as `since_state` until the flag is
131    /// `false`.
132    ///
133    /// `max_changes` caps the number of ids the server returns in a single
134    /// response; `None` lets the server choose. Servers are not required to
135    /// honour a `max_changes` hint exactly.
136    ///
137    /// Only call when [`crate::session::ChatSessionExt::supports_quotas`]
138    /// returns `true`.
139    ///
140    /// # Errors
141    ///
142    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
143    ///   if `since_state` is the empty string (defence-in-depth —
144    ///   `State` constructed via [`State::from`](jmap_types::State::from)
145    ///   accepts empty strings, but an empty `sinceState` is never
146    ///   useful).
147    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
148    ///   if the bound session has no primary account for
149    ///   `urn:ietf:params:jmap:chat`.
150    /// - Any transport / protocol variant returned by
151    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
152    ///   the matching error list on [`Self::quota_get`].
153    pub async fn quota_changes(
154        &self,
155        since_state: &State,
156        max_changes: Option<u64>,
157    ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
158        // Defence-in-depth: even with the typed-`State` parameter (a transparent
159        // newtype around `String`), an empty state token is still a logically
160        // invalid value that should be caught client-side rather than producing
161        // a confusing server-side `cannotCalculateChanges` error.
162        if since_state.as_ref().is_empty() {
163            return Err(jmap_base_client::ClientError::InvalidArgument(
164                "quota_changes: since_state may not be empty".into(),
165            ));
166        }
167        let (api_url, account_id) = self.session_parts()?;
168        let mut args = serde_json::json!({
169            "accountId": account_id,
170            "sinceState": since_state,
171        });
172        if let Some(mc) = max_changes {
173            args["maxChanges"] = mc.into();
174        }
175        let req = super::build_request("Quota/changes", args, super::USING_QUOTA);
176        let resp = self.call_internal(api_url, &req).await?;
177        jmap_base_client::extract_response(&resp, super::CALL_ID)
178    }
179}
180
181// ---------------------------------------------------------------------------
182// Tests
183// ---------------------------------------------------------------------------
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use serde_json::json;
189
190    // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
191    //
192    // The test deserialises wire JSON containing a synthetic `acmeCorp*`
193    // vendor field and asserts it survives in `extra`. The vendor field
194    // name cannot collide with any field defined in RFC 9425 §4, so the
195    // test is independent of the code under test (workspace
196    // test-integrity rule).
197
198    /// `Quota.extra` captures unknown fields on deserialize.
199    #[test]
200    fn quota_preserves_vendor_extras() {
201        let raw = json!({
202            "id": "Q1",
203            "name": "Message Storage",
204            "scope": "account",
205            "resourceType": "octets",
206            "types": ["Message"],
207            "used": 1024,
208            "hardLimit": 1048576,
209            "acmeCorpBillingTier": "enterprise"
210        });
211        let obj: Quota = serde_json::from_value(raw).expect("Quota must deserialize");
212        assert_eq!(
213            obj.extra
214                .get("acmeCorpBillingTier")
215                .and_then(|v| v.as_str()),
216            Some("enterprise")
217        );
218    }
219}