jmap-contacts-types 0.4.1

JMAP Contacts extension data types (RFC 9610)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
//! RFC 9610 §3 — ContactCard object and filter types.
//!
//! A ContactCard is a JMAP wrapper around a JSContact Card (RFC 9553 §2),
//! with two JMAP-specific additions: `id` and `addressBookIds`.
//!
//! Complex JSContact sub-objects (addresses, phones, emails, etc.) are
//! represented as [`serde_json::Value`] because their nested structure is
//! large and their schema is still evolving.  Callers that need typed access
//! to sub-objects should deserialize the relevant `Value` field directly.

use std::collections::BTreeMap;

use jmap_metadata_types::MetadataTextMatch;
use jmap_types::{Id, PatchObject, UTCDate, impl_string_enum};
use serde::{Deserialize, Serialize};

use crate::collision::{self, CollisionError};

/// Camel-case wire-format names of every typed field on [`ContactCard`].
///
/// Used by [`ContactCard::validate_extras`] to detect vendor-extras
/// key collisions before serialize. Order is not significant; the
/// helper sorts collisions alphabetically. JMAP-glx8.25.
const CONTACT_CARD_TYPED_FIELDS: &[&str] = &[
    "id",
    "addressBookIds",
    "version",
    "created",
    "kind",
    "language",
    "members",
    "prodId",
    "relatedTo",
    "uid",
    "updated",
    "name",
    "nicknames",
    "organizations",
    "speakToAs",
    "titles",
    "emails",
    "onlineServices",
    "phones",
    "preferredLanguages",
    "calendars",
    "schedulingAddresses",
    "addresses",
    "cryptoKeys",
    "directories",
    "links",
    "media",
    "localizations",
    "anniversaries",
    "keywords",
    "notes",
    "personalInfo",
    "metadata",
    "privateMetadata",
];

/// A JMAP ContactCard object (RFC 9610 §3).
///
/// Wraps the JSContact Card format (RFC 9553 §2) with two JMAP-specific
/// additions: [`id`](ContactCard::id) and
/// [`address_book_ids`](ContactCard::address_book_ids).
///
/// All fields use `Option` because:
/// - RFC 9553 makes most fields optional.
/// - JMAP `properties` argument allows partial responses; absent fields must
///   not fail deserialization.
///
/// Complex sub-object maps (addresses, phones, emails, etc.) use
/// [`serde_json::Value`] to avoid coupling this crate to every nested
/// JSContact type.
///
/// # Construction
///
/// [`ContactCard::default()`] returns a card with every field unset,
/// useful for incremental construction (e.g. building a `ContactCard/set
/// create` argument by patching the wanted fields). It is **not** a valid
/// wire payload on its own: RFC 9610 §3 requires `id` (server-set) and
/// `addressBookIds` on full-object responses, and RFC 9553 §2.1 requires
/// `@type` (implicit) plus `version` and `uid` on creation. The handler
/// layer (`jmap-contacts-server`) is responsible for rejecting partial
/// cards on `/set` create; servers building `/get` responses MUST fill in
/// `id` and `addressBookIds` before serializing. JMAP-glx8.16.
///
/// # Size and depth
///
/// Consumers of `ContactCard` SHOULD impose a request-body size limit
/// **at the HTTP layer** before deserializing. The 22 Sloppy-Value
/// fields (see the inline note above [`name`](Self::name)) accept
/// arbitrarily-large JSON blobs: a 50 MiB JSON body deserializes into
/// a `~150 MiB` `serde_json::Value` tree.
///
/// `serde_json`'s default 128-level recursion limit protects against
/// stack overflow from deeply-nested input (see the negative test
/// `deeply_nested_sloppy_field_beyond_serde_json_limit_errors`) but
/// does **not** bound heap memory or parser CPU. A `{"name":{"x":{"x":
/// {...127 levels of single-key nesting...}}}}` payload is small on
/// the wire but produces a deep `Value` tree; the same wire bytes
/// re-shaped as a wide-array (`{"name":{"a":[{},{},...]}}`) consumes
/// proportional memory.
///
/// This crate ships **no body-size constant**: the limit is consumer
/// policy, not type-crate policy, consistent with the workspace's
/// transport-less posture (see workspace AGENTS.md "What this
/// workspace builds"). The reference handler convention is a 10 MiB
/// body cap; production consumers MAY raise or lower it. JMAP-glx8.24.
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContactCard {
    // ── JMAP additions (RFC 9610 §3) ─────────────────────────────────
    /// Server-assigned immutable identifier (JMAP addition).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<Id>,

    /// Set of AddressBook ids this card belongs to (JMAP addition; wire
    /// name `addressBookIds`).
    ///
    /// Represented as `BTreeMap<Id, bool>` because the JMAP wire format
    /// uses a JSON object with boolean values (RFC 9610 §3). Values are
    /// always `true` in full-object responses; per RFC 9610 §3, "each
    /// value MUST be true" — the type does not enforce that constraint,
    /// so callers (and `jmap-contacts-server`) MUST reject `false`
    /// values with `invalidProperties` per RFC 8620 §5.3. The map shape
    /// is also used in PatchObject updates (RFC 8620 §5.3) where a
    /// `null` value removes an entry.
    ///
    /// This shape matches the canonical `Email.mailbox_ids` precedent
    /// in `jmap-mail-types`. JMAP-glx8.11.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub address_book_ids: Option<BTreeMap<Id, bool>>,

    // ── RFC 9553 §2.1 Metadata ───────────────────────────────────────────
    /// JSContact version; MUST be `"1.0"` when present.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,

    /// Date and time when the Card was created (RFC 8620 UTCDate format:
    /// `YYYY-MM-DDTHH:MM:SS[.SSS]Z`, exactly the `Z` UTC suffix).
    ///
    /// [`UTCDate`] is a transparent `String` newtype in `jmap-types`; it
    /// performs **no parse validation** at construction. Callers building a
    /// `ContactCard/set` argument MUST supply a syntactically valid
    /// UTCDate; the handler layer rejects malformed values with
    /// `invalidProperties`. JMAP-glx8.18.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub created: Option<UTCDate>,

    /// Kind of entity: `"individual"`, `"group"`, `"org"`, `"location"`,
    /// `"device"`, `"application"`, or a vendor-specific value.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub kind: Option<String>,

    /// BCP 47 language tag for the primary language of the Card.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<String>,

    /// For group cards (`kind = "group"`): set of member UIDs → `true`
    /// (RFC 9553 §2.1.6).
    ///
    /// Values are always `true`; per RFC 9553 §2.1.6, "each value MUST
    /// be true" — the type does not enforce that constraint, so callers
    /// (and `jmap-contacts-server`) MUST reject `false` values with
    /// `invalidProperties` per RFC 8620 §5.3. The map shape is also
    /// used in PatchObject updates (RFC 8620 §5.3) where a `null` value
    /// removes an entry. JMAP-glx8.11.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub members: Option<BTreeMap<String, bool>>,

    /// Identifier for the product that created the Card.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prod_id: Option<String>,

    /// Cards related to this one.  Map of uid → Relation object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub related_to: Option<serde_json::Value>,

    /// Globally unique identifier for this Card (often a UUID URN).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub uid: Option<String>,

    /// Date and time when the Card was last modified (UTCDate; server-set).
    ///
    /// See [`created`](Self::created) for the format and validation
    /// contract. JMAP-glx8.18.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub updated: Option<UTCDate>,

    // ── RFC 9553 §2.2 Name and Organization ─────────────────────────────
    //
    // The 22 sub-object fields below are `Option<serde_json::Value>` per
    // the workspace Sloppy-Value pattern (see workspace AGENTS.md). RFC
    // 9553 §2.2-§2.8 requires that each value be a **JSON object** of
    // the relevant sub-type shape, but the wire type accepts any JSON
    // (string, number, array, bool, null, object) and round-trips it
    // unchanged. A caller submitting a non-object value produces a
    // wire-format violation; the handler layer (`jmap-contacts-server`)
    // is responsible for rejecting non-object values with
    // `invalidProperties` per RFC 8620 §5.3. Typed access from
    // `jmap-jscontact-types` (re-exported at this crate's root) MUST
    // wrap `serde_json::from_value` in a `Result` to handle non-object
    // wire shapes gracefully. JMAP-glx8.13.
    /// The name of the entity.  JSContact Name object (complex sub-object).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<serde_json::Value>,

    /// Nicknames map: id → Nickname object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub nicknames: Option<serde_json::Value>,

    /// Organizations map: id → Organization object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub organizations: Option<serde_json::Value>,

    /// How to address the entity in spoken or written language.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub speak_to_as: Option<serde_json::Value>,

    /// Titles map: id → Title object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub titles: Option<serde_json::Value>,

    // ── RFC 9553 §2.3 Contact ────────────────────────────────────────────
    /// Email addresses map: id → EmailAddress object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub emails: Option<serde_json::Value>,

    /// Online services map: id → OnlineService object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub online_services: Option<serde_json::Value>,

    /// Phone numbers map: id → Phone object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub phones: Option<serde_json::Value>,

    /// Preferred languages map: id → LanguagePref object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub preferred_languages: Option<serde_json::Value>,

    // ── RFC 9553 §2.4 Calendaring ────────────────────────────────────────
    /// Calendars map: id → Calendar object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub calendars: Option<serde_json::Value>,

    /// Scheduling addresses map: id → SchedulingAddress object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub scheduling_addresses: Option<serde_json::Value>,

    // ── RFC 9553 §2.5 Address ────────────────────────────────────────────
    /// Postal addresses map: id → Address object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub addresses: Option<serde_json::Value>,

    // ── RFC 9553 §2.6 Resources ──────────────────────────────────────────
    /// Cryptographic keys map: id → CryptoKey object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub crypto_keys: Option<serde_json::Value>,

    /// Directories map: id → Directory object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub directories: Option<serde_json::Value>,

    /// Links map: id → Link object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub links: Option<serde_json::Value>,

    /// Media map: id → Media object (may include JMAP `blobId` extension).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub media: Option<serde_json::Value>,

    // ── RFC 9553 §2.7 Multilingual ───────────────────────────────────────
    /// Localization patches map: BCP 47 language-tag →
    /// [`PatchObject`].
    ///
    /// Wire format is byte-identical to a plain JSON object via
    /// `#[serde(transparent)]` on `PatchObject`. The typed shape enforces the
    /// outer object-map structure at the type system level; inner patch
    /// values remain opaque JSON (per RFC 8620 §5.3).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub localizations: Option<BTreeMap<String, PatchObject>>,

    // ── RFC 9553 §2.8 Additional ─────────────────────────────────────────
    /// Anniversaries map: id → Anniversary object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub anniversaries: Option<serde_json::Value>,

    /// Keywords set: keyword → `true` (RFC 9553 §2.8.2 `String[Boolean]`).
    ///
    /// Typed because the wire shape is closed by spec and matches the
    /// sibling `members` and `address_book_ids` fields. JMAP-glx8.3.
    /// Values are always `true`; the type does not enforce that
    /// constraint, so callers MUST reject `false` values per RFC 9553
    /// §2.8.2. The map shape is also used in PatchObject updates
    /// (RFC 8620 §5.3) where a `null` value removes an entry.
    /// JMAP-glx8.11.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub keywords: Option<BTreeMap<String, bool>>,

    /// Notes map: id → Note object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub notes: Option<serde_json::Value>,

    /// Personal information map: id → PersonalInfo object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub personal_info: Option<serde_json::Value>,

    // ── draft-ietf-jmap-metadata-04 §2 ───────────────────────────────────
    /// Shared metadata, keyed by namespace identifier
    /// (draft-ietf-jmap-metadata-04 §2). Each value is a JSON object (§2.1).
    /// `None` when absent, which -04 §2 treats as `{}`; in an update a
    /// `null` value "is equivalent to {}" (-04 §3.2) and also decodes as
    /// `None`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub metadata: Option<serde_json::Map<String, serde_json::Value>>,

    /// The authenticated user's own metadata, keyed by namespace
    /// identifier (draft-ietf-jmap-metadata-04 §2). Same shape and `None`
    /// handling as `metadata`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub private_metadata: Option<serde_json::Map<String, serde_json::Value>>,

    /// 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).
    ///
    /// # Collision contract
    ///
    /// `extra` MUST NOT contain a key that matches one of the typed
    /// wire-format field names (`id`, `addressBookIds`, `version`,
    /// `created`, `kind`, `language`, `members`, `prodId`, `relatedTo`,
    /// `uid`, `updated`, `name`, `nicknames`, `organizations`,
    /// `speakToAs`, `titles`, `emails`, `onlineServices`, `phones`,
    /// `preferredLanguages`, `calendars`, `schedulingAddresses`,
    /// `addresses`, `cryptoKeys`, `directories`, `links`, `media`,
    /// `localizations`, `anniversaries`, `keywords`, `notes`,
    /// `personalInfo`, `metadata`, `privateMetadata`). On deserialize, `#[serde(flatten)]` consumes
    /// matching keys into their typed fields first; truly unknown keys
    /// land in `extra`. On serialize, a colliding key in `extra` is
    /// emitted as a **duplicate JSON object key** alongside the typed
    /// field. RFC 8259 §4 leaves duplicate-key handling
    /// implementation-defined; JMAP servers MAY accept, reject, or
    /// last-wins.
    ///
    /// In short: treat `extra` as a write-only catch-all for unknown
    /// keys discovered at deserialize, and do not programmatically
    /// insert keys that match a typed field. JMAP-glx8.19.
    ///
    /// See [`ContactCard::validate_extras`] for a runtime pre-serialize
    /// check that detects this hazard. JMAP-glx8.25.
    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
    pub extra: serde_json::Map<String, serde_json::Value>,
}

impl ContactCard {
    /// Check that no [`extra`](Self::extra) key shadows a typed
    /// wire-format field of this struct. Returns
    /// [`Err(CollisionError)`](CollisionError) listing any colliding
    /// keys; otherwise returns `Ok(())`.
    ///
    /// Recommended pre-serialize hook for producers who construct
    /// `ContactCard` values programmatically and need to guarantee
    /// that the resulting JSON does not contain duplicate object keys.
    /// See the [`extra`](Self::extra) collision contract for the
    /// underlying hazard. JMAP-glx8.25.
    ///
    /// # Errors
    ///
    /// Returns [`CollisionError`] when one or more keys in
    /// [`extra`](Self::extra) match one of the camelCase wire-format
    /// names of this struct's typed fields.
    pub fn validate_extras(&self) -> Result<(), CollisionError> {
        collision::check(&self.extra, CONTACT_CARD_TYPED_FIELDS)
    }
}

/// Filter condition for `ContactCard/query` (RFC 9610 §3.3.1).
///
/// All fields are optional; a condition with no fields set matches every
/// ContactCard.
///
/// Note: several field names use forward-slash separators on the wire
/// (e.g., `"name/given"`, `"name/surname"`, `"name/surname2"`).  These are
/// encoded using explicit `#[serde(rename = "...")]` attributes.
///
/// # Field semantics
///
/// Two classes of filter field, with different matching contracts:
///
/// - **Exact-match fields** ([`in_address_book`](Self::in_address_book),
///   [`uid`](Self::uid), [`has_member`](Self::has_member),
///   [`kind`](Self::kind)) test value equality (string equality for the
///   `String`-typed fields, [`Id`] equality for `in_address_book`).
///   These are unambiguous and portable across servers.
///
/// - **Range fields** ([`created_before`](Self::created_before),
///   [`created_after`](Self::created_after), and the `updated_*` siblings)
///   test [`UTCDate`] ordering — `*_before` is strictly less than, `*_after`
///   is greater than or equal to, per RFC 8620 §5.6.
///
/// - **Text-match fields** ([`text`](Self::text), [`name`](Self::name),
///   [`name_given`](Self::name_given), [`name_surname`](Self::name_surname),
///   [`name_surname2`](Self::name_surname2), [`nickname`](Self::nickname),
///   [`organization`](Self::organization), [`email`](Self::email),
///   [`phone`](Self::phone), [`online_service`](Self::online_service),
///   [`address`](Self::address), [`note`](Self::note)) — **matching
///   semantics are server-defined**. RFC 9610 §3.3.1 does NOT specify
///   case-sensitivity, substring-vs-prefix-vs-exact, Unicode
///   normalization (NFC/NFD), accent folding, or word-boundary handling.
///   A caller writing `filter.name = Some("müller".into())` may get
///   different result sets from different servers running identical
///   queries. Portable callers SHOULD treat text-match fields as
///   approximate full-text-search filters, not exact value tests.
///   JMAP-glx8.17.
///
/// # Excluded from extras preservation
///
/// This type is **out of scope** for the workspace extras-preservation
/// policy: it carries no flatten-extras `extra` field. Filter clauses the
/// server does not understand are a query-correctness hazard — silently
/// preserving an unrecognised clause and round-tripping it back to the
/// client can return the wrong set of records with no error signal.
///
/// ## What to do instead
///
/// **IETF-track path.** Vendors who need both capability-level declaration
/// and filterability for custom fields should use
/// `draft-ietf-jmap-metadata` (capability URI
/// `urn:ietf:params:jmap:metadata`), which adds namespace-keyed
/// `metadata` / `privateMetadata` properties to each listed data type,
/// filterable through `metadata*` conditions on that type's own `/query`
/// (draft-ietf-jmap-metadata-04 §2, §3.6). This condition carries the six
/// -04 §3.6 conditions as typed leaf fields (bd JMAP-s4kp.5). Helpers live
/// in `jmap-metadata-types`, `jmap-metadata-server`, and
/// `jmap-metadata-client`.
///
/// **Pre-IETF escape.** Vendors who cannot wait for the metadata draft can
/// either escape the filter tree to `serde_json::Value` or fork the
/// `FilterCondition` type. See `crate-jmap-calendars-types/PLAN.md` for
/// the hybrid sloppy-value pattern.
///
/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
#[non_exhaustive]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ContactCardFilterCondition {
    /// Card must belong to this AddressBook.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub in_address_book: Option<Id>,

    /// Card must have exactly this uid.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub uid: Option<String>,

    /// Card must have a `members` property containing this uid.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub has_member: Option<String>,

    /// Card `kind` must equal this string exactly.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub kind: Option<String>,

    /// Card `created` must be strictly before this UTCDate
    /// (`YYYY-MM-DDTHH:MM:SS[.SSS]Z`).
    ///
    /// [`UTCDate`] is a transparent `String` newtype with no parse
    /// validation at construction; the server rejects syntactically
    /// invalid values per RFC 8620 §5.6. JMAP-glx8.18.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub created_before: Option<UTCDate>,

    /// Card `created` must be equal to or after this UTCDate.
    ///
    /// See [`created_before`](Self::created_before) for the format and
    /// validation contract.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub created_after: Option<UTCDate>,

    /// Card `updated` must be strictly before this UTCDate.
    ///
    /// See [`created_before`](Self::created_before) for the format and
    /// validation contract.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub updated_before: Option<UTCDate>,

    /// Card `updated` must be equal to or after this UTCDate.
    ///
    /// See [`created_before`](Self::created_before) for the format and
    /// validation contract.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub updated_after: Option<UTCDate>,

    /// Full-text search across all text in the card.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub text: Option<String>,

    /// Matches any NameComponent value or the `full` property in `name`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,

    /// Matches NameComponents with `kind = "given"`.
    /// Wire name: `name/given`.
    #[serde(rename = "name/given", skip_serializing_if = "Option::is_none")]
    pub name_given: Option<String>,

    /// Matches NameComponents with `kind = "surname"`.
    /// Wire name: `name/surname`.
    #[serde(rename = "name/surname", skip_serializing_if = "Option::is_none")]
    pub name_surname: Option<String>,

    /// Matches NameComponents with `kind = "surname2"`.
    /// Wire name: `name/surname2`.
    #[serde(rename = "name/surname2", skip_serializing_if = "Option::is_none")]
    pub name_surname2: Option<String>,

    /// Matches any Nickname `name` in the `nicknames` property.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub nickname: Option<String>,

    /// Matches any Organization `name` in the `organizations` property.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub organization: Option<String>,

    /// Matches any EmailAddress `address` or `label` in the `emails` property.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub email: Option<String>,

    /// Matches any Phone `number` or `label` in the `phones` property.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub phone: Option<String>,

    /// Matches any OnlineService `service`, `uri`, `user`, or `label` in
    /// `onlineServices`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub online_service: Option<String>,

    /// Matches any AddressComponent value or the `full` property in `addresses`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub address: Option<String>,

    /// Matches any Note `note` in the `notes` property.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub note: Option<String>,

    /// draft-ietf-jmap-metadata-04 §3.6 `metadataExists`: a value must be
    /// present at this path in the object's `metadata`. The path is a
    /// namespace identifier, optionally followed by an RFC 6901 JSON
    /// Pointer into the namespace value.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata_exists: Option<String>,

    /// -04 §3.6 `privateMetadataExists`: as `metadata_exists`, against the
    /// authenticated user's `privateMetadata`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub private_metadata_exists: Option<String>,

    /// -04 §3.6 `metadataTextContains`: substring match under the
    /// `i;unicode-casemap` collation on the `metadata` string at a path.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata_text_contains: Option<MetadataTextMatch>,

    /// -04 §3.6 `privateMetadataTextContains`: as
    /// `metadata_text_contains`, against `privateMetadata`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub private_metadata_text_contains: Option<MetadataTextMatch>,

    /// -04 §3.6 `metadataTextEquals`: exact code-point match on the
    /// `metadata` string at a path.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata_text_equals: Option<MetadataTextMatch>,

    /// -04 §3.6 `privateMetadataTextEquals`: as `metadata_text_equals`,
    /// against `privateMetadata`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub private_metadata_text_equals: Option<MetadataTextMatch>,
}

impl ContactCardFilterCondition {
    /// `true` if any of the six draft-ietf-jmap-metadata-04 §3.6
    /// `metadata*` / `privateMetadata*` fields is set.
    pub fn has_metadata_condition(&self) -> bool {
        self.metadata_exists.is_some()
            || self.private_metadata_exists.is_some()
            || self.metadata_text_contains.is_some()
            || self.private_metadata_text_contains.is_some()
            || self.metadata_text_equals.is_some()
            || self.private_metadata_text_equals.is_some()
    }
}

/// Concrete filter type for ContactCard/query (RFC 9610 §3.3).
///
/// Alias for `jmap_types::Filter<ContactCardFilterCondition>`
/// provided so callers do not have to reach into `jmap-types` directly.
/// Mirrors the canonical [`jmap_mail_types::EmailFilter`] shape from the
/// workspace canonical extension-types template.
///
/// [`jmap_mail_types::EmailFilter`]: https://docs.rs/jmap-mail-types/latest/jmap_mail_types/query/type.EmailFilter.html
pub type ContactCardFilter = jmap_types::Filter<ContactCardFilterCondition>;

/// The property to sort by in a [`ContactCardComparator`] (RFC 9610 §3.3.2).
///
/// RFC 9610 §3.3.2: `"created"` and `"updated"` "MUST be supported for
/// sorting"; `"name/given"`, `"name/surname"` and `"name/surname2"`
/// "SHOULD be supported for sorting".
///
/// Unknown property names are preserved in
/// [`Other`](ContactCardComparatorProperty::Other) so they round-trip; a
/// server cannot sort by one it does not know (RFC 8620 §5.5
/// `unsupportedSort`). Cross-reference: bd JMAP-wf92.11.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ContactCardComparatorProperty {
    /// Sort by the card's `created` date.
    Created,
    /// Sort by the card's `updated` date.
    Updated,
    /// Sort by the first NameComponent in `name` whose `kind` is `given`.
    NameGiven,
    /// Sort by the first NameComponent in `name` whose `kind` is `surname`.
    NameSurname,
    /// Sort by the first NameComponent in `name` whose `kind` is `surname2`.
    NameSurname2,
    /// A property not listed above, kept so it round-trips; a server
    /// answers it with `unsupportedSort` (RFC 8620 §5.5).
    Other(String),
}

impl_string_enum!(ContactCardComparatorProperty, "a ContactCard sort property string",
    "created"       => Created,
    "updated"       => Updated,
    "name/given"    => NameGiven,
    "name/surname"  => NameSurname,
    "name/surname2" => NameSurname2,
);

/// Comparator for `ContactCard/query` sort order (RFC 9610 §3.3.2).
///
/// Build one with [`ContactCardComparator::new`]; the struct is
/// `#[non_exhaustive]`.
///
/// # Excluded from extras preservation
///
/// This type is **out of scope** for the workspace extras-preservation
/// policy: it carries no flatten-extras `extra` field, and its `property`
/// field is consumed by backend dispatch to determine sort order. See
/// [`ContactCardFilterCondition`] for the rationale and for the two
/// recommended paths (`draft-ietf-jmap-metadata`, bd JMAP-ua37; or the
/// pre-IETF sloppy-value escape).
///
/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContactCardComparator {
    /// The property to sort by.
    pub property: ContactCardComparatorProperty,
    /// Sort direction; `true` = ascending (wire-default per RFC 8620
    /// §5.5), `false` = descending.
    #[serde(default = "bool_true", skip_serializing_if = "is_true")]
    pub is_ascending: bool,
    /// Optional collation identifier (RFC 4790).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub collation: Option<String>,
}

impl ContactCardComparator {
    /// An ascending comparator on `property` with no collation
    /// (`isAscending` defaults to `true`, RFC 8620 §5.5).
    pub fn new(property: ContactCardComparatorProperty) -> Self {
        Self {
            property,
            is_ascending: true,
            collation: None,
        }
    }
}

fn bool_true() -> bool {
    true
}

fn is_true(b: &bool) -> bool {
    *b
}