jmap_metadata_types/metadata.rs
1//! draft-ietf-jmap-metadata-01 §2 — Metadata object types.
2//!
3//! Provides [`Metadata`] — a tagged union of [`Annotation`], [`ImapMetadata`],
4//! and [`WebDavMetadata`] — discriminated by the `@type` property as defined
5//! in §2.1.
6
7use std::collections::BTreeMap;
8
9use jmap_types::Id;
10use serde::{Deserialize, Serialize};
11
12// ---------------------------------------------------------------------------
13// Metadata union
14// ---------------------------------------------------------------------------
15
16/// A JMAP Metadata object (draft-ietf-jmap-metadata-01 §2).
17///
18/// The spec defines three concrete object types — [`Annotation`],
19/// [`ImapMetadata`], and [`WebDavMetadata`] — all discriminated by the
20/// `@type` property. This enum is serialised with `@type` as the internal
21/// tag, matching the wire format defined by §2.1.
22///
23/// Additional metadata types MAY be defined by future specifications
24/// (§2.1). Such future types will require new variants on this enum; the
25/// `#[non_exhaustive]` derive keeps that non-breaking.
26///
27/// # Wire-format examples
28///
29/// ```json
30/// { "@type": "Annotation", "id": "MD1", "relatedType": "Mailbox",
31/// "relatedId": "MB1", "isPrivate": true,
32/// "acme.example.com:color": "blue" }
33///
34/// { "@type": "ImapMetadata", "id": "MD2", "relatedType": "Mailbox",
35/// "relatedId": "MB1", "isPrivate": false,
36/// "metadata": { "comment": "Team mailbox" } }
37///
38/// { "@type": "WebDavMetadata", "id": "MD3", "relatedType": "FileNode",
39/// "relatedId": "F1", "isPrivate": false,
40/// "metadata": { "{DAV:}displayname": "Project Documents" } }
41/// ```
42#[non_exhaustive]
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44#[serde(tag = "@type")]
45pub enum Metadata {
46 /// General-purpose vendor metadata (draft-ietf-jmap-metadata-01 §2.1.1).
47 Annotation(Annotation),
48 /// IMAP RFC 5464 mapping (draft-ietf-jmap-metadata-01 §2.1.2).
49 ImapMetadata(ImapMetadata),
50 /// WebDAV RFC 4918 dead-property mapping (draft-ietf-jmap-metadata-01 §2.1.3).
51 WebDavMetadata(WebDavMetadata),
52}
53
54impl Metadata {
55 /// Return the `id` property common to every Metadata variant.
56 pub fn id(&self) -> Option<&Id> {
57 match self {
58 Self::Annotation(a) => a.id.as_ref(),
59 Self::ImapMetadata(m) => m.id.as_ref(),
60 Self::WebDavMetadata(m) => m.id.as_ref(),
61 }
62 }
63
64 /// Return the `relatedType` property common to every Metadata variant,
65 /// or `None` if the wire input omitted it.
66 ///
67 /// The spec marks `relatedType` mandatory for full Metadata objects per
68 /// §2.2.1.3, but the `/get` partial-response shape defined by §4.1
69 /// legitimately omits it: §4.1 lists only `@type` and `relatedId` as
70 /// the properties a server MUST always include, regardless of the
71 /// caller's `metadataProperties` filter. The §7.2 example response
72 /// demonstrates this wire shape. This accessor therefore returns
73 /// `Option<&str>`. Callers that have a complete Metadata record
74 /// (created via `Metadata/set`) can `.expect()` here; callers
75 /// consuming partial responses from extended-`/get` should handle
76 /// the `None` arm — typically by inferring the type from the
77 /// related-object context.
78 pub fn related_type(&self) -> Option<&str> {
79 match self {
80 Self::Annotation(a) => a.related_type.as_deref(),
81 Self::ImapMetadata(m) => m.related_type.as_deref(),
82 Self::WebDavMetadata(m) => m.related_type.as_deref(),
83 }
84 }
85
86 /// Return the `relatedId` property common to every Metadata variant.
87 pub fn related_id(&self) -> &Id {
88 match self {
89 Self::Annotation(a) => &a.related_id,
90 Self::ImapMetadata(m) => &m.related_id,
91 Self::WebDavMetadata(m) => &m.related_id,
92 }
93 }
94
95 /// Return the `isPrivate` property common to every Metadata variant
96 /// (defaulting to `false` per §2.2.1.5 when the inner field is `None`).
97 pub fn is_private(&self) -> bool {
98 match self {
99 Self::Annotation(a) => a.is_private.unwrap_or(false),
100 Self::ImapMetadata(m) => m.is_private.unwrap_or(false),
101 Self::WebDavMetadata(m) => m.is_private.unwrap_or(false),
102 }
103 }
104
105 /// Return the wire string for the `@type` discriminator.
106 pub fn type_name(&self) -> &'static str {
107 match self {
108 Self::Annotation(_) => "Annotation",
109 Self::ImapMetadata(_) => "ImapMetadata",
110 Self::WebDavMetadata(_) => "WebDavMetadata",
111 }
112 }
113}
114
115// ---------------------------------------------------------------------------
116// Annotation
117// ---------------------------------------------------------------------------
118
119/// General-purpose vendor metadata (draft-ietf-jmap-metadata-01 §2.1.1).
120///
121/// The `@type` wire value is `"Annotation"`; it is supplied by the enclosing
122/// [`Metadata`] tag and does NOT appear as a field on this struct.
123///
124/// ## Vendor-specific properties (§2.2.1.6)
125///
126/// Annotations are designed to carry arbitrary vendor-defined properties
127/// beyond the common fields below. Vendor properties MUST be prefixed with a
128/// domain name owned by the vendor, e.g. `acme.example.com:color`. Those
129/// fields are captured in [`extra`](Self::extra) and round-trip
130/// losslessly per the workspace extras-preservation policy.
131///
132/// ## `id` is server-set
133///
134/// On `Metadata/set` create operations the client omits `id`; the server
135/// assigns it. `id` is `Option<Id>` so client-side request construction is
136/// natural.
137///
138/// ## `isPrivate` default
139///
140/// Per §2.2.1.5 the default is `false`. The field is `Option<bool>` so
141/// callers can distinguish "explicitly false on the wire" from "absent".
142/// Wire-omitted defaults to shared.
143///
144/// ## `relatedType` is optional on the wire
145///
146/// Per §2.2.1.3 `relatedType` is mandatory on full Metadata objects, but
147/// the extended-`/get` partial-response shape defined by §4.1 omits it
148/// when the caller filtered it out via `metadataProperties`. §4.1 lists
149/// only `@type` and `relatedId` as always-included; everything else
150/// (including `relatedType`) is omittable. The §7.2 example response
151/// demonstrates this shape. To round-trip the wire format faithfully
152/// the field is `Option<String>`. Callers constructing a Metadata
153/// object for `Metadata/set` MUST populate `relatedType` (otherwise
154/// the server will reject with `invalidProperties`).
155#[non_exhaustive]
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157#[serde(rename_all = "camelCase")]
158pub struct Annotation {
159 /// Server-assigned identifier. Absent on create requests.
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub id: Option<Id>,
162
163 /// JMAP type name of the related object (e.g. `"Email"`, `"Mailbox"`).
164 ///
165 /// Mandatory per §2.2.1.3 on full Metadata objects, but omittable in
166 /// the §4.1 extended-`/get` partial-response shape (the §7.2 example
167 /// demonstrates this). `None` indicates the wire input omitted the
168 /// field; callers consuming partial responses can infer the type
169 /// from context (e.g. the data type whose `/get` produced the
170 /// response). Callers constructing a Metadata object for
171 /// `Metadata/set` MUST populate this field.
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub related_type: Option<String>,
174
175 /// Identifier of the related JMAP object. Mandatory per §2.2.1.4.
176 pub related_id: Id,
177
178 /// Whether this annotation is private to the authenticated user.
179 /// Default `false` per §2.2.1.5.
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub is_private: Option<bool>,
182
183 /// Vendor-specific properties (§2.2.1.6) and any other extension fields
184 /// not covered by the typed fields above.
185 ///
186 /// Captures every JSON key on the wire that is not `@type`, `id`,
187 /// `relatedType`, `relatedId`, or `isPrivate`. Vendor properties are
188 /// domain-prefixed strings (e.g. `acme.example.com:color`) per the
189 /// spec's naming requirement.
190 ///
191 /// Round-trips losslessly per the workspace extras-preservation policy
192 /// (see workspace `AGENTS.md`). Empty-map case serialises to nothing,
193 /// preserving wire-byte identity with the spec examples.
194 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
195 pub extra: serde_json::Map<String, serde_json::Value>,
196}
197
198// ---------------------------------------------------------------------------
199// ImapMetadata
200// ---------------------------------------------------------------------------
201
202/// IMAP-METADATA-extension mapping (draft-ietf-jmap-metadata-01 §2.1.2).
203///
204/// The `@type` wire value is `"ImapMetadata"`; it is supplied by the enclosing
205/// [`Metadata`] tag and does NOT appear as a field on this struct.
206///
207/// ## `relatedType` constraint
208///
209/// Per §2.1.2 `relatedType` MUST be `"Mailbox"`. Servers MUST reject other
210/// values with an `invalidProperties` SetError. This crate does not enforce
211/// the constraint at the type level — that is the server's responsibility.
212///
213/// ## `metadata` key semantics
214///
215/// Per §2.2.2.1 the keys are IMAP metadata entry names with the
216/// `"/private/"` or `"/shared/"` prefix stripped. The interpretation
217/// depends on [`is_private`](Self::is_private): keys map under `/private/`
218/// when `true`, under `/shared/` otherwise.
219///
220/// An empty-string value is permitted and represents an IMAP entry that
221/// exists with no value (§2.2.2.1).
222#[non_exhaustive]
223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224#[serde(rename_all = "camelCase")]
225pub struct ImapMetadata {
226 /// Server-assigned identifier. Absent on create requests.
227 #[serde(skip_serializing_if = "Option::is_none")]
228 pub id: Option<Id>,
229
230 /// JMAP type name of the related object. MUST be `"Mailbox"` per §2.1.2.
231 ///
232 /// Mandatory per §2.2.1.3 on full Metadata objects, but omittable in
233 /// the §4.1 extended-`/get` partial-response shape. `None` indicates
234 /// the wire input omitted the field; consumers MAY infer
235 /// `"Mailbox"` from the variant (the spec fixes this association).
236 /// Callers constructing an `ImapMetadata` for `Metadata/set` MUST
237 /// populate this field with `"Mailbox"`.
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub related_type: Option<String>,
240
241 /// Identifier of the related JMAP `Mailbox` object.
242 pub related_id: Id,
243
244 /// Whether this metadata is private to the authenticated user. Selects
245 /// the `/private/` (`true`) vs `/shared/` (`false` or absent) IMAP
246 /// namespace prefix per §2.1.2 and §2.2.2.1.
247 #[serde(skip_serializing_if = "Option::is_none")]
248 pub is_private: Option<bool>,
249
250 /// IMAP metadata entries. Keys are entry names with the
251 /// `/private/` or `/shared/` prefix stripped (§2.2.2.1). Empty-string
252 /// values represent entries that exist but have no value.
253 ///
254 /// Uses `BTreeMap` for deterministic key ordering on serialise. The
255 /// spec does not require a specific ordering; deterministic output is
256 /// preferred for round-trip preservation tests and reproducible
257 /// builds. The wire format is `Map<String, String>` either way.
258 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
259 pub metadata: BTreeMap<String, String>,
260
261 /// Catch-all for vendor / site / private extension fields not covered
262 /// by the typed fields above. Preserves unknown fields across
263 /// deserialize/serialize round-trip per workspace extras-preservation
264 /// policy (see workspace AGENTS.md).
265 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
266 pub extra: serde_json::Map<String, serde_json::Value>,
267}
268
269// ---------------------------------------------------------------------------
270// WebDavMetadata
271// ---------------------------------------------------------------------------
272
273/// WebDAV dead-property mapping (draft-ietf-jmap-metadata-01 §2.1.3).
274///
275/// The `@type` wire value is `"WebDavMetadata"`; it is supplied by the
276/// enclosing [`Metadata`] tag and does NOT appear as a field on this struct.
277///
278/// ## `relatedType` constraint
279///
280/// Per §2.1.3 valid `relatedType` values include `"Calendar"`,
281/// `"CalendarEvent"`, `"AddressBook"`, `"ContactCard"`, and `"FileNode"`.
282/// Servers MAY reject other values with `invalidProperties`. This crate
283/// does not enforce the constraint at the type level.
284///
285/// ## `metadata` key format
286///
287/// Per §2.2.3.1 keys MUST use the expanded-name format
288/// `"{namespace-uri}localname"` (e.g.
289/// `"{http://example.com/ns}priority"`, `"{DAV:}displayname"`). Values
290/// are either simple text or serialised XML inner content for properties
291/// with complex structure.
292#[non_exhaustive]
293#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
294#[serde(rename_all = "camelCase")]
295pub struct WebDavMetadata {
296 /// Server-assigned identifier. Absent on create requests.
297 #[serde(skip_serializing_if = "Option::is_none")]
298 pub id: Option<Id>,
299
300 /// JMAP type name of the related object (e.g. `"FileNode"`,
301 /// `"Calendar"`, `"CalendarEvent"`, `"AddressBook"`, `"ContactCard"`).
302 ///
303 /// Mandatory per §2.2.1.3 on full Metadata objects, but omittable in
304 /// the §4.1 extended-`/get` partial-response shape. `None` indicates
305 /// the wire input omitted the field; consumers consuming partial
306 /// responses MAY infer the value from the request context.
307 /// Callers constructing a `WebDavMetadata` for `Metadata/set` MUST
308 /// populate this field.
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub related_type: Option<String>,
311
312 /// Identifier of the related JMAP object.
313 pub related_id: Id,
314
315 /// Whether this metadata is private to the authenticated user.
316 #[serde(skip_serializing_if = "Option::is_none")]
317 pub is_private: Option<bool>,
318
319 /// WebDAV dead properties. Keys MUST be in the expanded-name format
320 /// `"{namespace-uri}localname"` (§2.2.3.1). Values are simple text
321 /// for properties with text content, or serialised XML inner content
322 /// for properties with complex structure.
323 ///
324 /// `BTreeMap` is used for deterministic serialise ordering; the wire
325 /// format is `Map<String, String>` either way.
326 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
327 pub metadata: BTreeMap<String, String>,
328
329 /// Catch-all for vendor / site / private extension fields not covered
330 /// by the typed fields above. Preserves unknown fields across
331 /// deserialize/serialize round-trip per workspace extras-preservation
332 /// policy (see workspace AGENTS.md).
333 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
334 pub extra: serde_json::Map<String, serde_json::Value>,
335}