jmap-calendars-types 0.2.0

JMAP Calendars extension data types (draft-ietf-jmap-calendars-*)
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
//! CalendarEvent object and CalendarEventFilterCondition.
//!
//! Normative references:
//!   - draft-ietf-jmap-calendars-26 §5 — JMAP-specific additions
//!   - RFC 8984 §2.1, §4, §5.1 — JSCalendar Event properties

use std::collections::HashMap;

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

/// A JMAP CalendarEvent object (draft-ietf-jmap-calendars-26 §5; RFC 8984 §5.1).
///
/// `CalendarEvent` is a JSCalendar Event with additional JMAP-specific properties.
/// Every field is `Option<T>` because RFC 8620 §5.1 allows partial responses
/// (clients may request only specific fields via the `properties` argument).
/// A field absent from the server response MUST NOT fail deserialization.
///
/// ## Complex sub-objects
///
/// JSCalendar sub-objects with their own rich structure (`locations`,
/// `participants`, `alerts`, `recurrenceRules`, etc.) are represented as
/// `Option<serde_json::Value>`.  This avoids an exhaustive typed representation
/// while preserving full round-trip fidelity.  Consumers that need to
/// manipulate these fields should parse the `Value` using the concrete types
/// re-exported at this crate's root (e.g. [`crate::Location`], [`crate::Participant`],
/// [`crate::Alert`]) — defined in the `jmap-jscalendar-types` crate.
///
/// ## PatchObject envelopes
///
/// `recurrenceOverrides` and `localizations` are typed as
/// `Option<HashMap<String, jmap_types::PatchObject>>`: the outer envelope
/// is JMAP-level (RFC 8620 §5.3 PatchObject) while the inner leaves stay
/// `serde_json::Value` to preserve per-leaf JSCalendar flexibility.  Wire
/// format is byte-identical to the prior opaque `Option<Value>` shape via
/// `#[serde(transparent)]` on `PatchObject`.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CalendarEvent {
    // ── JMAP-specific properties (draft §5) ──────────────────────────────────
    /// Server-assigned immutable identifier.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub id: Option<Id>,

    /// `Id` of the base recurring event when `id` is a synthetic expanded
    /// instance id.  `null` for non-synthetic events.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub base_event_id: Option<Id>,

    /// Set of Calendar ids this event belongs to (values always `true`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub calendar_ids: Option<HashMap<Id, bool>>,

    /// If `true`, this is a draft; the server will not send scheduling messages.
    /// Always serialized; absent deserializes, and `None` serializes, as the
    /// spec default `false`.
    #[serde(default = "some_false", serialize_with = "serialize_false_if_none")]
    pub is_draft: Option<bool>,

    /// Server-set; `true` if this account is the authoritative source.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_origin: Option<bool>,

    /// Computed UTC start time (not returned by default; must be requested).
    ///
    /// Uses the [`UTCDate`] newtype to make the wire-format constraint
    /// (RFC 8620 §1.4: 20-character UTCDateTime string) explicit at the
    /// type level. JSON wire format is unchanged because `UTCDate` is a
    /// transparent newtype around `String`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub utc_start: Option<UTCDate>,

    /// Computed UTC end time (not returned by default; must be requested).
    ///
    /// See [`utc_start`](Self::utc_start) for the typing rationale.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub utc_end: Option<UTCDate>,

    /// If `true`, use per-calendar default alerts instead of `alerts` (default `false`).
    /// Always serialized; absent deserializes, and `None` serializes, as the
    /// spec default `false`.
    #[serde(default = "some_false", serialize_with = "serialize_false_if_none")]
    pub use_default_alerts: Option<bool>,

    /// If `true`, any user may add themselves as an attendee (draft §5.1.1).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub may_invite_self: Option<bool>,

    /// If `true`, existing attendees may add new attendees (draft §5.1.2).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub may_invite_others: Option<bool>,

    /// If `true`, non-owners see only owners and themselves in participants
    /// (draft §5.1.3).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hide_attendees: Option<bool>,

    /// JMAP Blob id for the iCalendar representation of this event.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub blob_id: Option<Id>,

    /// Raw iCalendar (RFC 5545) data for this event, base64-encoded.
    ///
    /// Only returned when explicitly requested via `properties:["iCalComponent"]`
    /// (draft-ietf-jmap-calendars-26 §5.7).  Never included by default.
    ///
    /// The value is the base64url-encoded VEVENT iCalendar component, suitable
    /// for interoperability with systems that do not support the native
    /// CalendarEvent representation.
    ///
    /// Wire name is `"iCalComponent"` — not `"icalComponent"` — because "iCal"
    /// is a brand abbreviation with mixed case.  Manual rename required since
    /// `rename_all = "camelCase"` would produce `"icalComponent"`.
    ///
    /// **Brand-mismatch hazard (interop pitfall).** A peer that follows naive
    /// camelCase rules instead of the spec spelling will emit
    /// `"icalComponent"` (lowercase `i`). That key does NOT deserialize into
    /// this typed field — it falls through into [`CalendarEvent::extra`] and
    /// round-trips back out under the same lowercase spelling. Application
    /// code that reads `.ical_component` will see `None` and conclude no
    /// iCalendar data was provided, even though the bytes are present in
    /// `extra["icalComponent"]`. Pinned in regression test
    /// `calendar_event_naive_icalcomponent_lands_in_extras_not_typed_field`.
    /// Consumers that interoperate with non-conformant peers should either
    /// check `extra` for the lowercase key explicitly, or normalize the
    /// payload upstream.
    #[serde(rename = "iCalComponent", skip_serializing_if = "Option::is_none")]
    pub ical_component: Option<String>,

    // ── JSCalendar @type ─────────────────────────────────────────────────────
    /// JSCalendar object type; always `"Event"` when present.
    /// Included for round-trip fidelity; omitted on create.
    #[serde(rename = "@type", skip_serializing_if = "Option::is_none")]
    pub at_type: Option<String>,

    // ── JSCalendar metadata properties (RFC 8984 §4.1) ───────────────────────
    /// Globally unique identifier for this event.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub uid: Option<String>,

    /// Map of related event UIDs → `Relation` objects.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub related_to: Option<serde_json::Value>,

    /// Product identifier (who created the event data).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prod_id: Option<String>,

    /// UTC date-time when this event was created.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub created: Option<UTCDate>,

    /// UTC date-time of last modification (mandatory in full responses).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub updated: Option<UTCDate>,

    /// iCalendar sequence number (default `0`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sequence: Option<u64>,

    // ── JSCalendar what/where properties (RFC 8984 §4.2) ─────────────────────
    /// Human-readable summary/title.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,

    /// Longer-form description.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// Content type of `description` (default `"text/plain"`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description_content_type: Option<String>,

    /// If `true`, this is an all-day event (default `false`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub show_without_time: Option<bool>,

    /// Map of location id → `Location` object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub locations: Option<serde_json::Value>,

    /// Map of virtual location id → `VirtualLocation` object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub virtual_locations: Option<serde_json::Value>,

    /// Map of link id → `Link` object (attachments, images, etc.).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub links: Option<serde_json::Value>,

    /// BCP 47 language tag for this event's locale.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub locale: Option<String>,

    /// Map of keyword strings → `true`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub keywords: Option<serde_json::Value>,

    /// Map of category URI strings → `true`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub categories: Option<serde_json::Value>,

    /// CSS color name or `#rrggbb` for this event.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub color: Option<String>,

    // ── JSCalendar recurrence properties (RFC 8984 §4.3) ─────────────────────
    /// `LocalDateTime` string identifying which recurrence instance this
    /// object overrides.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub recurrence_id: Option<String>,

    /// Time zone for `recurrence_id` (required when `recurrence_id` is set).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub recurrence_id_time_zone: Option<String>,

    /// Array of `RecurrenceRule` objects.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub recurrence_rules: Option<serde_json::Value>,

    /// Array of `RecurrenceRule` objects to exclude from the series.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub excluded_recurrence_rules: Option<serde_json::Value>,

    /// Map of `LocalDateTime` string → [`PatchObject`] for per-occurrence
    /// overrides (RFC 8984 §4.3.2).
    ///
    /// Outer envelope is typed at the JMAP level: keys are LocalDateTime
    /// strings and values are JMAP `PatchObject` (RFC 8620 §5.3) wire
    /// objects.  The inner `PatchObject` leaves remain `serde_json::Value`
    /// so per-occurrence overrides retain full JSCalendar shape flexibility
    /// (Sloppy-Value at the leaf, typed at the envelope).  Wire format is
    /// byte-identical via `#[serde(transparent)]`.
    ///
    /// **Maintainer note: do NOT "simplify" this shape.** Two refactors
    /// commonly surface; both are wrong:
    ///
    /// 1. **Revert to `Option<serde_json::Value>`.** Loses the type-level
    ///    signal that the outer envelope follows JMAP `PatchObject`
    ///    (RFC 8620 §5.3) semantics. JMAP and JSCalendar are
    ///    co-evolving specs with separate ownership of the envelope vs
    ///    leaf semantics, and the type-level split mirrors that.
    /// 2. **Type the inner leaves with full JSCalendar field-by-field
    ///    structs.** Would require an exhaustive JSCalendar leaf type
    ///    that must track both specs and break partial deserialize. The
    ///    workspace Sloppy-Value policy (workspace AGENTS.md) is
    ///    designed exactly to avoid this trap.
    ///
    /// Wire-format byte-identity is pinned by
    /// `calendar_event_recurrence_overrides_wire_format_is_object_map`
    /// in `tests/types_test.rs`. Design defense: bd:JMAP-1rwf.6. See
    /// also `localizations` below — same shape, same rationale.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub recurrence_overrides: Option<HashMap<String, PatchObject>>,

    /// If `true`, this occurrence is excluded from the series.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub excluded: Option<bool>,

    // ── JSCalendar scheduling/sharing properties (RFC 8984 §4.4) ─────────────
    /// Priority of this event (0–9; 0 = undefined).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub priority: Option<u8>,

    /// Free/busy status: `"free"` or `"busy"` (default `"busy"`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub free_busy_status: Option<String>,

    /// Privacy: `"public"`, `"private"`, or `"secret"` (default `"public"`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub privacy: Option<String>,

    /// Map of scheduling method → reply-to URI.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reply_to: Option<serde_json::Value>,

    /// Addr-spec of the person who sent this event data.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sent_by: Option<String>,

    /// The organizer's calendarAddress (wire: `organizerCalendarAddress`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub organizer_calendar_address: Option<String>,

    /// Map of participant id → `Participant` object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub participants: Option<serde_json::Value>,

    /// iTIP request status string.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub request_status: Option<String>,

    // ── JSCalendar alert properties (RFC 8984 §4.5) ───────────────────────────
    /// Map of alert id → `Alert` object.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub alerts: Option<serde_json::Value>,

    // ── JSCalendar multilingual properties (RFC 8984 §4.6) ───────────────────
    /// Map of language tag → [`PatchObject`] for per-language overrides
    /// (RFC 8984 §4.6).
    ///
    /// Outer envelope is typed at the JMAP level: keys are BCP 47 language
    /// tags and values are JMAP `PatchObject` (RFC 8620 §5.3) wire objects.
    /// The inner `PatchObject` leaves remain `serde_json::Value` so per-
    /// language overrides retain full JSCalendar shape flexibility (Sloppy-
    /// Value at the leaf, typed at the envelope).  Wire format is byte-
    /// identical via `#[serde(transparent)]`.
    ///
    /// **Maintainer note: do NOT "simplify" this shape** — see the same
    /// note on [`Self::recurrence_overrides`] above for the full rationale.
    /// The two fields share the hybrid Sloppy-Value-at-the-leaf,
    /// typed-at-the-envelope pattern (JMAP §5.3 envelope ownership vs.
    /// JSCalendar §4.6 leaf ownership). Design defense: bd:JMAP-1rwf.6.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub localizations: Option<HashMap<String, PatchObject>>,

    // ── JSCalendar time zone properties (RFC 8984 §4.7) ───────────────────────
    /// IANA time zone id for the event's start/end times.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub time_zone: Option<String>,

    /// Custom time zone definitions; opaque passthrough (RFC 8984 §4.7.2).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub time_zones: Option<serde_json::Value>,

    // ── JSCalendar Event-specific properties (RFC 8984 §5.1) ─────────────────
    /// Start date-time as a `LocalDateTime` string.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub start: Option<String>,

    /// Duration as an ISO 8601 duration string (e.g. `"PT1H"`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub duration: Option<String>,

    /// Event status: `"confirmed"`, `"cancelled"`, or `"tentative"`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,

    /// 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).
    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
    pub extra: serde_json::Map<String, serde_json::Value>,
}

/// Serialize an unset `Boolean (default: false)` property as `false`.
///
/// draft-ietf-jmap-calendars-33 §5.7: the "isDraft" and "useDefaultAlerts"
/// properties MUST be included on each object returned by `CalendarEvent/get`.
fn serialize_false_if_none<S: serde::Serializer>(
    value: &Option<bool>,
    serializer: S,
) -> Result<S::Ok, S::Error> {
    serializer.serialize_bool(value.unwrap_or(false))
}

/// Deserialize default for the same properties, so an absent value round-trips
/// to the `Some(false)` it serializes as.
fn some_false() -> Option<bool> {
    Some(false)
}

/// Filter condition for `CalendarEvent/query`
/// (draft-ietf-jmap-calendars-26 §5.11.1).
///
/// All fields are optional; a condition with no fields set matches every
/// CalendarEvent.
///
/// # 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 defines a filterable
/// `Metadata` / `Annotation` companion object. Implemented in `jmap-metadata-types`,
/// `jmap-metadata-server`, and `jmap-metadata-client` (bd JMAP-06zp).
///
/// **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")]
pub struct CalendarEventFilterCondition {
    /// Event must be in this Calendar (Calendar id).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub in_calendar: Option<Id>,

    /// Event end (or any recurrence end) must be on or after this
    /// `LocalDateTime`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub after: Option<String>,

    /// Event start (or any recurrence start) must be before this
    /// `LocalDateTime`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub before: Option<String>,

    /// Full-text search across title, description, locations, and participants.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub text: Option<String>,

    /// Event `title` must contain this text.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,

    /// Event `description` must contain this text.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// Matches against name/description of a participant-associated location.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub location: Option<String>,

    /// Matches against name or email of an `"owner"` participant.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub owner: Option<String>,

    /// Matches against name or email of an `"attendee"` participant.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub attendee: Option<String>,

    /// Event `uid` must be exactly this string.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub uid: Option<String>,
}

/// Concrete filter type for CalendarEvent/query
/// (draft-ietf-jmap-calendars-26 §5.11).
///
/// Alias for `jmap_types::query::Filter<CalendarEventFilterCondition>`
/// 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 CalendarEventFilter = jmap_types::query::Filter<CalendarEventFilterCondition>;

/// Comparator for `CalendarEvent/query`
/// (draft-ietf-jmap-calendars-26 §5.11.2).
///
/// The spec mandates `"start"`, `"uid"`, and `"recurrenceId"` MUST be
/// supported for sorting.
///
/// # 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
/// [`CalendarEventFilterCondition`] for the rationale and for the two
/// recommended paths (`draft-ietf-jmap-metadata`, bd JMAP-06zp; or the
/// pre-IETF sloppy-value escape).
///
/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CalendarEventComparator {
    /// Property name to sort by.  `"start"` MUST be supported.
    pub property: String,

    /// If `true`, sort ascending; if `false`, sort descending (default `true`).
    #[serde(default = "default_ascending")]
    pub is_ascending: bool,

    /// A collation identifier (RFC 4790) to use when comparing strings.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub collation: Option<String>,
}

fn default_ascending() -> bool {
    true
}