Skip to main content

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}