Skip to main content

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}