diff --git a/crates/groupware/src/scheduling/attendee.rs b/crates/groupware/src/scheduling/attendee.rs index 7b10fdcf..eb3d1fc1 100644 --- a/crates/groupware/src/scheduling/attendee.rs +++ b/crates/groupware/src/scheduling/attendee.rs @@ -5,15 +5,15 @@ */ use crate::scheduling::{ - Email, InstanceId, ItipEntryValue, ItipError, ItipMessage, ItipSnapshot, ItipSnapshots, - ItipSummary, + Email, InstanceId, ItipEntry, ItipEntryValue, ItipError, ItipMessage, ItipSnapshot, + ItipSnapshots, ItipSummary, itip::{ ItipExportAs, can_attendee_modify_property, itip_add_tz, itip_build_envelope, itip_export_component, }, organizer::organizer_request_full, }; -use ahash::AHashSet; +use ahash::{AHashMap, AHashSet}; use calcard::{ common::PartialDateTime, icalendar::{ @@ -46,6 +46,11 @@ pub(crate) fn attendee_handle_update( (Some(local_attendee), Some(old_local_attendee)) if local_attendee.email == old_local_attendee.email => { + // Distinguish a genuine add/remove of a restricted property from a value-only drift + // caused by a client re-encoding the same property + let old_name_counts = count_entry_names(&old_instance.entries); + let new_name_counts = count_entry_names(&instance.entries); + // Check added fields let mut send_update = false; for new_entry in instance.entries.difference(&old_instance.entries) { @@ -80,9 +85,13 @@ pub(crate) fn attendee_handle_update( &instance.comp.component_type, new_entry.name, ) { - return Err(ItipError::CannotModifyProperty( - new_entry.name.clone(), - )); + if name_count(&new_name_counts, new_entry.name) + > name_count(&old_name_counts, new_entry.name) + { + return Err(ItipError::CannotModifyProperty( + new_entry.name.clone(), + )); + } } else { send_update = send_update || (instance.comp.component_type @@ -149,7 +158,9 @@ pub(crate) fn attendee_handle_update( if !can_attendee_modify_property( &instance.comp.component_type, removed_entry.name, - ) { + ) && name_count(&old_name_counts, removed_entry.name) + > name_count(&new_name_counts, removed_entry.name) + { // Removing these properties is not allowed return Err(ItipError::CannotModifyProperty( removed_entry.name.clone(), @@ -351,3 +362,18 @@ pub(crate) fn attendee_decline<'x>( (cancel_comp, &local_attendee.email) }) } + +fn count_entry_names<'a>( + entries: &'a AHashSet>, +) -> AHashMap<&'a ICalendarProperty, usize> { + let mut counts = AHashMap::with_capacity(entries.len()); + for entry in entries { + *counts.entry(entry.name).or_insert(0) += 1; + } + counts +} + +#[inline] +fn name_count(counts: &AHashMap<&ICalendarProperty, usize>, name: &ICalendarProperty) -> usize { + counts.get(name).copied().unwrap_or(0) +} diff --git a/crates/jmap/src/registry/mapping/log.rs b/crates/jmap/src/registry/mapping/log.rs index e3d08cea..26907dd2 100644 --- a/crates/jmap/src/registry/mapping/log.rs +++ b/crates/jmap/src/registry/mapping/log.rs @@ -19,6 +19,7 @@ use registry::{ types::{EnumImpl, datetime::UTCDateTime}, }; use std::{ + borrow::Cow, fs::{self, File}, io::{self, BufRead, BufReader, Read, Seek, SeekFrom}, path::Path, @@ -267,6 +268,7 @@ fn read_log_entries( } fn log_from_line(line: &str) -> Option { + let line = strip_ansi(line); let (timestamp, rest) = line.split_once(' ')?; let timestamp = DateTime::parse_from_rfc3339(timestamp).ok()?; let (level, rest) = rest.trim().split_once(' ')?; @@ -281,6 +283,43 @@ fn log_from_line(line: &str) -> Option { }) } +fn strip_ansi(line: &str) -> Cow<'_, str> { + if !line.contains('\x1b') { + return Cow::Borrowed(line); + } + + let mut out = String::with_capacity(line.len()); + let mut chars = line.chars(); + while let Some(c) = chars.next() { + if c != '\x1b' { + out.push(c); + continue; + } + match chars.next() { + Some('[') => { + for c in chars.by_ref() { + if matches!(c as u32, 0x40..=0x7e) { + break; + } + } + } + Some(']') => { + while let Some(c) = chars.next() { + if c == '\x07' { + break; + } + if c == '\x1b' { + chars.next(); + break; + } + } + } + _ => {} + } + } + Cow::Owned(out) +} + /* * SPDX-FileCopyrightText: 2017 Michael Coyne * diff --git a/crates/jmap/src/sieve/set.rs b/crates/jmap/src/sieve/set.rs index 190aba7d..c0dc22d1 100644 --- a/crates/jmap/src/sieve/set.rs +++ b/crates/jmap/src/sieve/set.rs @@ -62,17 +62,7 @@ pub trait SieveScriptSet: Sync + Send { update: Option<(u32, Archive<&'x ArchivedSieveScript>)>, ctx: &SetContext, session_id: u64, - ) -> impl Future< - Output = trc::Result< - Result< - ( - ObjectIndexBuilder<&'x ArchivedSieveScript, SieveScript>, - Option>, - ), - SetError, - >, - >, - > + Send; + ) -> impl Future, SetError>>> + Send; } impl SieveScriptSet for Server { @@ -112,6 +102,7 @@ impl SieveScriptSet for Server { // Process creates let mut batch = BatchBuilder::new(); + let mut activations = Vec::new(); for (id, object) in request.unwrap_create() { if sieve_ids.len() < self.object_quota(account.object_quotas(), StorageQuota::MaxSieveScripts) as u64 @@ -120,11 +111,16 @@ impl SieveScriptSet for Server { .sieve_set_item(object, None, &ctx, session.session_id) .await? { - Ok((mut builder, Some(blob))) => { + Ok(mut result) => { // Store blob - let sieve = &mut builder.changes_mut().unwrap(); - let (blob_hash, blob_hold) = - self.put_temporary_blob(account_id, &blob, 60).await?; + let sieve = &mut result.builder.changes_mut().unwrap(); + let (blob_hash, blob_hold) = self + .put_temporary_blob( + account_id, + result.blob_update.as_ref().unwrap(), + 60, + ) + .await?; sieve.blob_hash = blob_hash; let blob_size = sieve.size as usize; let blob_hash = sieve.blob_hash.clone(); @@ -139,11 +135,20 @@ impl SieveScriptSet for Server { .with_account_id(account_id) .with_collection(Collection::SieveScript) .with_document(document_id) - .custom(builder.with_changed_by(ctx.access_token.account_tenant_ids())) + .custom( + result + .builder + .with_changed_by(ctx.access_token.account_tenant_ids()), + ) .caused_by(trc::location!())? .clear(blob_hold) .commit_point(); + // Set isActive if needed + if let Some(set_item) = result.set_item { + activations.push((document_id, set_item)); + } + let mut result = Map::with_capacity(1) .with_key_value(SieveProperty::Id, SieveValue::Id(document_id.into())) .with_key_value( @@ -179,7 +184,6 @@ impl SieveScriptSet for Server { Err(err) => { ctx.response.not_created.append(id, err); } - _ => unreachable!(), } } else { ctx.response.not_created.append( @@ -226,16 +230,16 @@ impl SieveScriptSet for Server { ) .await? { - Ok((mut builder, blob)) => { + Ok(mut result) => { // Prepare write batch batch .with_account_id(account_id) .with_collection(Collection::SieveScript) .with_document(document_id); - let blob_id = if let Some(blob) = blob { + let blob_id = if let Some(blob) = result.blob_update.take() { // Store blob - let sieve = &mut builder.changes_mut().unwrap(); + let sieve = &mut result.builder.changes_mut().unwrap(); let (blob_hash, blob_hold) = self.put_temporary_blob(account_id, &blob, 60).await?; sieve.blob_hash = blob_hash; @@ -259,9 +263,18 @@ impl SieveScriptSet for Server { None }; + // Set isActive if needed + if let Some(set_item) = result.set_item { + activations.push((document_id, set_item)); + } + // Write record batch - .custom(builder.with_changed_by(ctx.access_token.account_tenant_ids())) + .custom( + result + .builder + .with_changed_by(ctx.access_token.account_tenant_ids()), + ) .caused_by(trc::location!())? .commit_point(); @@ -328,11 +341,25 @@ impl SieveScriptSet for Server { } } - // Activate / deactivate scripts - let on_success_deactivate_script = request + // Non-standard script activation handling + let mut on_success_deactivate_script = request .arguments .on_success_deactivate_script .unwrap_or(false); + if activations.len() == 1 { + let (document_id, set_item) = activations[0]; + let is_active = active_script_id.is_some_and(|active_id| active_id == document_id); + if set_item { + if request.arguments.on_success_activate_script.is_none() && !is_active { + request.arguments.on_success_activate_script = + Some(MaybeIdReference::Id(document_id.into())); + } + } else if !on_success_deactivate_script && is_active { + on_success_deactivate_script = true; + } + } + + // Activate / deactivate scripts if ctx.response.not_created.is_empty() && ctx.response.not_updated.is_empty() && ctx.response.not_destroyed.is_empty() @@ -375,15 +402,7 @@ impl SieveScriptSet for Server { update: Option<(u32, Archive<&'x ArchivedSieveScript>)>, ctx: &SetContext<'_>, session_id: u64, - ) -> trc::Result< - Result< - ( - ObjectIndexBuilder<&'x ArchivedSieveScript, SieveScript>, - Option>, - ), - SetError, - >, - > { + ) -> trc::Result, SetError>> { // Vacation script cannot be modified if update .as_ref() @@ -396,6 +415,7 @@ impl SieveScriptSet for Server { } // Parse properties + let mut set_item = None; let mut changes = update .as_ref() .map(|(_, obj)| obj.deserialize().unwrap_or_default()) @@ -450,6 +470,10 @@ impl SieveScriptSet for Server { (Key::Property(SieveProperty::Name), Value::Null) => { continue; } + (Key::Property(SieveProperty::IsActive), Value::Bool(value)) => { + set_item = Some(value); + continue; + } _ => { return Ok(Err(SetError::invalid_properties() .with_property(property.into_owned()) @@ -528,11 +552,18 @@ impl SieveScriptSet for Server { }; // Validate - Ok(Ok(( - ObjectIndexBuilder::new() + Ok(Ok(SetItemResponse { + builder: ObjectIndexBuilder::new() .with_changes(changes) .with_current_opt(update.map(|(_, current)| current)), blob_update, - ))) + set_item, + })) } } + +pub struct SetItemResponse<'x> { + builder: ObjectIndexBuilder<&'x ArchivedSieveScript, SieveScript>, + blob_update: Option>, + set_item: Option, +} diff --git a/tests/resources/itip/rfc5546_event_recurring.txt b/tests/resources/itip/rfc5546_event_recurring.txt index cef668f6..a29428b8 100644 --- a/tests/resources/itip/rfc5546_event_recurring.txt +++ b/tests/resources/itip/rfc5546_event_recurring.txt @@ -890,3 +890,98 @@ SEQUENCE:3 END:VEVENT END:VCALENDAR +# Attendee decline of a recurring event tolerates client-side RRULE drift +> put a@example.com rrule-drift@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +VERSION:2.0 +BEGIN:VEVENT +UID:rrule-drift@example.com +SEQUENCE:0 +DTSTAMP:19970526T083000Z +DTSTART:19970603T210000Z +DTEND:19970603T220000Z +SUMMARY:Weekly Sync +RRULE:FREQ=WEEKLY;BYDAY=TU +ORGANIZER:mailto:a@example.com +ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:a@example.com +ATTENDEE;RSVP=TRUE:mailto:b@example.com +END:VEVENT +END:VCALENDAR + +> expect +from: a@example.com +to: b@example.com +summary: invite +summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: None, is_organizer: false }]) +summary.dtstart: Time(ItipTime { start: 865371600, tz_id: 32768 }) +summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count: None, interval: None, bysecond: [], byminute: [], byhour: [], byday: [ICalendarDay { ordwk: None, weekday: Tuesday }], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: None, rscale: None, skip: None }) +summary.summary: Text("Weekly Sync") +BEGIN:VCALENDAR +METHOD:REQUEST +PRODID:-//Stalwart Labs LLC//Stalwart Server//EN +VERSION:2.0 +BEGIN:VEVENT +SUMMARY:Weekly Sync +DTEND:19970603T220000Z +DTSTART:19970603T210000Z +ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:a@example.com +ATTENDEE;RSVP=TRUE;PARTSTAT=NEEDS-ACTION:mailto:b@example.com +ORGANIZER:mailto:a@example.com +UID:rrule-drift@example.com +RRULE:FREQ=WEEKLY;BYDAY=TU +DTSTAMP:0 +SEQUENCE:1 +END:VEVENT +END:VCALENDAR + +# Deliver the REQUEST to B +> send + +# B declines the master. Their client re-encoded the RRULE to include an explicit INTERVAL=1 +> put b@example.com rrule-drift@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +VERSION:2.0 +BEGIN:VEVENT +UID:rrule-drift@example.com +SEQUENCE:0 +DTSTAMP:19970526T090000Z +DTSTART:19970603T210000Z +DTEND:19970603T220000Z +SUMMARY:Weekly Sync +RRULE:FREQ=WEEKLY;BYDAY=TU;INTERVAL=1 +ORGANIZER:mailto:a@example.com +ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:a@example.com +ATTENDEE;RSVP=TRUE;PARTSTAT=DECLINED:mailto:b@example.com +END:VEVENT +END:VCALENDAR + +> expect +from: b@example.com +to: a@example.com +summary: rsvp DECLINED +summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: None, is_organizer: false }]) +summary.dtstart: Time(ItipTime { start: 865371600, tz_id: 32768 }) +summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count: None, interval: Some(1), bysecond: [], byminute: [], byhour: [], byday: [ICalendarDay { ordwk: None, weekday: Tuesday }], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: None, rscale: None, skip: None }) +summary.summary: Text("Weekly Sync") +BEGIN:VCALENDAR +METHOD:REPLY +PRODID:-//Stalwart Labs LLC//Stalwart Server//EN +VERSION:2.0 +BEGIN:VEVENT +SUMMARY:Weekly Sync +DTEND:19970603T220000Z +DTSTART:19970603T210000Z +ATTENDEE;RSVP=TRUE;PARTSTAT=DECLINED:mailto:b@example.com +ORGANIZER:mailto:a@example.com +UID:rrule-drift@example.com +DTSTAMP:0 +SEQUENCE:0 +REQUEST-STATUS:2.0;Success +END:VEVENT +END:VCALENDAR + +# Deliver the REPLY to A +> send +