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