CalDAV: Allow organized properties to be present in PUT requests if they are equal to the existing ones
This commit is contained in:
@@ -5,15 +5,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::scheduling::{
|
use crate::scheduling::{
|
||||||
Email, InstanceId, ItipEntryValue, ItipError, ItipMessage, ItipSnapshot, ItipSnapshots,
|
Email, InstanceId, ItipEntry, ItipEntryValue, ItipError, ItipMessage, ItipSnapshot,
|
||||||
ItipSummary,
|
ItipSnapshots, ItipSummary,
|
||||||
itip::{
|
itip::{
|
||||||
ItipExportAs, can_attendee_modify_property, itip_add_tz, itip_build_envelope,
|
ItipExportAs, can_attendee_modify_property, itip_add_tz, itip_build_envelope,
|
||||||
itip_export_component,
|
itip_export_component,
|
||||||
},
|
},
|
||||||
organizer::organizer_request_full,
|
organizer::organizer_request_full,
|
||||||
};
|
};
|
||||||
use ahash::AHashSet;
|
use ahash::{AHashMap, AHashSet};
|
||||||
use calcard::{
|
use calcard::{
|
||||||
common::PartialDateTime,
|
common::PartialDateTime,
|
||||||
icalendar::{
|
icalendar::{
|
||||||
@@ -46,6 +46,11 @@ pub(crate) fn attendee_handle_update(
|
|||||||
(Some(local_attendee), Some(old_local_attendee))
|
(Some(local_attendee), Some(old_local_attendee))
|
||||||
if local_attendee.email == old_local_attendee.email =>
|
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
|
// Check added fields
|
||||||
let mut send_update = false;
|
let mut send_update = false;
|
||||||
for new_entry in instance.entries.difference(&old_instance.entries) {
|
for new_entry in instance.entries.difference(&old_instance.entries) {
|
||||||
@@ -80,9 +85,13 @@ pub(crate) fn attendee_handle_update(
|
|||||||
&instance.comp.component_type,
|
&instance.comp.component_type,
|
||||||
new_entry.name,
|
new_entry.name,
|
||||||
) {
|
) {
|
||||||
return Err(ItipError::CannotModifyProperty(
|
if name_count(&new_name_counts, new_entry.name)
|
||||||
new_entry.name.clone(),
|
> name_count(&old_name_counts, new_entry.name)
|
||||||
));
|
{
|
||||||
|
return Err(ItipError::CannotModifyProperty(
|
||||||
|
new_entry.name.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
send_update = send_update
|
send_update = send_update
|
||||||
|| (instance.comp.component_type
|
|| (instance.comp.component_type
|
||||||
@@ -149,7 +158,9 @@ pub(crate) fn attendee_handle_update(
|
|||||||
if !can_attendee_modify_property(
|
if !can_attendee_modify_property(
|
||||||
&instance.comp.component_type,
|
&instance.comp.component_type,
|
||||||
removed_entry.name,
|
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
|
// Removing these properties is not allowed
|
||||||
return Err(ItipError::CannotModifyProperty(
|
return Err(ItipError::CannotModifyProperty(
|
||||||
removed_entry.name.clone(),
|
removed_entry.name.clone(),
|
||||||
@@ -351,3 +362,18 @@ pub(crate) fn attendee_decline<'x>(
|
|||||||
(cancel_comp, &local_attendee.email)
|
(cancel_comp, &local_attendee.email)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn count_entry_names<'a>(
|
||||||
|
entries: &'a AHashSet<ItipEntry<'a>>,
|
||||||
|
) -> 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use registry::{
|
|||||||
types::{EnumImpl, datetime::UTCDateTime},
|
types::{EnumImpl, datetime::UTCDateTime},
|
||||||
};
|
};
|
||||||
use std::{
|
use std::{
|
||||||
|
borrow::Cow,
|
||||||
fs::{self, File},
|
fs::{self, File},
|
||||||
io::{self, BufRead, BufReader, Read, Seek, SeekFrom},
|
io::{self, BufRead, BufReader, Read, Seek, SeekFrom},
|
||||||
path::Path,
|
path::Path,
|
||||||
@@ -267,6 +268,7 @@ fn read_log_entries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn log_from_line(line: &str) -> Option<Log> {
|
fn log_from_line(line: &str) -> Option<Log> {
|
||||||
|
let line = strip_ansi(line);
|
||||||
let (timestamp, rest) = line.split_once(' ')?;
|
let (timestamp, rest) = line.split_once(' ')?;
|
||||||
let timestamp = DateTime::parse_from_rfc3339(timestamp).ok()?;
|
let timestamp = DateTime::parse_from_rfc3339(timestamp).ok()?;
|
||||||
let (level, rest) = rest.trim().split_once(' ')?;
|
let (level, rest) = rest.trim().split_once(' ')?;
|
||||||
@@ -281,6 +283,43 @@ fn log_from_line(line: &str) -> Option<Log> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 <mjc@hey.com>
|
* SPDX-FileCopyrightText: 2017 Michael Coyne <mjc@hey.com>
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -62,17 +62,7 @@ pub trait SieveScriptSet: Sync + Send {
|
|||||||
update: Option<(u32, Archive<&'x ArchivedSieveScript>)>,
|
update: Option<(u32, Archive<&'x ArchivedSieveScript>)>,
|
||||||
ctx: &SetContext,
|
ctx: &SetContext,
|
||||||
session_id: u64,
|
session_id: u64,
|
||||||
) -> impl Future<
|
) -> impl Future<Output = trc::Result<Result<SetItemResponse<'x>, SetError<SieveProperty>>>> + Send;
|
||||||
Output = trc::Result<
|
|
||||||
Result<
|
|
||||||
(
|
|
||||||
ObjectIndexBuilder<&'x ArchivedSieveScript, SieveScript>,
|
|
||||||
Option<Vec<u8>>,
|
|
||||||
),
|
|
||||||
SetError<SieveProperty>,
|
|
||||||
>,
|
|
||||||
>,
|
|
||||||
> + Send;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SieveScriptSet for Server {
|
impl SieveScriptSet for Server {
|
||||||
@@ -112,6 +102,7 @@ impl SieveScriptSet for Server {
|
|||||||
|
|
||||||
// Process creates
|
// Process creates
|
||||||
let mut batch = BatchBuilder::new();
|
let mut batch = BatchBuilder::new();
|
||||||
|
let mut activations = Vec::new();
|
||||||
for (id, object) in request.unwrap_create() {
|
for (id, object) in request.unwrap_create() {
|
||||||
if sieve_ids.len()
|
if sieve_ids.len()
|
||||||
< self.object_quota(account.object_quotas(), StorageQuota::MaxSieveScripts) as u64
|
< 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)
|
.sieve_set_item(object, None, &ctx, session.session_id)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
Ok((mut builder, Some(blob))) => {
|
Ok(mut result) => {
|
||||||
// Store blob
|
// Store blob
|
||||||
let sieve = &mut builder.changes_mut().unwrap();
|
let sieve = &mut result.builder.changes_mut().unwrap();
|
||||||
let (blob_hash, blob_hold) =
|
let (blob_hash, blob_hold) = self
|
||||||
self.put_temporary_blob(account_id, &blob, 60).await?;
|
.put_temporary_blob(
|
||||||
|
account_id,
|
||||||
|
result.blob_update.as_ref().unwrap(),
|
||||||
|
60,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
sieve.blob_hash = blob_hash;
|
sieve.blob_hash = blob_hash;
|
||||||
let blob_size = sieve.size as usize;
|
let blob_size = sieve.size as usize;
|
||||||
let blob_hash = sieve.blob_hash.clone();
|
let blob_hash = sieve.blob_hash.clone();
|
||||||
@@ -139,11 +135,20 @@ impl SieveScriptSet for Server {
|
|||||||
.with_account_id(account_id)
|
.with_account_id(account_id)
|
||||||
.with_collection(Collection::SieveScript)
|
.with_collection(Collection::SieveScript)
|
||||||
.with_document(document_id)
|
.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!())?
|
.caused_by(trc::location!())?
|
||||||
.clear(blob_hold)
|
.clear(blob_hold)
|
||||||
.commit_point();
|
.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)
|
let mut result = Map::with_capacity(1)
|
||||||
.with_key_value(SieveProperty::Id, SieveValue::Id(document_id.into()))
|
.with_key_value(SieveProperty::Id, SieveValue::Id(document_id.into()))
|
||||||
.with_key_value(
|
.with_key_value(
|
||||||
@@ -179,7 +184,6 @@ impl SieveScriptSet for Server {
|
|||||||
Err(err) => {
|
Err(err) => {
|
||||||
ctx.response.not_created.append(id, err);
|
ctx.response.not_created.append(id, err);
|
||||||
}
|
}
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ctx.response.not_created.append(
|
ctx.response.not_created.append(
|
||||||
@@ -226,16 +230,16 @@ impl SieveScriptSet for Server {
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
Ok((mut builder, blob)) => {
|
Ok(mut result) => {
|
||||||
// Prepare write batch
|
// Prepare write batch
|
||||||
batch
|
batch
|
||||||
.with_account_id(account_id)
|
.with_account_id(account_id)
|
||||||
.with_collection(Collection::SieveScript)
|
.with_collection(Collection::SieveScript)
|
||||||
.with_document(document_id);
|
.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
|
// Store blob
|
||||||
let sieve = &mut builder.changes_mut().unwrap();
|
let sieve = &mut result.builder.changes_mut().unwrap();
|
||||||
let (blob_hash, blob_hold) =
|
let (blob_hash, blob_hold) =
|
||||||
self.put_temporary_blob(account_id, &blob, 60).await?;
|
self.put_temporary_blob(account_id, &blob, 60).await?;
|
||||||
sieve.blob_hash = blob_hash;
|
sieve.blob_hash = blob_hash;
|
||||||
@@ -259,9 +263,18 @@ impl SieveScriptSet for Server {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set isActive if needed
|
||||||
|
if let Some(set_item) = result.set_item {
|
||||||
|
activations.push((document_id, set_item));
|
||||||
|
}
|
||||||
|
|
||||||
// Write record
|
// Write record
|
||||||
batch
|
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!())?
|
.caused_by(trc::location!())?
|
||||||
.commit_point();
|
.commit_point();
|
||||||
|
|
||||||
@@ -328,11 +341,25 @@ impl SieveScriptSet for Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate / deactivate scripts
|
// Non-standard script activation handling
|
||||||
let on_success_deactivate_script = request
|
let mut on_success_deactivate_script = request
|
||||||
.arguments
|
.arguments
|
||||||
.on_success_deactivate_script
|
.on_success_deactivate_script
|
||||||
.unwrap_or(false);
|
.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()
|
if ctx.response.not_created.is_empty()
|
||||||
&& ctx.response.not_updated.is_empty()
|
&& ctx.response.not_updated.is_empty()
|
||||||
&& ctx.response.not_destroyed.is_empty()
|
&& ctx.response.not_destroyed.is_empty()
|
||||||
@@ -375,15 +402,7 @@ impl SieveScriptSet for Server {
|
|||||||
update: Option<(u32, Archive<&'x ArchivedSieveScript>)>,
|
update: Option<(u32, Archive<&'x ArchivedSieveScript>)>,
|
||||||
ctx: &SetContext<'_>,
|
ctx: &SetContext<'_>,
|
||||||
session_id: u64,
|
session_id: u64,
|
||||||
) -> trc::Result<
|
) -> trc::Result<Result<SetItemResponse<'x>, SetError<SieveProperty>>> {
|
||||||
Result<
|
|
||||||
(
|
|
||||||
ObjectIndexBuilder<&'x ArchivedSieveScript, SieveScript>,
|
|
||||||
Option<Vec<u8>>,
|
|
||||||
),
|
|
||||||
SetError<SieveProperty>,
|
|
||||||
>,
|
|
||||||
> {
|
|
||||||
// Vacation script cannot be modified
|
// Vacation script cannot be modified
|
||||||
if update
|
if update
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -396,6 +415,7 @@ impl SieveScriptSet for Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse properties
|
// Parse properties
|
||||||
|
let mut set_item = None;
|
||||||
let mut changes = update
|
let mut changes = update
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|(_, obj)| obj.deserialize().unwrap_or_default())
|
.map(|(_, obj)| obj.deserialize().unwrap_or_default())
|
||||||
@@ -450,6 +470,10 @@ impl SieveScriptSet for Server {
|
|||||||
(Key::Property(SieveProperty::Name), Value::Null) => {
|
(Key::Property(SieveProperty::Name), Value::Null) => {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
(Key::Property(SieveProperty::IsActive), Value::Bool(value)) => {
|
||||||
|
set_item = Some(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Ok(Err(SetError::invalid_properties()
|
return Ok(Err(SetError::invalid_properties()
|
||||||
.with_property(property.into_owned())
|
.with_property(property.into_owned())
|
||||||
@@ -528,11 +552,18 @@ impl SieveScriptSet for Server {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Validate
|
// Validate
|
||||||
Ok(Ok((
|
Ok(Ok(SetItemResponse {
|
||||||
ObjectIndexBuilder::new()
|
builder: ObjectIndexBuilder::new()
|
||||||
.with_changes(changes)
|
.with_changes(changes)
|
||||||
.with_current_opt(update.map(|(_, current)| current)),
|
.with_current_opt(update.map(|(_, current)| current)),
|
||||||
blob_update,
|
blob_update,
|
||||||
)))
|
set_item,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct SetItemResponse<'x> {
|
||||||
|
builder: ObjectIndexBuilder<&'x ArchivedSieveScript, SieveScript>,
|
||||||
|
blob_update: Option<Vec<u8>>,
|
||||||
|
set_item: Option<bool>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -890,3 +890,98 @@ SEQUENCE:3
|
|||||||
END:VEVENT
|
END:VEVENT
|
||||||
END:VCALENDAR
|
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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user