use jmap_types::{GetObject, JmapObject, PatchObject, QueryObject, SetObject};
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CalendarProperty {
Id,
Name,
Description,
Color,
SortOrder,
IsSubscribed,
IsVisible,
IsDefault,
IncludeInAvailability,
DefaultAlertsWithTime,
DefaultAlertsWithoutTime,
TimeZone,
ShareWith,
MyRights,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CalendarEventProperty {
Id,
BaseEventId,
CalendarIds,
IsDraft,
IsOrigin,
UtcStart,
UtcEnd,
UseDefaultAlerts,
MayInviteSelf,
MayInviteOthers,
HideAttendees,
BlobId,
Uid,
Title,
Description,
Start,
Duration,
Status,
}
pub const PER_USER_CALENDAR_EVENT_PROPERTIES: &[&str] = &[
"keywords",
"color",
"freeBusyStatus",
"useDefaultAlerts",
"alerts",
];
#[allow(dead_code)]
const PER_USER_PROPERTIES_IN_ENUM: &[&str] = &["useDefaultAlerts"];
#[allow(dead_code)]
const PER_USER_PROPERTIES_NOT_YET_IN_ENUM: &[&str] =
&["keywords", "color", "freeBusyStatus", "alerts"];
#[must_use]
pub fn is_per_user_calendar_event_property(name: &str) -> bool {
PER_USER_CALENDAR_EVENT_PROPERTIES.contains(&name)
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CalendarEventNotificationProperty {
Id,
Created,
ChangedBy,
Comment,
Type,
CalendarEventId,
IsDraft,
Event,
EventPatch,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ParticipantIdentityProperty {
Id,
Name,
CalendarAddress,
IsDefault,
}
impl JmapObject for crate::Calendar {
const TYPE_NAME: &'static str = "Calendar";
type Property = CalendarProperty;
}
impl GetObject for crate::Calendar {}
impl SetObject for crate::Calendar {
type Patch = PatchObject;
const SERVER_SET: &'static [&'static str] = &["id", "isDefault", "myRights"];
const CLIENT_SETTABLE: &'static [&'static str] = &[
"name",
"description",
"color",
"sortOrder",
"isSubscribed",
"isVisible",
"includeInAvailability",
"defaultAlertsWithTime",
"defaultAlertsWithoutTime",
"timeZone",
"shareWith",
"metadata",
"privateMetadata",
];
const IMMUTABLE: &'static [&'static str] = &[];
}
impl JmapObject for crate::CalendarEvent {
const TYPE_NAME: &'static str = "CalendarEvent";
type Property = CalendarEventProperty;
}
impl GetObject for crate::CalendarEvent {}
impl SetObject for crate::CalendarEvent {
type Patch = PatchObject;
const SERVER_SET: &'static [&'static str] = &["id", "baseEventId", "isOrigin"];
const CLIENT_SETTABLE: &'static [&'static str] = &[
"calendarIds",
"isDraft",
"utcStart",
"utcEnd",
"useDefaultAlerts",
"mayInviteSelf",
"mayInviteOthers",
"hideAttendees",
"blobId",
"iCalendar",
"@type",
"version",
"uid",
"relatedTo",
"prodId",
"created",
"updated",
"sequence",
"title",
"description",
"descriptionContentType",
"showWithoutTime",
"locations",
"virtualLocations",
"links",
"locale",
"keywords",
"categories",
"color",
"recurrenceId",
"recurrenceIdTimeZone",
"recurrenceRules",
"excludedRecurrenceRules",
"recurrenceOverrides",
"excluded",
"priority",
"freeBusyStatus",
"privacy",
"replyTo",
"sentBy",
"organizerCalendarAddress",
"participants",
"requestStatus",
"alerts",
"localizations",
"timeZone",
"timeZones",
"start",
"duration",
"status",
"metadata",
"privateMetadata",
];
const IMMUTABLE: &'static [&'static str] = &[];
}
impl QueryObject for crate::CalendarEvent {
type Filter = crate::CalendarEventFilter;
type Comparator = crate::CalendarEventComparator;
}
impl JmapObject for crate::CalendarEventNotification {
const TYPE_NAME: &'static str = "CalendarEventNotification";
type Property = CalendarEventNotificationProperty;
}
impl GetObject for crate::CalendarEventNotification {}
impl SetObject for crate::CalendarEventNotification {
type Patch = PatchObject;
const SERVER_SET: &'static [&'static str] = &[
"id",
"created",
"changedBy",
"comment",
"type",
"calendarEventId",
"isDraft",
"event",
"eventPatch",
];
const CLIENT_SETTABLE: &'static [&'static str] = &[];
const IMMUTABLE: &'static [&'static str] = &[];
}
impl QueryObject for crate::CalendarEventNotification {
type Filter = crate::NotificationFilter;
type Comparator = crate::CalendarEventNotificationComparator;
}
impl JmapObject for crate::ParticipantIdentity {
const TYPE_NAME: &'static str = "ParticipantIdentity";
type Property = ParticipantIdentityProperty;
}
impl GetObject for crate::ParticipantIdentity {}
impl SetObject for crate::ParticipantIdentity {
type Patch = PatchObject;
const SERVER_SET: &'static [&'static str] = &["id", "isDefault"];
const CLIENT_SETTABLE: &'static [&'static str] = &["name", "calendarAddress"];
const IMMUTABLE: &'static [&'static str] = &[];
}
#[cfg(test)]
mod tests {
use super::*;
use jmap_types::{Id, UTCDate, check_property_classification};
use serde_json::{Map, Value, json};
use std::collections::BTreeMap;
fn de<T: serde::de::DeserializeOwned>(v: Value) -> T {
serde_json::from_value(v).expect("hand-written fixture deserializes")
}
fn rights() -> crate::CalendarRights {
de(json!({
"mayReadFreeBusy": true,
"mayReadItems": true,
"mayWriteAll": true,
"mayWriteOwn": true,
"mayUpdatePrivate": true,
"mayRSVP": true,
"mayShare": true,
"mayDelete": true,
}))
}
fn patch() -> PatchObject {
de(json!({"title": "t"}))
}
#[test]
fn calendar_properties_are_classified() {
let obj = crate::Calendar {
id: Some(Id::from("c1")),
name: "n".into(),
description: Some("d".into()),
color: Some("red".into()),
sort_order: 1,
is_subscribed: true,
is_visible: true,
is_default: true,
include_in_availability: de(json!("all")),
default_alerts_with_time: Some(BTreeMap::new()),
default_alerts_without_time: Some(BTreeMap::new()),
time_zone: Some("Etc/UTC".into()),
share_with: Some(BTreeMap::from([(Id::from("p1"), rights())])),
my_rights: rights(),
metadata: Some(de(json!({"acme.example.com": {"memo": "m"}}))),
private_metadata: Some(de(json!({"acme.example.com": {"memo": "p"}}))),
extra: Map::new(),
};
assert_eq!(check_property_classification(&obj), Ok(()));
}
#[test]
fn calendar_event_properties_are_classified() {
let v = || Some(json!({}));
let obj = crate::CalendarEvent {
id: Some(Id::from("e1")),
base_event_id: Some(Id::from("e0")),
calendar_ids: Some(BTreeMap::from([(Id::from("c1"), true)])),
is_draft: Some(true),
is_origin: Some(true),
utc_start: Some(UTCDate::from("2026-01-01T00:00:00Z")),
utc_end: Some(UTCDate::from("2026-01-01T01:00:00Z")),
use_default_alerts: Some(true),
may_invite_self: Some(true),
may_invite_others: Some(true),
hide_attendees: Some(true),
blob_id: Some(Id::from("b1")),
icalendar: Some(json!([])),
at_type: Some("Event".into()),
version: Some("2.0".into()),
uid: Some("u".into()),
related_to: v(),
prod_id: Some("p".into()),
created: Some(UTCDate::from("2026-01-01T00:00:00Z")),
updated: Some(UTCDate::from("2026-01-01T00:00:00Z")),
sequence: Some(1),
title: Some("t".into()),
description: Some("d".into()),
description_content_type: Some("text/plain".into()),
show_without_time: Some(true),
locations: v(),
virtual_locations: v(),
links: v(),
locale: Some("en".into()),
keywords: Some(BTreeMap::from([("k".into(), true)])),
categories: Some(BTreeMap::from([("c".into(), true)])),
color: Some("red".into()),
recurrence_id: Some("2026-01-01T00:00:00".into()),
recurrence_id_time_zone: Some("Etc/UTC".into()),
recurrence_rules: Some(json!([])),
excluded_recurrence_rules: Some(json!([])),
recurrence_overrides: Some(BTreeMap::from([("2026-01-02T00:00:00".into(), patch())])),
excluded: Some(false),
priority: Some(1),
free_busy_status: Some("busy".into()),
privacy: Some("public".into()),
reply_to: Some(BTreeMap::from([(
"imip".into(),
"mailto:a@example.com".into(),
)])),
sent_by: Some("mailto:a@example.com".into()),
organizer_calendar_address: Some("mailto:a@example.com".into()),
participants: v(),
request_status: Some("2.0;Success".into()),
alerts: v(),
localizations: Some(BTreeMap::from([("de".into(), patch())])),
time_zone: Some("Etc/UTC".into()),
time_zones: v(),
start: Some("2026-01-01T00:00:00".into()),
duration: Some("PT1H".into()),
status: Some("confirmed".into()),
metadata: Some(de(json!({"acme.example.com": {"memo": "m"}}))),
private_metadata: Some(de(json!({"acme.example.com": {"memo": "p"}}))),
extra: Map::new(),
};
assert_eq!(check_property_classification(&obj), Ok(()));
}
#[test]
fn calendar_event_notification_properties_are_classified() {
let obj = crate::CalendarEventNotification {
id: Id::from("n1"),
created: UTCDate::from("2026-01-01T00:00:00Z"),
changed_by: de(json!({"name": "A", "email": "a@example.com"})),
comment: Some("c".into()),
notification_type: de(json!("updated")),
calendar_event_id: Id::from("e1"),
is_draft: Some(false),
event: json!({}),
event_patch: Some(patch()),
extra: Map::new(),
};
assert_eq!(check_property_classification(&obj), Ok(()));
}
#[test]
fn participant_identity_properties_are_classified() {
let obj = crate::ParticipantIdentity {
id: Some(Id::from("p1")),
name: "A".into(),
calendar_address: "mailto:a@example.com".into(),
is_default: true,
extra: Map::new(),
};
assert_eq!(check_property_classification(&obj), Ok(()));
}
#[test]
fn per_user_calendar_event_properties_match_spec() {
const SPEC_REGISTERED_PER_USER_PROPERTIES_VERBATIM: &str = "\
* keywords
* color
* freeBusyStatus
* useDefaultAlerts
* alerts
";
let spec_list: Vec<&str> = SPEC_REGISTERED_PER_USER_PROPERTIES_VERBATIM
.lines()
.filter_map(|line| line.strip_prefix("* ").map(str::trim))
.filter(|name| !name.is_empty())
.collect();
assert_eq!(
spec_list.len(),
5,
"spec-quote parser sanity: §10.8.2 bullet list has exactly 5 entries; \
parser found {}: {spec_list:?}",
spec_list.len()
);
assert_eq!(
PER_USER_CALENDAR_EVENT_PROPERTIES,
spec_list.as_slice(),
"PER_USER_CALENDAR_EVENT_PROPERTIES must match \
draft-ietf-jmap-calendars-26 §10.8.2 verbatim (including order)"
);
}
#[test]
fn is_per_user_classifies_spec_properties_as_true() {
for name in PER_USER_CALENDAR_EVENT_PROPERTIES {
assert!(
is_per_user_calendar_event_property(name),
"expected {name} to be classified per-user"
);
}
}
#[test]
fn is_per_user_classifies_shared_properties_as_false() {
for shared in &["id", "title", "start", "duration", "calendarIds", "uid"] {
assert!(
!is_per_user_calendar_event_property(shared),
"expected {shared} to be classified shared"
);
}
}
#[test]
fn is_per_user_rejects_unknown_property() {
assert!(!is_per_user_calendar_event_property(""));
assert!(!is_per_user_calendar_event_property("notARealProperty"));
assert!(!is_per_user_calendar_event_property("alerts/abc"));
}
#[test]
fn per_user_const_is_union_of_two_halves() {
use std::collections::BTreeSet;
let in_enum: BTreeSet<&str> = PER_USER_PROPERTIES_IN_ENUM.iter().copied().collect();
let not_yet: BTreeSet<&str> = PER_USER_PROPERTIES_NOT_YET_IN_ENUM
.iter()
.copied()
.collect();
let public: BTreeSet<&str> = PER_USER_CALENDAR_EVENT_PROPERTIES.iter().copied().collect();
let overlap: BTreeSet<&&str> = in_enum.intersection(¬_yet).collect();
assert!(
overlap.is_empty(),
"PER_USER_PROPERTIES_IN_ENUM and PER_USER_PROPERTIES_NOT_YET_IN_ENUM \
must be disjoint, but both contain: {overlap:?}. When you promote a \
property to the enum, move its string from NOT_YET_IN_ENUM to IN_ENUM, \
do not duplicate it."
);
let union: BTreeSet<&str> = in_enum.union(¬_yet).copied().collect();
assert_eq!(
union, public,
"PER_USER_CALENDAR_EVENT_PROPERTIES must equal the union of the two halves"
);
}
#[test]
fn per_user_in_enum_matches_enum_variants() {
use std::collections::BTreeSet;
fn classify(p: &CalendarEventProperty) -> (&'static str, bool) {
match p {
CalendarEventProperty::Id => ("id", false),
CalendarEventProperty::BaseEventId => ("baseEventId", false),
CalendarEventProperty::CalendarIds => ("calendarIds", false),
CalendarEventProperty::IsDraft => ("isDraft", false),
CalendarEventProperty::IsOrigin => ("isOrigin", false),
CalendarEventProperty::UtcStart => ("utcStart", false),
CalendarEventProperty::UtcEnd => ("utcEnd", false),
CalendarEventProperty::UseDefaultAlerts => ("useDefaultAlerts", true),
CalendarEventProperty::MayInviteSelf => ("mayInviteSelf", false),
CalendarEventProperty::MayInviteOthers => ("mayInviteOthers", false),
CalendarEventProperty::HideAttendees => ("hideAttendees", false),
CalendarEventProperty::BlobId => ("blobId", false),
CalendarEventProperty::Uid => ("uid", false),
CalendarEventProperty::Title => ("title", false),
CalendarEventProperty::Description => ("description", false),
CalendarEventProperty::Start => ("start", false),
CalendarEventProperty::Duration => ("duration", false),
CalendarEventProperty::Status => ("status", false),
}
}
let variants = [
CalendarEventProperty::Id,
CalendarEventProperty::BaseEventId,
CalendarEventProperty::CalendarIds,
CalendarEventProperty::IsDraft,
CalendarEventProperty::IsOrigin,
CalendarEventProperty::UtcStart,
CalendarEventProperty::UtcEnd,
CalendarEventProperty::UseDefaultAlerts,
CalendarEventProperty::MayInviteSelf,
CalendarEventProperty::MayInviteOthers,
CalendarEventProperty::HideAttendees,
CalendarEventProperty::BlobId,
CalendarEventProperty::Uid,
CalendarEventProperty::Title,
CalendarEventProperty::Description,
CalendarEventProperty::Start,
CalendarEventProperty::Duration,
CalendarEventProperty::Status,
];
let derived_per_user: BTreeSet<&str> = variants
.iter()
.filter_map(|p| {
let (wire, is_per_user) = classify(p);
is_per_user.then_some(wire)
})
.collect();
let declared_per_user: BTreeSet<&str> =
PER_USER_PROPERTIES_IN_ENUM.iter().copied().collect();
assert_eq!(
derived_per_user, declared_per_user,
"PER_USER_PROPERTIES_IN_ENUM ({declared_per_user:?}) must match the per-user \
variants derived from the CalendarEventProperty match in this test \
({derived_per_user:?}). Update the const, the match, or both as the spec dictates."
);
let mut wire_names = BTreeSet::new();
for p in &variants {
let (wire, _) = classify(p);
assert!(
wire_names.insert(wire),
"duplicate wire-name {wire:?} in classify(); a CalendarEventProperty \
variant has the wrong wire-string"
);
}
}
#[test]
fn query_filters_are_operator_trees() {
fn decodes_as_operator<O, C>(json: serde_json::Value) -> bool
where
O: QueryObject<Filter = jmap_types::Filter<C>>,
C: serde::de::DeserializeOwned,
{
let f: O::Filter = serde_json::from_value(json).expect("operator tree must decode");
matches!(f, jmap_types::Filter::Operator(_))
}
let not = |cond: serde_json::Value| serde_json::json!({ "operator": "NOT", "conditions": [cond] });
assert!(decodes_as_operator::<crate::CalendarEvent, _>(not(
serde_json::json!({ "inCalendar": "c1" })
)));
assert!(decodes_as_operator::<crate::CalendarEventNotification, _>(
not(serde_json::json!({ "calendarEventIds": ["e1"] }))
));
}
}