Skip to main content

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}