jmap_calendars_types/notification.rs
1//! CalendarEventNotification, Person, and NotificationType.
2//!
3//! Normative reference: draft-ietf-jmap-calendars-33 §7.
4
5use jmap_types::{Id, PatchObject, UTCDate, impl_string_enum};
6use serde::{Deserialize, Serialize};
7
8/// Who made the change that triggered a [`CalendarEventNotification`]
9/// (draft-ietf-jmap-calendars-33 §7).
10#[non_exhaustive]
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct Person {
14 /// Display name of the person who made the change.
15 pub name: String,
16
17 /// Email address of the person, or `null` if not available.
18 pub email: Option<String>,
19
20 /// Id of the Principal corresponding to this person, or `null` if
21 /// the change came from an external entity (e.g. an iTIP invitation).
22 pub principal_id: Option<Id>,
23
24 /// CalendarAddress URI of the person, or `null`.
25 pub calendar_address: Option<String>,
26
27 /// Catch-all for vendor / site / private extension fields not covered
28 /// by the typed fields above. Preserves unknown fields across
29 /// deserialize/serialize round-trip per workspace extras-preservation
30 /// policy (see workspace AGENTS.md).
31 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
32 pub extra: serde_json::Map<String, serde_json::Value>,
33}
34
35/// Type of change recorded by a [`CalendarEventNotification`]
36/// (draft-ietf-jmap-calendars-33 §7).
37#[non_exhaustive]
38#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39pub enum NotificationType {
40 /// A calendar event was created.
41 Created,
42 /// A calendar event was updated.
43 Updated,
44 /// A calendar event was destroyed.
45 Destroyed,
46 /// Any notification type string not recognised by this implementation.
47 Other(String),
48}
49
50impl_string_enum!(NotificationType, "a JMAP CalendarEventNotification type string",
51 "created" => Created,
52 "updated" => Updated,
53 "destroyed" => Destroyed,
54);
55
56/// A JMAP CalendarEventNotification object
57/// (draft-ietf-jmap-calendars-33 §7).
58///
59/// Records a change made to a calendar event by an external entity or
60/// another client. Apart from `hideAttendees` filtering, a notification
61/// has no per-user data, so one object may be shared by all users with
62/// access to the calendar; but the server MUST NOT create one for anyone
63/// other than the calendar's owner when the event's `privacy` is
64/// `"private"` or `"secret"` before or after the change (§7).
65///
66/// Only `destroyed` notifications may be explicitly managed via
67/// `CalendarEventNotification/set`; `created` and `updated` notifications
68/// are server-set.
69#[non_exhaustive]
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct CalendarEventNotification {
73 /// Server-assigned identifier for this notification.
74 pub id: Id,
75
76 /// UTC date-time when this notification was created.
77 pub created: UTCDate,
78
79 /// Who made the change.
80 pub changed_by: Person,
81
82 /// Comment sent along with the change (e.g. iTIP COMMENT property), if any.
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub comment: Option<String>,
85
86 /// Type of change recorded.
87 ///
88 /// Wire field name is `type`.
89 #[serde(rename = "type")]
90 pub notification_type: NotificationType,
91
92 /// Id of the CalendarEvent this notification is about (always the base
93 /// event id, even for single-instance changes).
94 pub calendar_event_id: Id,
95
96 /// Whether the event is a draft. Present for `created` and `updated`
97 /// notifications only.
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub is_draft: Option<bool>,
100
101 /// The CalendarEvent data before the change (for `updated` or `destroyed`),
102 /// or the data after creation (for `created`).
103 pub event: serde_json::Value,
104
105 /// A PatchObject encoding the change between `event` and the state after
106 /// the update. Present for `updated` notifications only.
107 ///
108 /// Wire format is byte-identical to a plain JSON object via
109 /// [`PatchObject`]'s `#[serde(transparent)]`.
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub event_patch: Option<PatchObject>,
112
113 /// Catch-all for vendor / site / private extension fields not covered
114 /// by the typed fields above. Preserves unknown fields across
115 /// deserialize/serialize round-trip per workspace extras-preservation
116 /// policy (see workspace AGENTS.md).
117 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
118 pub extra: serde_json::Map<String, serde_json::Value>,
119}
120
121// Serde-default function for the `@type` discriminator on
122// CalendarAlert. draft-ietf-jmap-calendars-33 §6.4 marks the field as
123// mandatory on the wire; we supply the literal as a default so
124// deserialize is liberal in what it accepts (spec-violating vendor
125// input missing `@type` does not fail). Mirrors the same pattern in
126// `jmap-jscalendar-types` (bd:JMAP-ky8g.10) and `jmap-tasks-types`
127// (bd:JMAP-ky8g.1).
128
129fn calendar_alert_at_type_default() -> String {
130 "CalendarAlert".to_owned()
131}
132
133/// Push-notification payload emitted when a calendar alert fires
134/// (draft-ietf-jmap-calendars-33 §6.4).
135///
136/// Sent over the JMAP push channel when the server determines that an alert
137/// for a CalendarEvent should fire. Clients receiving this payload should
138/// trigger the appropriate local notification (e.g. an OS alert).
139///
140/// `recurrenceId` is `null` for non-recurring events and MUST serialize as
141/// `null` (not be omitted) — the receiver uses its presence to distinguish
142/// recurring from non-recurring.
143#[non_exhaustive]
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct CalendarAlert {
147 /// Object type discriminator; always `"CalendarAlert"` on the wire.
148 ///
149 /// Deserialize is liberal: if `@type` is absent (spec-violating
150 /// vendor input), this field defaults to `"CalendarAlert"` rather
151 /// than failing the whole parent object's deserialize. Serialize
152 /// always emits the field. See bd:JMAP-ky8g.10.
153 #[serde(rename = "@type", default = "calendar_alert_at_type_default")]
154 pub at_type: String,
155
156 /// The account that owns the calendar event.
157 pub account_id: Id,
158
159 /// Id of the CalendarEvent for which this alert is firing.
160 pub calendar_event_id: Id,
161
162 /// The UID property of the iCalendar object underlying the calendar event.
163 pub uid: String,
164
165 /// The recurrenceId of the specific occurrence, or `null` for non-recurring
166 /// events (draft-ietf-jmap-calendars-33 §6.4).
167 ///
168 /// Serializes as `null` when `None` — intentionally NOT marked
169 /// `skip_serializing_if` so the receiver can distinguish recurring from
170 /// non-recurring events.
171 ///
172 /// **Maintainer note: do NOT add `#[serde(skip_serializing_if = "Option::is_none")]`
173 /// to this field.** Adding it would silently break the spec contract:
174 /// per draft-ietf-jmap-calendars-33 §6.4, the receiver uses the
175 /// **presence** of `recurrenceId` to determine whether the alert
176 /// fired for a recurring or non-recurring event. An omitted field
177 /// is indistinguishable from a sender that forgot to include it; an
178 /// explicit `null` carries the "non-recurring" signal. This is the
179 /// same "required-and-nullable" wire shape used by
180 /// `PrincipalCalendarsCapability.account_id` in `capability.rs:121`.
181 ///
182 /// A "clippy clean-up", "consistency PR", or pattern-based code
183 /// audit may flag this field as inconsistent with neighbours that
184 /// use `skip_serializing_if` and propose to "fix" it. That is a
185 /// silent wire-protocol break. The regression test
186 /// `calendar_alert_recurrence_id_null_for_non_recurring` (in
187 /// `tests/types_test.rs`) pins the current behavior. Design defense:
188 /// bd:JMAP-1rwf.7.
189 pub recurrence_id: Option<String>,
190
191 /// Id of the [`Alert`](crate::Alert) object within the CalendarEvent that is firing.
192 pub alert_id: String,
193
194 /// Catch-all for vendor / site / private extension fields not covered
195 /// by the typed fields above. Preserves unknown fields across
196 /// deserialize/serialize round-trip per workspace extras-preservation
197 /// policy (see workspace AGENTS.md).
198 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
199 pub extra: serde_json::Map<String, serde_json::Value>,
200}
201
202/// Filter condition for `CalendarEventNotification/query`
203/// (draft-ietf-jmap-calendars-33 §7.4.1).
204///
205/// All fields are optional; a condition with no fields set matches every
206/// CalendarEventNotification.
207///
208/// # Excluded from extras preservation
209///
210/// This type is **out of scope** for the workspace extras-preservation
211/// policy: it carries no flatten-extras `extra` field. Filter clauses the
212/// server does not understand are a query-correctness hazard — silently
213/// preserving an unrecognised clause and round-tripping it back to the
214/// client can return the wrong set of records with no error signal.
215///
216/// ## What to do instead
217///
218/// **IETF-track path.** Vendors who need both capability-level declaration
219/// and filterability for custom fields should use
220/// `draft-ietf-jmap-metadata` (capability URI
221/// `urn:ietf:params:jmap:metadata`), which adds namespace-keyed
222/// `metadata` / `privateMetadata` properties to each listed data type,
223/// filterable through `metadata*` conditions on that type's own `/query`
224/// (draft-ietf-jmap-metadata-04 §2, §3.6). Helpers live in
225/// `jmap-metadata-types`, `jmap-metadata-server`, and
226/// `jmap-metadata-client`.
227///
228/// **Pre-IETF escape.** Vendors who cannot wait for the metadata draft can
229/// either escape the filter tree to `serde_json::Value` or fork the
230/// `FilterCondition` type. See `crate-jmap-calendars-types/PLAN.md` for
231/// the hybrid sloppy-value pattern.
232///
233/// Cross-reference: bd JMAP-lbdy "Decision: filter algebra excluded".
234#[non_exhaustive]
235#[derive(Debug, Clone, Default, Serialize, Deserialize)]
236#[serde(rename_all = "camelCase", deny_unknown_fields)]
237pub struct NotificationFilterCondition {
238 /// Notification `created` time must be on or after this UTCDate.
239 #[serde(skip_serializing_if = "Option::is_none")]
240 pub after: Option<UTCDate>,
241
242 /// Notification `created` time must be before this UTCDate.
243 #[serde(skip_serializing_if = "Option::is_none")]
244 pub before: Option<UTCDate>,
245
246 /// Notification `type` must equal this value.
247 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
248 pub notification_type: Option<NotificationType>,
249
250 /// Notification `calendarEventId` must be in this list.
251 #[serde(skip_serializing_if = "Option::is_none")]
252 pub calendar_event_ids: Option<Vec<Id>>,
253}
254
255/// Concrete filter type for CalendarEventNotification/query
256/// (draft-ietf-jmap-calendars-33 §6).
257///
258/// Alias for `jmap_types::Filter<NotificationFilterCondition>`
259/// provided so callers do not have to reach into `jmap-types` directly.
260/// Mirrors the canonical [`jmap_mail_types::EmailFilter`] shape from the
261/// workspace canonical extension-types template.
262///
263/// [`jmap_mail_types::EmailFilter`]: https://docs.rs/jmap-mail-types/latest/jmap_mail_types/query/type.EmailFilter.html
264pub type NotificationFilter = jmap_types::Filter<NotificationFilterCondition>;
265
266/// The property to sort by in a [`CalendarEventNotificationComparator`]
267/// (draft-ietf-jmap-calendars-33 §7.4.2: "The "created" property MUST be
268/// supported for sorting.").
269///
270/// Unknown property names are preserved in
271/// [`Other`](CalendarEventNotificationComparatorProperty::Other) so they
272/// round-trip; a server cannot sort by one it does not know (RFC 8620 §5.5
273/// `unsupportedSort`).
274#[non_exhaustive]
275#[derive(Debug, Clone, PartialEq, Eq, Hash)]
276pub enum CalendarEventNotificationComparatorProperty {
277 /// Sort by `created`.
278 Created,
279 /// A property not listed above, kept so it round-trips; a server
280 /// answers it with `unsupportedSort` (RFC 8620 §5.5).
281 Other(String),
282}
283
284impl_string_enum!(CalendarEventNotificationComparatorProperty,
285 "a CalendarEventNotification sort property string",
286 "created" => Created,
287);
288
289/// Comparator for `CalendarEventNotification/query`
290/// (draft-ietf-jmap-calendars-33 §7.4.2).
291///
292/// Build one with [`CalendarEventNotificationComparator::new`]; the struct
293/// is `#[non_exhaustive]`. Out of scope for extras preservation for the
294/// same reason as [`crate::CalendarEventComparator`]: backends dispatch on
295/// `property`.
296#[non_exhaustive]
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298#[serde(rename_all = "camelCase")]
299pub struct CalendarEventNotificationComparator {
300 /// The property to sort by.
301 pub property: CalendarEventNotificationComparatorProperty,
302
303 /// If `true`, sort ascending; if `false`, sort descending (default `true`).
304 #[serde(default = "bool_true", skip_serializing_if = "is_true")]
305 pub is_ascending: bool,
306
307 /// A collation identifier (RFC 4790) to use when comparing strings.
308 #[serde(skip_serializing_if = "Option::is_none")]
309 pub collation: Option<String>,
310}
311
312impl CalendarEventNotificationComparator {
313 /// An ascending comparator on `property` with no collation
314 /// (`isAscending` defaults to `true`, RFC 8620 §5.5).
315 pub fn new(property: CalendarEventNotificationComparatorProperty) -> Self {
316 Self {
317 property,
318 is_ascending: true,
319 collation: None,
320 }
321 }
322}
323
324fn bool_true() -> bool {
325 true
326}
327
328fn is_true(b: &bool) -> bool {
329 *b
330}
331
332#[cfg(test)]
333mod comparator_tests {
334 use super::*;
335
336 /// Oracle: draft-ietf-jmap-calendars-33 §7.4.2 names "created" as the
337 /// sort property; RFC 8620 §5.5 makes `isAscending` default to true.
338 #[test]
339 fn created_comparator_decodes_from_spec_name() {
340 let c: CalendarEventNotificationComparator =
341 serde_json::from_str(r#"{"property":"created"}"#).expect("must parse");
342 assert_eq!(
343 c.property,
344 CalendarEventNotificationComparatorProperty::Created
345 );
346 assert!(c.is_ascending);
347 assert_eq!(c.collation, None);
348 }
349
350 /// Oracle: hand-written JSON; an unknown property survives as `Other`
351 /// and re-serializes unchanged (AGENTS.md comparator rule).
352 #[test]
353 fn unknown_property_round_trips() {
354 let json = serde_json::json!({"property":"calendarEventId","isAscending":false});
355 let c: CalendarEventNotificationComparator =
356 serde_json::from_value(json.clone()).expect("must parse");
357 assert_eq!(
358 c.property,
359 CalendarEventNotificationComparatorProperty::Other("calendarEventId".to_owned())
360 );
361 assert_eq!(serde_json::to_value(&c).unwrap(), json);
362 }
363}