jmap-metadata-types 0.1.1

JMAP Object Metadata extension data types (draft-ietf-jmap-metadata-*)
Documentation
//! draft-ietf-jmap-metadata-01 §2 — Metadata object types.
//!
//! Provides [`Metadata`] — a tagged union of [`Annotation`], [`ImapMetadata`],
//! and [`WebDavMetadata`] — discriminated by the `@type` property as defined
//! in §2.1.

use std::collections::BTreeMap;

use jmap_types::Id;
use serde::{Deserialize, Serialize};

// ---------------------------------------------------------------------------
// Metadata union
// ---------------------------------------------------------------------------

/// A JMAP Metadata object (draft-ietf-jmap-metadata-01 §2).
///
/// The spec defines three concrete object types — [`Annotation`],
/// [`ImapMetadata`], and [`WebDavMetadata`] — all discriminated by the
/// `@type` property. This enum is serialised with `@type` as the internal
/// tag, matching the wire format defined by §2.1.
///
/// Additional metadata types MAY be defined by future specifications
/// (§2.1). Such future types will require new variants on this enum; the
/// `#[non_exhaustive]` derive keeps that non-breaking.
///
/// # Wire-format examples
///
/// ```json
/// { "@type": "Annotation", "id": "MD1", "relatedType": "Mailbox",
///   "relatedId": "MB1", "isPrivate": true,
///   "acme.example.com:color": "blue" }
///
/// { "@type": "ImapMetadata", "id": "MD2", "relatedType": "Mailbox",
///   "relatedId": "MB1", "isPrivate": false,
///   "metadata": { "comment": "Team mailbox" } }
///
/// { "@type": "WebDavMetadata", "id": "MD3", "relatedType": "FileNode",
///   "relatedId": "F1", "isPrivate": false,
///   "metadata": { "{DAV:}displayname": "Project Documents" } }
/// ```
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "@type")]
pub enum Metadata {
    /// General-purpose vendor metadata (draft-ietf-jmap-metadata-01 §2.1.1).
    Annotation(Annotation),
    /// IMAP RFC 5464 mapping (draft-ietf-jmap-metadata-01 §2.1.2).
    ImapMetadata(ImapMetadata),
    /// WebDAV RFC 4918 dead-property mapping (draft-ietf-jmap-metadata-01 §2.1.3).
    WebDavMetadata(WebDavMetadata),
}

impl Metadata {
    /// Return the `id` property common to every Metadata variant.
    pub fn id(&self) -> Option<&Id> {
        match self {
            Self::Annotation(a) => a.id.as_ref(),
            Self::ImapMetadata(m) => m.id.as_ref(),
            Self::WebDavMetadata(m) => m.id.as_ref(),
        }
    }

    /// Return the `relatedType` property common to every Metadata variant,
    /// or `None` if the wire input omitted it.
    ///
    /// The spec marks `relatedType` mandatory for full Metadata objects per
    /// §2.2.1.3, but the `/get` partial-response shape defined by §4.1
    /// legitimately omits it: §4.1 lists only `@type` and `relatedId` as
    /// the properties a server MUST always include, regardless of the
    /// caller's `metadataProperties` filter. The §7.2 example response
    /// demonstrates this wire shape. This accessor therefore returns
    /// `Option<&str>`. Callers that have a complete Metadata record
    /// (created via `Metadata/set`) can `.expect()` here; callers
    /// consuming partial responses from extended-`/get` should handle
    /// the `None` arm — typically by inferring the type from the
    /// related-object context.
    pub fn related_type(&self) -> Option<&str> {
        match self {
            Self::Annotation(a) => a.related_type.as_deref(),
            Self::ImapMetadata(m) => m.related_type.as_deref(),
            Self::WebDavMetadata(m) => m.related_type.as_deref(),
        }
    }

    /// Return the `relatedId` property common to every Metadata variant.
    pub fn related_id(&self) -> &Id {
        match self {
            Self::Annotation(a) => &a.related_id,
            Self::ImapMetadata(m) => &m.related_id,
            Self::WebDavMetadata(m) => &m.related_id,
        }
    }

    /// Return the `isPrivate` property common to every Metadata variant
    /// (defaulting to `false` per §2.2.1.5 when the inner field is `None`).
    pub fn is_private(&self) -> bool {
        match self {
            Self::Annotation(a) => a.is_private.unwrap_or(false),
            Self::ImapMetadata(m) => m.is_private.unwrap_or(false),
            Self::WebDavMetadata(m) => m.is_private.unwrap_or(false),
        }
    }

    /// Return the wire string for the `@type` discriminator.
    pub fn type_name(&self) -> &'static str {
        match self {
            Self::Annotation(_) => "Annotation",
            Self::ImapMetadata(_) => "ImapMetadata",
            Self::WebDavMetadata(_) => "WebDavMetadata",
        }
    }
}

// ---------------------------------------------------------------------------
// Annotation
// ---------------------------------------------------------------------------

/// General-purpose vendor metadata (draft-ietf-jmap-metadata-01 §2.1.1).
///
/// The `@type` wire value is `"Annotation"`; it is supplied by the enclosing
/// [`Metadata`] tag and does NOT appear as a field on this struct.
///
/// ## Vendor-specific properties (§2.2.1.6)
///
/// Annotations are designed to carry arbitrary vendor-defined properties
/// beyond the common fields below. Vendor properties MUST be prefixed with a
/// domain name owned by the vendor, e.g. `acme.example.com:color`. Those
/// fields are captured in [`extra`](Self::extra) and round-trip
/// losslessly per the workspace extras-preservation policy.
///
/// ## `id` is server-set
///
/// On `Metadata/set` create operations the client omits `id`; the server
/// assigns it. `id` is `Option<Id>` so client-side request construction is
/// natural.
///
/// ## `isPrivate` default
///
/// Per §2.2.1.5 the default is `false`. The field is `Option<bool>` so
/// callers can distinguish "explicitly false on the wire" from "absent".
/// Wire-omitted defaults to shared.
///
/// ## `relatedType` is optional on the wire
///
/// Per §2.2.1.3 `relatedType` is mandatory on full Metadata objects, but
/// the extended-`/get` partial-response shape defined by §4.1 omits it
/// when the caller filtered it out via `metadataProperties`. §4.1 lists
/// only `@type` and `relatedId` as always-included; everything else
/// (including `relatedType`) is omittable. The §7.2 example response
/// demonstrates this shape. To round-trip the wire format faithfully
/// the field is `Option<String>`. Callers constructing a Metadata
/// object for `Metadata/set` MUST populate `relatedType` (otherwise
/// the server will reject with `invalidProperties`).
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Annotation {
    /// Server-assigned identifier. Absent on create requests.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<Id>,

    /// JMAP type name of the related object (e.g. `"Email"`, `"Mailbox"`).
    ///
    /// Mandatory per §2.2.1.3 on full Metadata objects, but omittable in
    /// the §4.1 extended-`/get` partial-response shape (the §7.2 example
    /// demonstrates this). `None` indicates the wire input omitted the
    /// field; callers consuming partial responses can infer the type
    /// from context (e.g. the data type whose `/get` produced the
    /// response). Callers constructing a Metadata object for
    /// `Metadata/set` MUST populate this field.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub related_type: Option<String>,

    /// Identifier of the related JMAP object. Mandatory per §2.2.1.4.
    pub related_id: Id,

    /// Whether this annotation is private to the authenticated user.
    /// Default `false` per §2.2.1.5.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_private: Option<bool>,

    /// Vendor-specific properties (§2.2.1.6) and any other extension fields
    /// not covered by the typed fields above.
    ///
    /// Captures every JSON key on the wire that is not `@type`, `id`,
    /// `relatedType`, `relatedId`, or `isPrivate`. Vendor properties are
    /// domain-prefixed strings (e.g. `acme.example.com:color`) per the
    /// spec's naming requirement.
    ///
    /// Round-trips losslessly per the workspace extras-preservation policy
    /// (see workspace `AGENTS.md`). Empty-map case serialises to nothing,
    /// preserving wire-byte identity with the spec examples.
    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
    pub extra: serde_json::Map<String, serde_json::Value>,
}

// ---------------------------------------------------------------------------
// ImapMetadata
// ---------------------------------------------------------------------------

/// IMAP-METADATA-extension mapping (draft-ietf-jmap-metadata-01 §2.1.2).
///
/// The `@type` wire value is `"ImapMetadata"`; it is supplied by the enclosing
/// [`Metadata`] tag and does NOT appear as a field on this struct.
///
/// ## `relatedType` constraint
///
/// Per §2.1.2 `relatedType` MUST be `"Mailbox"`. Servers MUST reject other
/// values with an `invalidProperties` SetError. This crate does not enforce
/// the constraint at the type level — that is the server's responsibility.
///
/// ## `metadata` key semantics
///
/// Per §2.2.2.1 the keys are IMAP metadata entry names with the
/// `"/private/"` or `"/shared/"` prefix stripped. The interpretation
/// depends on [`is_private`](Self::is_private): keys map under `/private/`
/// when `true`, under `/shared/` otherwise.
///
/// An empty-string value is permitted and represents an IMAP entry that
/// exists with no value (§2.2.2.1).
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImapMetadata {
    /// Server-assigned identifier. Absent on create requests.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<Id>,

    /// JMAP type name of the related object. MUST be `"Mailbox"` per §2.1.2.
    ///
    /// Mandatory per §2.2.1.3 on full Metadata objects, but omittable in
    /// the §4.1 extended-`/get` partial-response shape. `None` indicates
    /// the wire input omitted the field; consumers MAY infer
    /// `"Mailbox"` from the variant (the spec fixes this association).
    /// Callers constructing an `ImapMetadata` for `Metadata/set` MUST
    /// populate this field with `"Mailbox"`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub related_type: Option<String>,

    /// Identifier of the related JMAP `Mailbox` object.
    pub related_id: Id,

    /// Whether this metadata is private to the authenticated user. Selects
    /// the `/private/` (`true`) vs `/shared/` (`false` or absent) IMAP
    /// namespace prefix per §2.1.2 and §2.2.2.1.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_private: Option<bool>,

    /// IMAP metadata entries. Keys are entry names with the
    /// `/private/` or `/shared/` prefix stripped (§2.2.2.1). Empty-string
    /// values represent entries that exist but have no value.
    ///
    /// Uses `BTreeMap` for deterministic key ordering on serialise. The
    /// spec does not require a specific ordering; deterministic output is
    /// preferred for round-trip preservation tests and reproducible
    /// builds. The wire format is `Map<String, String>` either way.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub metadata: BTreeMap<String, String>,

    /// Catch-all for vendor / site / private extension fields not covered
    /// by the typed fields above. Preserves unknown fields across
    /// deserialize/serialize round-trip per workspace extras-preservation
    /// policy (see workspace AGENTS.md).
    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
    pub extra: serde_json::Map<String, serde_json::Value>,
}

// ---------------------------------------------------------------------------
// WebDavMetadata
// ---------------------------------------------------------------------------

/// WebDAV dead-property mapping (draft-ietf-jmap-metadata-01 §2.1.3).
///
/// The `@type` wire value is `"WebDavMetadata"`; it is supplied by the
/// enclosing [`Metadata`] tag and does NOT appear as a field on this struct.
///
/// ## `relatedType` constraint
///
/// Per §2.1.3 valid `relatedType` values include `"Calendar"`,
/// `"CalendarEvent"`, `"AddressBook"`, `"ContactCard"`, and `"FileNode"`.
/// Servers MAY reject other values with `invalidProperties`. This crate
/// does not enforce the constraint at the type level.
///
/// ## `metadata` key format
///
/// Per §2.2.3.1 keys MUST use the expanded-name format
/// `"{namespace-uri}localname"` (e.g.
/// `"{http://example.com/ns}priority"`, `"{DAV:}displayname"`). Values
/// are either simple text or serialised XML inner content for properties
/// with complex structure.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebDavMetadata {
    /// Server-assigned identifier. Absent on create requests.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<Id>,

    /// JMAP type name of the related object (e.g. `"FileNode"`,
    /// `"Calendar"`, `"CalendarEvent"`, `"AddressBook"`, `"ContactCard"`).
    ///
    /// Mandatory per §2.2.1.3 on full Metadata objects, but omittable in
    /// the §4.1 extended-`/get` partial-response shape. `None` indicates
    /// the wire input omitted the field; consumers consuming partial
    /// responses MAY infer the value from the request context.
    /// Callers constructing a `WebDavMetadata` for `Metadata/set` MUST
    /// populate this field.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub related_type: Option<String>,

    /// Identifier of the related JMAP object.
    pub related_id: Id,

    /// Whether this metadata is private to the authenticated user.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_private: Option<bool>,

    /// WebDAV dead properties. Keys MUST be in the expanded-name format
    /// `"{namespace-uri}localname"` (§2.2.3.1). Values are simple text
    /// for properties with text content, or serialised XML inner content
    /// for properties with complex structure.
    ///
    /// `BTreeMap` is used for deterministic serialise ordering; the wire
    /// format is `Map<String, String>` either way.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub metadata: BTreeMap<String, String>,

    /// Catch-all for vendor / site / private extension fields not covered
    /// by the typed fields above. Preserves unknown fields across
    /// deserialize/serialize round-trip per workspace extras-preservation
    /// policy (see workspace AGENTS.md).
    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
    pub extra: serde_json::Map<String, serde_json::Value>,
}