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}