jmap_metadata_client/lib.rs
1//! jmap-metadata-client — JMAP Object Metadata extension client helpers.
2//!
3//! Implements the client-side helpers for
4//! [draft-ietf-jmap-metadata-04](https://datatracker.ietf.org/doc/draft-ietf-jmap-metadata/).
5//!
6//! ## Architecture change from -01 to -02
7//!
8//! The -02 revision eliminates the standalone `Metadata` object type.
9//! Metadata becomes `metadata` and `privateMetadata` properties on
10//! each listed JMAP data type. Clients read and write metadata
11//! through the existing `Foo/get`, `Foo/set`, `Foo/changes`, and
12//! `Foo/query` methods — there are no separate `Metadata/*` methods.
13//!
14//! This crate no longer provides a `SessionClient` or method bindings.
15//! Instead, it provides **pure helper functions** that consumers use
16//! alongside their existing extension-client crate calls:
17//!
18//! - **Property constants** — [`METADATA_PROPERTY`], [`PRIVATE_METADATA_PROPERTY`]
19//! for inclusion in `/get` `properties` lists.
20//! - **Filter-condition builders** — [`metadata_exists_condition`],
21//! [`metadata_text_contains_condition`], [`metadata_text_equals_condition`],
22//! and their `private_metadata_*` counterparts, producing JSON values
23//! suitable for merging into per-type `FilterCondition` objects.
24//! - **`/changes` helpers** — [`IGNORE_METADATA_ONLY_CHANGES`] arg name,
25//! [`is_metadata_property`], [`is_metadata_only_change`] for classifying
26//! `updatedProperties` in `/changes` responses.
27//!
28//! All functions are stateless and operate on string/JSON values.
29//!
30//! # Migration from -01
31//!
32//! The -01 crate exposed `JmapMetadataExt`, `SessionClient`,
33//! `metadata_get`, `metadata_set`, `metadata_changes`, `metadata_query`,
34//! `metadata_query_changes`, five `*Params` structs, and `MetadataChangesParams`.
35//! All of those are removed in -02. Extension-client crates that previously
36//! depended on this crate for standalone `Metadata/*` methods should instead
37//! use the helper functions from this crate alongside their own typed method
38//! calls (e.g. `email_get` with metadata properties, `email_query` with
39//! metadata filter conditions).
40//!
41//! # Example
42//!
43//! ```rust
44//! use jmap_metadata_client::{
45//! METADATA_PROPERTY,
46//! PRIVATE_METADATA_PROPERTY,
47//! metadata_exists_condition,
48//! metadata_text_contains_condition,
49//! };
50//!
51//! // Include metadata properties in a /get properties list
52//! let properties = vec!["id", "subject", METADATA_PROPERTY, PRIVATE_METADATA_PROPERTY];
53//! assert!(properties.contains(&"metadata"));
54//!
55//! // Build filter conditions for a /query request
56//! let exists = metadata_exists_condition("photography");
57//! assert_eq!(exists, serde_json::json!({ "metadataExists": "photography" }));
58//!
59//! let text_match = metadata_text_contains_condition("photography/caption", "sunset");
60//! assert_eq!(text_match, serde_json::json!({
61//! "metadataTextContains": { "path": "photography/caption", "value": "sunset" }
62//! }));
63//! ```
64
65#![forbid(unsafe_code)]
66
67// ---------------------------------------------------------------------------
68// Property name constants (§2)
69// ---------------------------------------------------------------------------
70
71/// Wire name of the shared metadata property on listed data types (§2).
72///
73/// Include in the `properties` list of a `/get` request to fetch the
74/// per-namespace shared metadata map.
75pub const METADATA_PROPERTY: &str = "metadata";
76
77/// Wire name of the per-user private metadata property on listed data
78/// types (§2).
79///
80/// Include in the `properties` list of a `/get` request to fetch the
81/// per-namespace per-user metadata map. Only available when the data type's
82/// `supportsPrivate` in the account capability is `true` (§1.2.1).
83pub const PRIVATE_METADATA_PROPERTY: &str = "privateMetadata";
84
85// ---------------------------------------------------------------------------
86// /changes helpers (§3.3)
87// ---------------------------------------------------------------------------
88
89/// Wire name of the `ignoreMetadataOnlyChanges` argument for `/changes`
90/// requests (draft-ietf-jmap-metadata-04 §3.3).
91///
92/// When set to `true` in a `/changes` request, objects whose only changes
93/// are to `metadata` and/or `privateMetadata` properties are excluded from
94/// the `updated` array in the response. The server still reports an id
95/// it "cannot determine" to be metadata-only. A request whose `using`
96/// lacks `urn:ietf:params:jmap:metadata` behaves as if this were `true`
97/// (-04 §3).
98pub const IGNORE_METADATA_ONLY_CHANGES: &str = "ignoreMetadataOnlyChanges";
99
100/// Returns `true` if `property` is one of the metadata-extension properties
101/// (`metadata` or `privateMetadata`).
102///
103/// Useful for classifying the `updatedProperties` array in a `/changes`
104/// response (draft-ietf-jmap-metadata-04 §3.3) to determine whether a
105/// change is metadata-only.
106///
107/// # Examples
108///
109/// ```
110/// use jmap_metadata_client::is_metadata_property;
111///
112/// assert!(is_metadata_property("metadata"));
113/// assert!(is_metadata_property("privateMetadata"));
114/// assert!(!is_metadata_property("subject"));
115/// assert!(!is_metadata_property("id"));
116/// ```
117pub fn is_metadata_property(property: &str) -> bool {
118 property == METADATA_PROPERTY || property == PRIVATE_METADATA_PROPERTY
119}
120
121/// Returns `true` if ALL properties in `updated_properties` are metadata
122/// properties (draft-ietf-jmap-metadata-04 §3.3).
123///
124/// A `/changes` response whose `updatedProperties` is a list indicates
125/// that only those properties changed. If every property in the list is
126/// a metadata property, the change is metadata-only and may be skipped by
127/// clients that don't need metadata state updates.
128///
129/// A `Mailbox/changes` list may also name the RFC 8621 §2.2 count
130/// properties: -04 §3.3 makes it "the union of the count property names
131/// and the metadata property names that may have changed", for example
132/// `["totalEmails", "metadata"]`. This returns `false` for such a list
133/// because a count changed too. A caller that treats "only metadata plus
134/// counts changed" as one case must check for the count names itself.
135///
136/// Returns `false` for an empty slice (no properties ≠ metadata-only).
137///
138/// # Examples
139///
140/// ```
141/// use jmap_metadata_client::is_metadata_only_change;
142///
143/// assert!(is_metadata_only_change(&["metadata".to_string()]));
144/// assert!(is_metadata_only_change(&["metadata".to_string(), "privateMetadata".to_string()]));
145/// assert!(!is_metadata_only_change(&["metadata".to_string(), "subject".to_string()]));
146/// assert!(!is_metadata_only_change(&[]));
147/// ```
148pub fn is_metadata_only_change(updated_properties: &[String]) -> bool {
149 !updated_properties.is_empty() && updated_properties.iter().all(|p| is_metadata_property(p))
150}
151
152// ---------------------------------------------------------------------------
153// Filter-condition builders (§3.6)
154// ---------------------------------------------------------------------------
155
156/// Build a `metadataExists` filter condition (§3.6).
157///
158/// Returns a JSON object `{ "metadataExists": "<path>" }` suitable for
159/// merging into a per-type `FilterCondition` object in a `/query` request.
160///
161/// `path` is a namespace identifier, optionally followed by an RFC 6901
162/// JSON Pointer into the namespace value, such as `<namespace>/<key>` or
163/// `<namespace>/<key>/<subkey>` (draft-ietf-jmap-metadata-04 §3.6).
164///
165/// # Examples
166///
167/// ```
168/// use jmap_metadata_client::metadata_exists_condition;
169/// use serde_json::json;
170///
171/// assert_eq!(
172/// metadata_exists_condition("photography"),
173/// json!({ "metadataExists": "photography" })
174/// );
175/// assert_eq!(
176/// metadata_exists_condition("acme.example.com/memo"),
177/// json!({ "metadataExists": "acme.example.com/memo" })
178/// );
179/// ```
180pub fn metadata_exists_condition(path: &str) -> serde_json::Value {
181 serde_json::json!({ "metadataExists": path })
182}
183
184/// Build a `privateMetadataExists` filter condition (§3.6).
185///
186/// Same as [`metadata_exists_condition`] but targets `privateMetadata`.
187///
188/// # Examples
189///
190/// ```
191/// use jmap_metadata_client::private_metadata_exists_condition;
192/// use serde_json::json;
193///
194/// assert_eq!(
195/// private_metadata_exists_condition("photography"),
196/// json!({ "privateMetadataExists": "photography" })
197/// );
198/// ```
199pub fn private_metadata_exists_condition(path: &str) -> serde_json::Value {
200 serde_json::json!({ "privateMetadataExists": path })
201}
202
203/// Build a `metadataTextContains` filter condition (§3.6).
204///
205/// Returns a JSON object suitable for merging into a per-type
206/// `FilterCondition`. Matches objects where the string value at `path`
207/// in the `metadata` map contains `value` as a substring.
208///
209/// # Examples
210///
211/// ```
212/// use jmap_metadata_client::metadata_text_contains_condition;
213/// use serde_json::json;
214///
215/// assert_eq!(
216/// metadata_text_contains_condition("photography/caption", "sunset"),
217/// json!({ "metadataTextContains": { "path": "photography/caption", "value": "sunset" } })
218/// );
219/// ```
220pub fn metadata_text_contains_condition(path: &str, value: &str) -> serde_json::Value {
221 serde_json::json!({
222 "metadataTextContains": {
223 "path": path,
224 "value": value,
225 }
226 })
227}
228
229/// Build a `privateMetadataTextContains` filter condition (§3.6).
230///
231/// Same as [`metadata_text_contains_condition`] but targets `privateMetadata`.
232///
233/// # Examples
234///
235/// ```
236/// use jmap_metadata_client::private_metadata_text_contains_condition;
237/// use serde_json::json;
238///
239/// assert_eq!(
240/// private_metadata_text_contains_condition("workflow/note", "urgent"),
241/// json!({ "privateMetadataTextContains": { "path": "workflow/note", "value": "urgent" } })
242/// );
243/// ```
244pub fn private_metadata_text_contains_condition(path: &str, value: &str) -> serde_json::Value {
245 serde_json::json!({
246 "privateMetadataTextContains": {
247 "path": path,
248 "value": value,
249 }
250 })
251}
252
253/// Build a `metadataTextEquals` filter condition (§3.6).
254///
255/// Returns a JSON object suitable for merging into a per-type
256/// `FilterCondition`. Matches objects where the string value at `path`
257/// in the `metadata` map is exactly equal to `value`.
258///
259/// # Examples
260///
261/// ```
262/// use jmap_metadata_client::metadata_text_equals_condition;
263/// use serde_json::json;
264///
265/// assert_eq!(
266/// metadata_text_equals_condition("photography/rating", "5"),
267/// json!({ "metadataTextEquals": { "path": "photography/rating", "value": "5" } })
268/// );
269/// ```
270pub fn metadata_text_equals_condition(path: &str, value: &str) -> serde_json::Value {
271 serde_json::json!({
272 "metadataTextEquals": {
273 "path": path,
274 "value": value,
275 }
276 })
277}
278
279/// Build a `privateMetadataTextEquals` filter condition (§3.6).
280///
281/// Same as [`metadata_text_equals_condition`] but targets `privateMetadata`.
282///
283/// # Examples
284///
285/// ```
286/// use jmap_metadata_client::private_metadata_text_equals_condition;
287/// use serde_json::json;
288///
289/// assert_eq!(
290/// private_metadata_text_equals_condition("workflow/status", "approved"),
291/// json!({ "privateMetadataTextEquals": { "path": "workflow/status", "value": "approved" } })
292/// );
293/// ```
294pub fn private_metadata_text_equals_condition(path: &str, value: &str) -> serde_json::Value {
295 serde_json::json!({
296 "privateMetadataTextEquals": {
297 "path": path,
298 "value": value,
299 }
300 })
301}
302
303// ---------------------------------------------------------------------------
304// Tests
305// ---------------------------------------------------------------------------
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use serde_json::json;
311
312 // -- Property constants ------------------------------------------------
313
314 #[test]
315 fn property_constants_match_spec_wire_names() {
316 assert_eq!(METADATA_PROPERTY, "metadata");
317 assert_eq!(PRIVATE_METADATA_PROPERTY, "privateMetadata");
318 }
319
320 // -- is_metadata_property ----------------------------------------------
321
322 #[test]
323 fn is_metadata_property_metadata() {
324 assert!(is_metadata_property("metadata"));
325 }
326
327 #[test]
328 fn is_metadata_property_private() {
329 assert!(is_metadata_property("privateMetadata"));
330 }
331
332 #[test]
333 fn is_metadata_property_non_metadata() {
334 assert!(!is_metadata_property("subject"));
335 assert!(!is_metadata_property("id"));
336 assert!(!is_metadata_property(""));
337 }
338
339 // -- is_metadata_only_change -------------------------------------------
340
341 #[test]
342 fn metadata_only_change_single() {
343 assert!(is_metadata_only_change(&["metadata".into()]));
344 }
345
346 #[test]
347 fn metadata_only_change_both() {
348 assert!(is_metadata_only_change(&[
349 "metadata".into(),
350 "privateMetadata".into()
351 ]));
352 }
353
354 #[test]
355 fn metadata_only_change_mixed() {
356 assert!(!is_metadata_only_change(&[
357 "metadata".into(),
358 "subject".into()
359 ]));
360 }
361
362 #[test]
363 fn metadata_only_change_empty() {
364 assert!(!is_metadata_only_change(&[]));
365 }
366
367 #[test]
368 fn metadata_only_change_non_metadata() {
369 assert!(!is_metadata_only_change(&["subject".into()]));
370 }
371
372 // -- Filter-condition builders -----------------------------------------
373
374 #[test]
375 fn metadata_exists_condition_namespace_only() {
376 assert_eq!(
377 metadata_exists_condition("photography"),
378 json!({ "metadataExists": "photography" })
379 );
380 }
381
382 #[test]
383 fn metadata_exists_condition_with_key() {
384 assert_eq!(
385 metadata_exists_condition("acme.example.com/memo"),
386 json!({ "metadataExists": "acme.example.com/memo" })
387 );
388 }
389
390 #[test]
391 fn private_metadata_exists_condition_spec() {
392 assert_eq!(
393 private_metadata_exists_condition("photography"),
394 json!({ "privateMetadataExists": "photography" })
395 );
396 }
397
398 #[test]
399 fn metadata_text_contains_wire_shape() {
400 let v = metadata_text_contains_condition("photography/caption", "sunset");
401 assert_eq!(v["metadataTextContains"]["path"], "photography/caption");
402 assert_eq!(v["metadataTextContains"]["value"], "sunset");
403 }
404
405 #[test]
406 fn private_metadata_text_contains_wire_shape() {
407 let v = private_metadata_text_contains_condition("workflow/note", "urgent");
408 assert_eq!(v["privateMetadataTextContains"]["path"], "workflow/note");
409 assert_eq!(v["privateMetadataTextContains"]["value"], "urgent");
410 }
411
412 #[test]
413 fn metadata_text_equals_wire_shape() {
414 let v = metadata_text_equals_condition("photography/rating", "5");
415 assert_eq!(v["metadataTextEquals"]["path"], "photography/rating");
416 assert_eq!(v["metadataTextEquals"]["value"], "5");
417 }
418
419 #[test]
420 fn private_metadata_text_equals_wire_shape() {
421 let v = private_metadata_text_equals_condition("workflow/status", "approved");
422 assert_eq!(v["privateMetadataTextEquals"]["path"], "workflow/status");
423 assert_eq!(v["privateMetadataTextEquals"]["value"], "approved");
424 }
425
426 // -- IGNORE_METADATA_ONLY_CHANGES constant -----------------------------
427
428 #[test]
429 fn ignore_metadata_only_changes_wire_name() {
430 assert_eq!(IGNORE_METADATA_ONLY_CHANGES, "ignoreMetadataOnlyChanges");
431 }
432}