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#[derive(Debug, Clone, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct Quota {
24    /// Server-assigned identifier.
25    pub id: Id,
26    /// Human-readable name for this quota (e.g. `"Message Storage"`).
27    pub name: String,
28    /// Scope of the quota: `"account"`, `"domain"`, or `"global"`.
29    pub scope: crate::types::QuotaScope,
30    /// Resource type: `"octets"` (byte-based) or `"count"` (object-count-based).
31    pub resource_type: String,
32    /// Data type names covered by this quota (e.g. `["Message", "Chat"]`).
33    pub types: Vec<String>,
34    /// Bytes currently consumed.
35    pub used: u64,
36    /// Hard limit in bytes; requests that would exceed this MUST fail.
37    pub hard_limit: u64,
38    /// Warning threshold in bytes; clients SHOULD warn the user above this.
39    #[serde(default)]
40    pub warn_limit: Option<u64>,
41    /// Soft limit in bytes (server may begin rejecting requests above this).
42    #[serde(default)]
43    pub soft_limit: Option<u64>,
44    /// Optional human-readable description.
45    #[serde(default)]
46    pub description: Option<String>,
47    /// Catch-all for vendor / site / private extension fields not covered
48    /// by the typed fields above. Preserves unknown fields across
49    /// deserialize/serialize round-trip per workspace extras-preservation
50    /// policy (see workspace AGENTS.md).
51    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
52    pub extra: serde_json::Map<String, serde_json::Value>,
53}
54
55impl super::SessionClient {
56    /// Fetch all Quota objects for the account (RFC 9425 §4.2 Quota/get).
57    ///
58    /// Returns all quota records for the primary JMAP Chat account.  Each
59    /// [`Quota`] includes `used`, `hard_limit`, and optional `warn_limit` fields
60    /// that callers can use to display storage bars and warnings.
61    ///
62    /// The returned [`GetResponse::state`] token is preserved for
63    /// [`quota_changes`](Self::quota_changes) delta-sync support.
64    ///
65    /// Only call when [`crate::session::ChatSessionExt::supports_quotas`]
66    /// returns `true`.  Returns `ClientError::InvalidSession` if the session
67    /// has no primary JMAP Chat account.
68    pub async fn quota_get(&self) -> Result<GetResponse<Quota>, jmap_base_client::ClientError> {
69        let (api_url, account_id) = self.session_parts()?;
70        let args = serde_json::json!({
71            "accountId": account_id,
72            "ids": serde_json::Value::Null,
73        });
74        let req = super::build_request("Quota/get", args, super::USING_QUOTA);
75        let resp = self.call_internal(api_url, &req).await?;
76        jmap_base_client::extract_response(&resp, super::CALL_ID)
77    }
78
79    /// Fetch changes to Quota objects since `since_state` (RFC 8620 §5.2 / Quota/changes).
80    ///
81    /// Returns ids of Quota objects created, updated, or destroyed since the
82    /// caller-supplied `since_state` token (typically the
83    /// [`GetResponse::state`] returned by an earlier
84    /// [`quota_get`](Self::quota_get) call).
85    ///
86    /// If [`ChangesResponse::has_more_changes`] is `true`, call again with
87    /// [`ChangesResponse::new_state`] as `since_state` until the flag is
88    /// `false`.
89    ///
90    /// `max_changes` caps the number of ids the server returns in a single
91    /// response; `None` lets the server choose. Servers are not required to
92    /// honour a `max_changes` hint exactly.
93    ///
94    /// Only call when [`crate::session::ChatSessionExt::supports_quotas`]
95    /// returns `true`. Returns [`jmap_base_client::ClientError::InvalidArgument`]
96    /// if `since_state` is empty, or [`jmap_base_client::ClientError::InvalidSession`]
97    /// if the session has no primary JMAP Chat account.
98    pub async fn quota_changes(
99        &self,
100        since_state: &State,
101        max_changes: Option<u64>,
102    ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
103        // Defence-in-depth: even with the typed-`State` parameter (a transparent
104        // newtype around `String`), an empty state token is still a logically
105        // invalid value that should be caught client-side rather than producing
106        // a confusing server-side `cannotCalculateChanges` error.
107        if since_state.as_ref().is_empty() {
108            return Err(jmap_base_client::ClientError::InvalidArgument(
109                "quota_changes: since_state may not be empty".into(),
110            ));
111        }
112        let (api_url, account_id) = self.session_parts()?;
113        let mut args = serde_json::json!({
114            "accountId": account_id,
115            "sinceState": since_state,
116        });
117        if let Some(mc) = max_changes {
118            args["maxChanges"] = mc.into();
119        }
120        let req = super::build_request("Quota/changes", args, super::USING_QUOTA);
121        let resp = self.call_internal(api_url, &req).await?;
122        jmap_base_client::extract_response(&resp, super::CALL_ID)
123    }
124}
125
126// ---------------------------------------------------------------------------
127// Tests
128// ---------------------------------------------------------------------------
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use serde_json::json;
134
135    // ── Extras-preservation policy tests (JMAP-lbdy.9) ─────────────────
136    //
137    // The test deserialises wire JSON containing a synthetic `acmeCorp*`
138    // vendor field and asserts it survives in `extra`. The vendor field
139    // name cannot collide with any field defined in RFC 9425 §4, so the
140    // test is independent of the code under test (workspace
141    // test-integrity rule).
142
143    /// `Quota.extra` captures unknown fields on deserialize.
144    #[test]
145    fn quota_preserves_vendor_extras() {
146        let raw = json!({
147            "id": "Q1",
148            "name": "Message Storage",
149            "scope": "account",
150            "resourceType": "octets",
151            "types": ["Message"],
152            "used": 1024,
153            "hardLimit": 1048576,
154            "acmeCorpBillingTier": "enterprise"
155        });
156        let obj: Quota = serde_json::from_value(raw).expect("Quota must deserialize");
157        assert_eq!(
158            obj.extra
159                .get("acmeCorpBillingTier")
160                .and_then(|v| v.as_str()),
161            Some("enterprise")
162        );
163    }
164}