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 pub fn related_type(&self) -> &str {
66 match self {
67 Self::Annotation(a) => a.related_type.as_str(),
68 Self::ImapMetadata(m) => m.related_type.as_str(),
69 Self::WebDavMetadata(m) => m.related_type.as_str(),
70 }
71 }
72
73 /// Return the `relatedId` property common to every Metadata variant.
74 pub fn related_id(&self) -> &Id {
75 match self {
76 Self::Annotation(a) => &a.related_id,
77 Self::ImapMetadata(m) => &m.related_id,
78 Self::WebDavMetadata(m) => &m.related_id,
79 }
80 }
81
82 /// Return the `isPrivate` property common to every Metadata variant
83 /// (defaulting to `false` per §2.2.1.5 when the inner field is `None`).
84 pub fn is_private(&self) -> bool {
85 match self {
86 Self::Annotation(a) => a.is_private.unwrap_or(false),
87 Self::ImapMetadata(m) => m.is_private.unwrap_or(false),
88 Self::WebDavMetadata(m) => m.is_private.unwrap_or(false),
89 }
90 }
91
92 /// Return the wire string for the `@type` discriminator.
93 pub fn type_name(&self) -> &'static str {
94 match self {
95 Self::Annotation(_) => "Annotation",
96 Self::ImapMetadata(_) => "ImapMetadata",
97 Self::WebDavMetadata(_) => "WebDavMetadata",
98 }
99 }
100}
101
102// ---------------------------------------------------------------------------
103// Annotation
104// ---------------------------------------------------------------------------
105
106/// General-purpose vendor metadata (draft-ietf-jmap-metadata-01 §2.1.1).
107///
108/// The `@type` wire value is `"Annotation"`; it is supplied by the enclosing
109/// [`Metadata`] tag and does NOT appear as a field on this struct.
110///
111/// ## Vendor-specific properties (§2.2.1.6)
112///
113/// Annotations are designed to carry arbitrary vendor-defined properties
114/// beyond the common fields below. Vendor properties MUST be prefixed with a
115/// domain name owned by the vendor, e.g. `acme.example.com:color`. Those
116/// fields are captured in [`extra`](Self::extra) and round-trip
117/// losslessly per the workspace extras-preservation policy.
118///
119/// ## `id` is server-set
120///
121/// On `Metadata/set` create operations the client omits `id`; the server
122/// assigns it. `id` is `Option<Id>` so client-side request construction is
123/// natural.
124///
125/// ## `isPrivate` default
126///
127/// Per §2.2.1.5 the default is `false`. The field is `Option<bool>` so
128/// callers can distinguish "explicitly false on the wire" from "absent".
129/// Wire-omitted defaults to shared.
130#[non_exhaustive]
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub struct Annotation {
134 /// Server-assigned identifier. Absent on create requests.
135 #[serde(skip_serializing_if = "Option::is_none")]
136 pub id: Option<Id>,
137
138 /// JMAP type name of the related object (e.g. `"Email"`, `"Mailbox"`).
139 /// Mandatory per §2.2.1.3.
140 pub related_type: String,
141
142 /// Identifier of the related JMAP object. Mandatory per §2.2.1.4.
143 pub related_id: Id,
144
145 /// Whether this annotation is private to the authenticated user.
146 /// Default `false` per §2.2.1.5.
147 #[serde(skip_serializing_if = "Option::is_none")]
148 pub is_private: Option<bool>,
149
150 /// Vendor-specific properties (§2.2.1.6) and any other extension fields
151 /// not covered by the typed fields above.
152 ///
153 /// Captures every JSON key on the wire that is not `@type`, `id`,
154 /// `relatedType`, `relatedId`, or `isPrivate`. Vendor properties are
155 /// domain-prefixed strings (e.g. `acme.example.com:color`) per the
156 /// spec's naming requirement.
157 ///
158 /// Round-trips losslessly per the workspace extras-preservation policy
159 /// (see workspace `AGENTS.md`). Empty-map case serialises to nothing,
160 /// preserving wire-byte identity with the spec examples.
161 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
162 pub extra: serde_json::Map<String, serde_json::Value>,
163}
164
165// ---------------------------------------------------------------------------
166// ImapMetadata
167// ---------------------------------------------------------------------------
168
169/// IMAP-METADATA-extension mapping (draft-ietf-jmap-metadata-01 §2.1.2).
170///
171/// The `@type` wire value is `"ImapMetadata"`; it is supplied by the enclosing
172/// [`Metadata`] tag and does NOT appear as a field on this struct.
173///
174/// ## `relatedType` constraint
175///
176/// Per §2.1.2 `relatedType` MUST be `"Mailbox"`. Servers MUST reject other
177/// values with an `invalidProperties` SetError. This crate does not enforce
178/// the constraint at the type level — that is the server's responsibility.
179///
180/// ## `metadata` key semantics
181///
182/// Per §2.2.2.1 the keys are IMAP metadata entry names with the
183/// `"/private/"` or `"/shared/"` prefix stripped. The interpretation
184/// depends on [`is_private`](Self::is_private): keys map under `/private/`
185/// when `true`, under `/shared/` otherwise.
186///
187/// An empty-string value is permitted and represents an IMAP entry that
188/// exists with no value (§2.2.2.1).
189#[non_exhaustive]
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase")]
192pub struct ImapMetadata {
193 /// Server-assigned identifier. Absent on create requests.
194 #[serde(skip_serializing_if = "Option::is_none")]
195 pub id: Option<Id>,
196
197 /// JMAP type name of the related object. MUST be `"Mailbox"` per §2.1.2.
198 pub related_type: String,
199
200 /// Identifier of the related JMAP `Mailbox` object.
201 pub related_id: Id,
202
203 /// Whether this metadata is private to the authenticated user. Selects
204 /// the `/private/` (`true`) vs `/shared/` (`false` or absent) IMAP
205 /// namespace prefix per §2.1.2 and §2.2.2.1.
206 #[serde(skip_serializing_if = "Option::is_none")]
207 pub is_private: Option<bool>,
208
209 /// IMAP metadata entries. Keys are entry names with the
210 /// `/private/` or `/shared/` prefix stripped (§2.2.2.1). Empty-string
211 /// values represent entries that exist but have no value.
212 ///
213 /// Uses `BTreeMap` for deterministic key ordering on serialise. The
214 /// spec does not require a specific ordering; deterministic output is
215 /// preferred for round-trip preservation tests and reproducible
216 /// builds. The wire format is `Map<String, String>` either way.
217 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
218 pub metadata: BTreeMap<String, String>,
219
220 /// Catch-all for vendor / site / private extension fields not covered
221 /// by the typed fields above. Preserves unknown fields across
222 /// deserialize/serialize round-trip per workspace extras-preservation
223 /// policy (see workspace AGENTS.md).
224 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
225 pub extra: serde_json::Map<String, serde_json::Value>,
226}
227
228// ---------------------------------------------------------------------------
229// WebDavMetadata
230// ---------------------------------------------------------------------------
231
232/// WebDAV dead-property mapping (draft-ietf-jmap-metadata-01 §2.1.3).
233///
234/// The `@type` wire value is `"WebDavMetadata"`; it is supplied by the
235/// enclosing [`Metadata`] tag and does NOT appear as a field on this struct.
236///
237/// ## `relatedType` constraint
238///
239/// Per §2.1.3 valid `relatedType` values include `"Calendar"`,
240/// `"CalendarEvent"`, `"AddressBook"`, `"ContactCard"`, and `"FileNode"`.
241/// Servers MAY reject other values with `invalidProperties`. This crate
242/// does not enforce the constraint at the type level.
243///
244/// ## `metadata` key format
245///
246/// Per §2.2.3.1 keys MUST use the expanded-name format
247/// `"{namespace-uri}localname"` (e.g.
248/// `"{http://example.com/ns}priority"`, `"{DAV:}displayname"`). Values
249/// are either simple text or serialised XML inner content for properties
250/// with complex structure.
251#[non_exhaustive]
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct WebDavMetadata {
255 /// Server-assigned identifier. Absent on create requests.
256 #[serde(skip_serializing_if = "Option::is_none")]
257 pub id: Option<Id>,
258
259 /// JMAP type name of the related object (e.g. `"FileNode"`,
260 /// `"Calendar"`, `"CalendarEvent"`, `"AddressBook"`, `"ContactCard"`).
261 pub related_type: String,
262
263 /// Identifier of the related JMAP object.
264 pub related_id: Id,
265
266 /// Whether this metadata is private to the authenticated user.
267 #[serde(skip_serializing_if = "Option::is_none")]
268 pub is_private: Option<bool>,
269
270 /// WebDAV dead properties. Keys MUST be in the expanded-name format
271 /// `"{namespace-uri}localname"` (§2.2.3.1). Values are simple text
272 /// for properties with text content, or serialised XML inner content
273 /// for properties with complex structure.
274 ///
275 /// `BTreeMap` is used for deterministic serialise ordering; the wire
276 /// format is `Map<String, String>` either way.
277 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
278 pub metadata: BTreeMap<String, String>,
279
280 /// Catch-all for vendor / site / private extension fields not covered
281 /// by the typed fields above. Preserves unknown fields across
282 /// deserialize/serialize round-trip per workspace extras-preservation
283 /// policy (see workspace AGENTS.md).
284 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
285 pub extra: serde_json::Map<String, serde_json::Value>,
286}