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}