Skip to main content

jmap_contacts_types/
card.rs

1//! RFC 9610 §3 — ContactCard object and filter types.
2//!
3//! A ContactCard is a JMAP wrapper around a JSContact Card (RFC 9553 §2),
4//! with two JMAP-specific additions: `id` and `addressBookIds`.
5//!
6//! Complex JSContact sub-objects (addresses, phones, emails, etc.) are
7//! represented as [`serde_json::Value`] because their nested structure is
8//! large and their schema is still evolving.  Callers that need typed access
9//! to sub-objects should deserialize the relevant `Value` field directly.
10
11use std::collections::BTreeMap;
12
13use jmap_metadata_types::MetadataTextMatch;
14use jmap_types::{Id, PatchObject, UTCDate, impl_string_enum};
15use serde::{Deserialize, Serialize};
16
17use crate::collision::{self, CollisionError};
18
19/// Camel-case wire-format names of every typed field on [`ContactCard`].
20///
21/// Used by [`ContactCard::validate_extras`] to detect vendor-extras
22/// key collisions before serialize. Order is not significant; the
23/// helper sorts collisions alphabetically. JMAP-glx8.25.
24const CONTACT_CARD_TYPED_FIELDS: &[&str] = &[
25    "id",
26    "addressBookIds",
27    "version",
28    "created",
29    "kind",
30    "language",
31    "members",
32    "prodId",
33    "relatedTo",
34    "uid",
35    "updated",
36    "name",
37    "nicknames",
38    "organizations",
39    "speakToAs",
40    "titles",
41    "emails",
42    "onlineServices",
43    "phones",
44    "preferredLanguages",
45    "calendars",
46    "schedulingAddresses",
47    "addresses",
48    "cryptoKeys",
49    "directories",
50    "links",
51    "media",
52    "localizations",
53    "anniversaries",
54    "keywords",
55    "notes",
56    "personalInfo",
57    "metadata",
58    "privateMetadata",
59];
60
61/// A JMAP ContactCard object (RFC 9610 §3).
62///
63/// Wraps the JSContact Card format (RFC 9553 §2) with two JMAP-specific
64/// additions: [`id`](ContactCard::id) and
65/// [`address_book_ids`](ContactCard::address_book_ids).
66///
67/// All fields use `Option` because:
68/// - RFC 9553 makes most fields optional.
69/// - JMAP `properties` argument allows partial responses; absent fields must
70///   not fail deserialization.
71///
72/// Complex sub-object maps (addresses, phones, emails, etc.) use
73/// [`serde_json::Value`] to avoid coupling this crate to every nested
74/// JSContact type.
75///
76/// # Construction
77///
78/// [`ContactCard::default()`] returns a card with every field unset,
79/// useful for incremental construction (e.g. building a `ContactCard/set
80/// create` argument by patching the wanted fields). It is **not** a valid
81/// wire payload on its own: RFC 9610 §3 requires `id` (server-set) and
82/// `addressBookIds` on full-object responses, and RFC 9553 §2.1 requires
83/// `@type` (implicit) plus `version` and `uid` on creation. The handler
84/// layer (`jmap-contacts-server`) is responsible for rejecting partial
85/// cards on `/set` create; servers building `/get` responses MUST fill in
86/// `id` and `addressBookIds` before serializing. JMAP-glx8.16.
87///
88/// # Size and depth
89///
90/// Consumers of `ContactCard` SHOULD impose a request-body size limit
91/// **at the HTTP layer** before deserializing. The 22 Sloppy-Value
92/// fields (see the inline note above [`name`](Self::name)) accept
93/// arbitrarily-large JSON blobs: a 50 MiB JSON body deserializes into
94/// a `~150 MiB` `serde_json::Value` tree.
95///
96/// `serde_json`'s default 128-level recursion limit protects against
97/// stack overflow from deeply-nested input (see the negative test
98/// `deeply_nested_sloppy_field_beyond_serde_json_limit_errors`) but
99/// does **not** bound heap memory or parser CPU. A `{"name":{"x":{"x":
100/// {...127 levels of single-key nesting...}}}}` payload is small on
101/// the wire but produces a deep `Value` tree; the same wire bytes
102/// re-shaped as a wide-array (`{"name":{"a":[{},{},...]}}`) consumes
103/// proportional memory.
104///
105/// This crate ships **no body-size constant**: the limit is consumer
106/// policy, not type-crate policy, consistent with the workspace's
107/// transport-less posture (see workspace AGENTS.md "What this
108/// workspace builds"). The reference handler convention is a 10 MiB
109/// body cap; production consumers MAY raise or lower it. JMAP-glx8.24.
110#[non_exhaustive]
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct ContactCard {
114    // ── JMAP additions (RFC 9610 §3) ─────────────────────────────────
115    /// Server-assigned immutable identifier (JMAP addition).
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub id: Option<Id>,
118
119    /// Set of AddressBook ids this card belongs to (JMAP addition; wire
120    /// name `addressBookIds`).
121    ///
122    /// Represented as `BTreeMap<Id, bool>` because the JMAP wire format
123    /// uses a JSON object with boolean values (RFC 9610 §3). Values are
124    /// always `true` in full-object responses; per RFC 9610 §3, "each
125    /// value MUST be true" — the type does not enforce that constraint,
126    /// so callers (and `jmap-contacts-server`) MUST reject `false`
127    /// values with `invalidProperties` per RFC 8620 §5.3. The map shape
128    /// is also used in PatchObject updates (RFC 8620 §5.3) where a
129    /// `null` value removes an entry.
130    ///
131    /// This shape matches the canonical `Email.mailbox_ids` precedent
132    /// in `jmap-mail-types`. JMAP-glx8.11.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub address_book_ids: Option<BTreeMap<Id, bool>>,
135
136    // ── RFC 9553 §2.1 Metadata ───────────────────────────────────────────
137    /// JSContact version; MUST be `"1.0"` when present.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub version: Option<String>,
140
141    /// Date and time when the Card was created (RFC 8620 UTCDate format:
142    /// `YYYY-MM-DDTHH:MM:SS[.SSS]Z`, exactly the `Z` UTC suffix).
143    ///
144    /// [`UTCDate`] is a transparent `String` newtype in `jmap-types`; it
145    /// performs **no parse validation** at construction. Callers building a
146    /// `ContactCard/set` argument MUST supply a syntactically valid
147    /// UTCDate; the handler layer rejects malformed values with
148    /// `invalidProperties`. JMAP-glx8.18.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub created: Option<UTCDate>,
151
152    /// Kind of entity: `"individual"`, `"group"`, `"org"`, `"location"`,
153    /// `"device"`, `"application"`, or a vendor-specific value.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub kind: Option<String>,
156
157    /// BCP 47 language tag for the primary language of the Card.
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub language: Option<String>,
160
161    /// For group cards (`kind = "group"`): set of member UIDs → `true`
162    /// (RFC 9553 §2.1.6).
163    ///
164    /// Values are always `true`; per RFC 9553 §2.1.6, "each value MUST
165    /// be true" — the type does not enforce that constraint, so callers
166    /// (and `jmap-contacts-server`) MUST reject `false` values with
167    /// `invalidProperties` per RFC 8620 §5.3. The map shape is also
168    /// used in PatchObject updates (RFC 8620 §5.3) where a `null` value
169    /// removes an entry. JMAP-glx8.11.
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub members: Option<BTreeMap<String, bool>>,
172
173    /// Identifier for the product that created the Card.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub prod_id: Option<String>,
176
177    /// Cards related to this one.  Map of uid → Relation object.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub related_to: Option<serde_json::Value>,
180
181    /// Globally unique identifier for this Card (often a UUID URN).
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub uid: Option<String>,
184
185    /// Date and time when the Card was last modified (UTCDate; server-set).
186    ///
187    /// See [`created`](Self::created) for the format and validation
188    /// contract. JMAP-glx8.18.
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub updated: Option<UTCDate>,
191
192    // ── RFC 9553 §2.2 Name and Organization ─────────────────────────────
193    //
194    // The 22 sub-object fields below are `Option<serde_json::Value>` per
195    // the workspace Sloppy-Value pattern (see workspace AGENTS.md). RFC
196    // 9553 §2.2-§2.8 requires that each value be a **JSON object** of
197    // the relevant sub-type shape, but the wire type accepts any JSON
198    // (string, number, array, bool, null, object) and round-trips it
199    // unchanged. A caller submitting a non-object value produces a
200    // wire-format violation; the handler layer (`jmap-contacts-server`)
201    // is responsible for rejecting non-object values with
202    // `invalidProperties` per RFC 8620 §5.3. Typed access from
203    // `jmap-jscontact-types` (re-exported at this crate's root) MUST
204    // wrap `serde_json::from_value` in a `Result` to handle non-object
205    // wire shapes gracefully. JMAP-glx8.13.
206    /// The name of the entity.  JSContact Name object (complex sub-object).
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub name: Option<serde_json::Value>,
209
210    /// Nicknames map: id → Nickname object.
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub nicknames: Option<serde_json::Value>,
213
214    /// Organizations map: id → Organization object.
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub organizations: Option<serde_json::Value>,
217
218    /// How to address the entity in spoken or written language.
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub speak_to_as: Option<serde_json::Value>,
221
222    /// Titles map: id → Title object.
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub titles: Option<serde_json::Value>,
225
226    // ── RFC 9553 §2.3 Contact ────────────────────────────────────────────
227    /// Email addresses map: id → EmailAddress object.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub emails: Option<serde_json::Value>,
230
231    /// Online services map: id → OnlineService object.
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub online_services: Option<serde_json::Value>,
234
235    /// Phone numbers map: id → Phone object.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub phones: Option<serde_json::Value>,
238
239    /// Preferred languages map: id → LanguagePref object.
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub preferred_languages: Option<serde_json::Value>,
242
243    // ── RFC 9553 §2.4 Calendaring ────────────────────────────────────────
244    /// Calendars map: id → Calendar object.
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub calendars: Option<serde_json::Value>,
247
248    /// Scheduling addresses map: id → SchedulingAddress object.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub scheduling_addresses: Option<serde_json::Value>,
251
252    // ── RFC 9553 §2.5 Address ────────────────────────────────────────────
253    /// Postal addresses map: id → Address object.
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub addresses: Option<serde_json::Value>,
256
257    // ── RFC 9553 §2.6 Resources ──────────────────────────────────────────
258    /// Cryptographic keys map: id → CryptoKey object.
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub crypto_keys: Option<serde_json::Value>,
261
262    /// Directories map: id → Directory object.
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub directories: Option<serde_json::Value>,
265
266    /// Links map: id → Link object.
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub links: Option<serde_json::Value>,
269
270    /// Media map: id → Media object (may include JMAP `blobId` extension).
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub media: Option<serde_json::Value>,
273
274    // ── RFC 9553 §2.7 Multilingual ───────────────────────────────────────
275    /// Localization patches map: BCP 47 language-tag →
276    /// [`PatchObject`].
277    ///
278    /// Wire format is byte-identical to a plain JSON object via
279    /// `#[serde(transparent)]` on `PatchObject`. The typed shape enforces the
280    /// outer object-map structure at the type system level; inner patch
281    /// values remain opaque JSON (per RFC 8620 §5.3).
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub localizations: Option<BTreeMap<String, PatchObject>>,
284
285    // ── RFC 9553 §2.8 Additional ─────────────────────────────────────────
286    /// Anniversaries map: id → Anniversary object.
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub anniversaries: Option<serde_json::Value>,
289
290    /// Keywords set: keyword → `true` (RFC 9553 §2.8.2 `String[Boolean]`).
291    ///
292    /// Typed because the wire shape is closed by spec and matches the
293    /// sibling `members` and `address_book_ids` fields. JMAP-glx8.3.
294    /// Values are always `true`; the type does not enforce that
295    /// constraint, so callers MUST reject `false` values per RFC 9553
296    /// §2.8.2. The map shape is also used in PatchObject updates
297    /// (RFC 8620 §5.3) where a `null` value removes an entry.
298    /// JMAP-glx8.11.
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub keywords: Option<BTreeMap<String, bool>>,
301
302    /// Notes map: id → Note object.
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub notes: Option<serde_json::Value>,
305
306    /// Personal information map: id → PersonalInfo object.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub personal_info: Option<serde_json::Value>,
309
310    // ── draft-ietf-jmap-metadata-04 §2 ───────────────────────────────────
311    /// Shared metadata, keyed by namespace identifier
312    /// (draft-ietf-jmap-metadata-04 §2). Each value is a JSON object (§2.1).
313    /// `None` when absent, which -04 §2 treats as `{}`; in an update a
314    /// `null` value "is equivalent to {}" (-04 §3.2) and also decodes as
315    /// `None`.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
318
319    /// The authenticated user's own metadata, keyed by namespace
320    /// identifier (draft-ietf-jmap-metadata-04 §2). Same shape and `None`
321    /// handling as `metadata`.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub private_metadata: Option<serde_json::Map<String, serde_json::Value>>,
324
325    /// Catch-all for vendor / site / private extension fields not covered
326    /// by the typed fields above. Preserves unknown fields across
327    /// deserialize/serialize round-trip per workspace extras-preservation
328    /// policy (see workspace AGENTS.md).
329    ///
330    /// # Collision contract
331    ///
332    /// `extra` MUST NOT contain a key that matches one of the typed
333    /// wire-format field names (`id`, `addressBookIds`, `version`,
334    /// `created`, `kind`, `language`, `members`, `prodId`, `relatedTo`,
335    /// `uid`, `updated`, `name`, `nicknames`, `organizations`,
336    /// `speakToAs`, `titles`, `emails`, `onlineServices`, `phones`,
337    /// `preferredLanguages`, `calendars`, `schedulingAddresses`,
338    /// `addresses`, `cryptoKeys`, `directories`, `links`, `media`,
339    /// `localizations`, `anniversaries`, `keywords`, `notes`,
340    /// `personalInfo`, `metadata`, `privateMetadata`). On deserialize, `#[serde(flatten)]` consumes
341    /// matching keys into their typed fields first; truly unknown keys
342    /// land in `extra`. On serialize, a colliding key in `extra` is
343    /// emitted as a **duplicate JSON object key** alongside the typed
344    /// field. RFC 8259 §4 leaves duplicate-key handling
345    /// implementation-defined; JMAP servers MAY accept, reject, or
346    /// last-wins.
347    ///
348    /// In short: treat `extra` as a write-only catch-all for unknown
349    /// keys discovered at deserialize, and do not programmatically
350    /// insert keys that match a typed field. JMAP-glx8.19.
351    ///
352    /// See [`ContactCard::validate_extras`] for a runtime pre-serialize
353    /// check that detects this hazard. JMAP-glx8.25.
354    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
355    pub extra: serde_json::Map<String, serde_json::Value>,
356}
357
358impl ContactCard {
359    /// Check that no [`extra`](Self::extra) key shadows a typed
360    /// wire-format field of this struct. Returns
361    /// [`Err(CollisionError)`](CollisionError) listing any colliding
362    /// keys; otherwise returns `Ok(())`.
363    ///
364    /// Recommended pre-serialize hook for producers who construct
365    /// `ContactCard` values programmatically and need to guarantee
366    /// that the resulting JSON does not contain duplicate object keys.
367    /// See the [`extra`](Self::extra) collision contract for the
368    /// underlying hazard. JMAP-glx8.25.
369    ///
370    /// # Errors
371    ///
372    /// Returns [`CollisionError`] when one or more keys in
373    /// [`extra`](Self::extra) match one of the camelCase wire-format
374    /// names of this struct's typed fields.
375    pub fn validate_extras(&self) -> Result<(), CollisionError> {
376        collision::check(&self.extra, CONTACT_CARD_TYPED_FIELDS)
377    }
378}
379
380/// Filter condition for `ContactCard/query` (RFC 9610 §3.3.1).
381///
382/// All fields are optional; a condition with no fields set matches every
383/// ContactCard.
384///
385/// Note: several field names use forward-slash separators on the wire
386/// (e.g., `"name/given"`, `"name/surname"`, `"name/surname2"`).  These are
387/// encoded using explicit `#[serde(rename = "...")]` attributes.
388///
389/// # Field semantics
390///
391/// Two classes of filter field, with different matching contracts:
392///
393/// - **Exact-match fields** ([`in_address_book`](Self::in_address_book),
394///   [`uid`](Self::uid), [`has_member`](Self::has_member),
395///   [`kind`](Self::kind)) test value equality (string equality for the
396///   `String`-typed fields, [`Id`] equality for `in_address_book`).
397///   These are unambiguous and portable across servers.
398///
399/// - **Range fields** ([`created_before`](Self::created_before),
400///   [`created_after`](Self::created_after), and the `updated_*` siblings)
401///   test [`UTCDate`] ordering — `*_before` is strictly less than, `*_after`
402///   is greater than or equal to, per RFC 8620 §5.6.
403///
404/// - **Text-match fields** ([`text`](Self::text), [`name`](Self::name),
405///   [`name_given`](Self::name_given), [`name_surname`](Self::name_surname),
406///   [`name_surname2`](Self::name_surname2), [`nickname`](Self::nickname),
407///   [`organization`](Self::organization), [`email`](Self::email),
408///   [`phone`](Self::phone), [`online_service`](Self::online_service),
409///   [`address`](Self::address), [`note`](Self::note)) — **matching
410///   semantics are server-defined**. RFC 9610 §3.3.1 does NOT specify
411///   case-sensitivity, substring-vs-prefix-vs-exact, Unicode
412///   normalization (NFC/NFD), accent folding, or word-boundary handling.
413///   A caller writing `filter.name = Some("müller".into())` may get
414///   different result sets from different servers running identical
415///   queries. Portable callers SHOULD treat text-match fields as
416///   approximate full-text-search filters, not exact value tests.
417///   JMAP-glx8.17.
418///
419/// # Excluded from extras preservation
420///
421/// This type is **out of scope** for the workspace extras-preservation
422/// policy: it carries no flatten-extras `extra` field. Filter clauses the
423/// server does not understand are a query-correctness hazard — silently
424/// preserving an unrecognised clause and round-tripping it back to the
425/// client can return the wrong set of records with no error signal.
426///
427/// ## What to do instead
428///
429/// **IETF-track path.** Vendors who need both capability-level declaration
430/// and filterability for custom fields should use
431/// `draft-ietf-jmap-metadata` (capability URI
432/// `urn:ietf:params:jmap:metadata`), which adds namespace-keyed
433/// `metadata` / `privateMetadata` properties to each listed data type,
434/// filterable through `metadata*` conditions on that type's own `/query`
435/// (draft-ietf-jmap-metadata-04 §2, §3.6). This condition carries the six
436/// -04 §3.6 conditions as typed leaf fields (bd JMAP-s4kp.5). Helpers live
437/// in `jmap-metadata-types`, `jmap-metadata-server`, and
438/// `jmap-metadata-client`.
439///
440/// **Pre-IETF escape.** Vendors who cannot wait for the metadata draft can
441/// either escape the filter tree to `serde_json::Value` or fork the
442/// `FilterCondition` type. See `crate-jmap-calendars-types/PLAN.md` for
443/// the hybrid sloppy-value pattern.
444///
445/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
446#[non_exhaustive]
447#[derive(Debug, Clone, Default, Serialize, Deserialize)]
448#[serde(rename_all = "camelCase", deny_unknown_fields)]
449pub struct ContactCardFilterCondition {
450    /// Card must belong to this AddressBook.
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub in_address_book: Option<Id>,
453
454    /// Card must have exactly this uid.
455    #[serde(skip_serializing_if = "Option::is_none")]
456    pub uid: Option<String>,
457
458    /// Card must have a `members` property containing this uid.
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub has_member: Option<String>,
461
462    /// Card `kind` must equal this string exactly.
463    #[serde(skip_serializing_if = "Option::is_none")]
464    pub kind: Option<String>,
465
466    /// Card `created` must be strictly before this UTCDate
467    /// (`YYYY-MM-DDTHH:MM:SS[.SSS]Z`).
468    ///
469    /// [`UTCDate`] is a transparent `String` newtype with no parse
470    /// validation at construction; the server rejects syntactically
471    /// invalid values per RFC 8620 §5.6. JMAP-glx8.18.
472    #[serde(skip_serializing_if = "Option::is_none")]
473    pub created_before: Option<UTCDate>,
474
475    /// Card `created` must be equal to or after this UTCDate.
476    ///
477    /// See [`created_before`](Self::created_before) for the format and
478    /// validation contract.
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub created_after: Option<UTCDate>,
481
482    /// Card `updated` must be strictly before this UTCDate.
483    ///
484    /// See [`created_before`](Self::created_before) for the format and
485    /// validation contract.
486    #[serde(skip_serializing_if = "Option::is_none")]
487    pub updated_before: Option<UTCDate>,
488
489    /// Card `updated` must be equal to or after this UTCDate.
490    ///
491    /// See [`created_before`](Self::created_before) for the format and
492    /// validation contract.
493    #[serde(skip_serializing_if = "Option::is_none")]
494    pub updated_after: Option<UTCDate>,
495
496    /// Full-text search across all text in the card.
497    #[serde(skip_serializing_if = "Option::is_none")]
498    pub text: Option<String>,
499
500    /// Matches any NameComponent value or the `full` property in `name`.
501    #[serde(skip_serializing_if = "Option::is_none")]
502    pub name: Option<String>,
503
504    /// Matches NameComponents with `kind = "given"`.
505    /// Wire name: `name/given`.
506    #[serde(rename = "name/given", skip_serializing_if = "Option::is_none")]
507    pub name_given: Option<String>,
508
509    /// Matches NameComponents with `kind = "surname"`.
510    /// Wire name: `name/surname`.
511    #[serde(rename = "name/surname", skip_serializing_if = "Option::is_none")]
512    pub name_surname: Option<String>,
513
514    /// Matches NameComponents with `kind = "surname2"`.
515    /// Wire name: `name/surname2`.
516    #[serde(rename = "name/surname2", skip_serializing_if = "Option::is_none")]
517    pub name_surname2: Option<String>,
518
519    /// Matches any Nickname `name` in the `nicknames` property.
520    #[serde(skip_serializing_if = "Option::is_none")]
521    pub nickname: Option<String>,
522
523    /// Matches any Organization `name` in the `organizations` property.
524    #[serde(skip_serializing_if = "Option::is_none")]
525    pub organization: Option<String>,
526
527    /// Matches any EmailAddress `address` or `label` in the `emails` property.
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub email: Option<String>,
530
531    /// Matches any Phone `number` or `label` in the `phones` property.
532    #[serde(skip_serializing_if = "Option::is_none")]
533    pub phone: Option<String>,
534
535    /// Matches any OnlineService `service`, `uri`, `user`, or `label` in
536    /// `onlineServices`.
537    #[serde(skip_serializing_if = "Option::is_none")]
538    pub online_service: Option<String>,
539
540    /// Matches any AddressComponent value or the `full` property in `addresses`.
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub address: Option<String>,
543
544    /// Matches any Note `note` in the `notes` property.
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub note: Option<String>,
547
548    /// draft-ietf-jmap-metadata-04 §3.6 `metadataExists`: a value must be
549    /// present at this path in the object's `metadata`. The path is a
550    /// namespace identifier, optionally followed by an RFC 6901 JSON
551    /// Pointer into the namespace value.
552    #[serde(skip_serializing_if = "Option::is_none")]
553    pub metadata_exists: Option<String>,
554
555    /// -04 §3.6 `privateMetadataExists`: as `metadata_exists`, against the
556    /// authenticated user's `privateMetadata`.
557    #[serde(skip_serializing_if = "Option::is_none")]
558    pub private_metadata_exists: Option<String>,
559
560    /// -04 §3.6 `metadataTextContains`: substring match under the
561    /// `i;unicode-casemap` collation on the `metadata` string at a path.
562    #[serde(skip_serializing_if = "Option::is_none")]
563    pub metadata_text_contains: Option<MetadataTextMatch>,
564
565    /// -04 §3.6 `privateMetadataTextContains`: as
566    /// `metadata_text_contains`, against `privateMetadata`.
567    #[serde(skip_serializing_if = "Option::is_none")]
568    pub private_metadata_text_contains: Option<MetadataTextMatch>,
569
570    /// -04 §3.6 `metadataTextEquals`: exact code-point match on the
571    /// `metadata` string at a path.
572    #[serde(skip_serializing_if = "Option::is_none")]
573    pub metadata_text_equals: Option<MetadataTextMatch>,
574
575    /// -04 §3.6 `privateMetadataTextEquals`: as `metadata_text_equals`,
576    /// against `privateMetadata`.
577    #[serde(skip_serializing_if = "Option::is_none")]
578    pub private_metadata_text_equals: Option<MetadataTextMatch>,
579}
580
581impl ContactCardFilterCondition {
582    /// `true` if any of the six draft-ietf-jmap-metadata-04 §3.6
583    /// `metadata*` / `privateMetadata*` fields is set.
584    pub fn has_metadata_condition(&self) -> bool {
585        self.metadata_exists.is_some()
586            || self.private_metadata_exists.is_some()
587            || self.metadata_text_contains.is_some()
588            || self.private_metadata_text_contains.is_some()
589            || self.metadata_text_equals.is_some()
590            || self.private_metadata_text_equals.is_some()
591    }
592}
593
594/// Concrete filter type for ContactCard/query (RFC 9610 §3.3).
595///
596/// Alias for `jmap_types::Filter<ContactCardFilterCondition>`
597/// provided so callers do not have to reach into `jmap-types` directly.
598/// Mirrors the canonical [`jmap_mail_types::EmailFilter`] shape from the
599/// workspace canonical extension-types template.
600///
601/// [`jmap_mail_types::EmailFilter`]: https://docs.rs/jmap-mail-types/latest/jmap_mail_types/query/type.EmailFilter.html
602pub type ContactCardFilter = jmap_types::Filter<ContactCardFilterCondition>;
603
604/// The property to sort by in a [`ContactCardComparator`] (RFC 9610 §3.3.2).
605///
606/// RFC 9610 §3.3.2: `"created"` and `"updated"` "MUST be supported for
607/// sorting"; `"name/given"`, `"name/surname"` and `"name/surname2"`
608/// "SHOULD be supported for sorting".
609///
610/// Unknown property names are preserved in
611/// [`Other`](ContactCardComparatorProperty::Other) so they round-trip; a
612/// server cannot sort by one it does not know (RFC 8620 §5.5
613/// `unsupportedSort`). Cross-reference: bd JMAP-wf92.11.
614#[non_exhaustive]
615#[derive(Debug, Clone, PartialEq, Eq, Hash)]
616pub enum ContactCardComparatorProperty {
617    /// Sort by the card's `created` date.
618    Created,
619    /// Sort by the card's `updated` date.
620    Updated,
621    /// Sort by the first NameComponent in `name` whose `kind` is `given`.
622    NameGiven,
623    /// Sort by the first NameComponent in `name` whose `kind` is `surname`.
624    NameSurname,
625    /// Sort by the first NameComponent in `name` whose `kind` is `surname2`.
626    NameSurname2,
627    /// A property not listed above, kept so it round-trips; a server
628    /// answers it with `unsupportedSort` (RFC 8620 §5.5).
629    Other(String),
630}
631
632impl_string_enum!(ContactCardComparatorProperty, "a ContactCard sort property string",
633    "created"       => Created,
634    "updated"       => Updated,
635    "name/given"    => NameGiven,
636    "name/surname"  => NameSurname,
637    "name/surname2" => NameSurname2,
638);
639
640/// Comparator for `ContactCard/query` sort order (RFC 9610 §3.3.2).
641///
642/// Build one with [`ContactCardComparator::new`]; the struct is
643/// `#[non_exhaustive]`.
644///
645/// # Excluded from extras preservation
646///
647/// This type is **out of scope** for the workspace extras-preservation
648/// policy: it carries no flatten-extras `extra` field, and its `property`
649/// field is consumed by backend dispatch to determine sort order. See
650/// [`ContactCardFilterCondition`] for the rationale and for the two
651/// recommended paths (`draft-ietf-jmap-metadata`, bd JMAP-ua37; or the
652/// pre-IETF sloppy-value escape).
653///
654/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
655#[non_exhaustive]
656#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
657#[serde(rename_all = "camelCase")]
658pub struct ContactCardComparator {
659    /// The property to sort by.
660    pub property: ContactCardComparatorProperty,
661    /// Sort direction; `true` = ascending (wire-default per RFC 8620
662    /// §5.5), `false` = descending.
663    #[serde(default = "bool_true", skip_serializing_if = "is_true")]
664    pub is_ascending: bool,
665    /// Optional collation identifier (RFC 4790).
666    #[serde(skip_serializing_if = "Option::is_none")]
667    pub collation: Option<String>,
668}
669
670impl ContactCardComparator {
671    /// An ascending comparator on `property` with no collation
672    /// (`isAscending` defaults to `true`, RFC 8620 §5.5).
673    pub fn new(property: ContactCardComparatorProperty) -> Self {
674        Self {
675            property,
676            is_ascending: true,
677            collation: None,
678        }
679    }
680}
681
682fn bool_true() -> bool {
683    true
684}
685
686fn is_true(b: &bool) -> bool {
687    *b
688}