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