CalDAV Scheduling (closes #1514)

This commit is contained in:
mdecimus
2025-06-23 18:32:19 +02:00
parent 367ddeeae4
commit 7087148d3d
69 changed files with 2684 additions and 881 deletions

58
Cargo.lock generated
View File

@@ -1263,7 +1263,7 @@ dependencies = [
[[package]]
name = "common"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"aes-gcm-siv",
"ahash",
@@ -1733,7 +1733,7 @@ checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]]
name = "dav"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"calcard",
"chrono",
@@ -1755,7 +1755,7 @@ dependencies = [
[[package]]
name = "dav-proto"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"calcard",
"chrono",
@@ -1948,7 +1948,7 @@ dependencies = [
[[package]]
name = "directory"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"ahash",
"argon2",
@@ -2226,7 +2226,7 @@ dependencies = [
[[package]]
name = "email"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"aes",
"aes-gcm",
@@ -2356,7 +2356,7 @@ dependencies = [
[[package]]
name = "event_macro"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"proc-macro2",
"quote",
@@ -2842,7 +2842,7 @@ dependencies = [
[[package]]
name = "groupware"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"ahash",
"calcard",
@@ -3142,7 +3142,7 @@ dependencies = [
[[package]]
name = "http"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"async-stream",
"base64 0.22.1",
@@ -3252,7 +3252,7 @@ dependencies = [
[[package]]
name = "http_proto"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"common",
"compact_str",
@@ -3554,7 +3554,7 @@ checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
[[package]]
name = "imap"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"ahash",
"common",
@@ -3581,7 +3581,7 @@ dependencies = [
[[package]]
name = "imap_proto"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"ahash",
"chrono",
@@ -3827,7 +3827,7 @@ dependencies = [
[[package]]
name = "jmap"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"aes-gcm",
"aes-gcm-siv",
@@ -3898,7 +3898,7 @@ dependencies = [
[[package]]
name = "jmap_proto"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"ahash",
"compact_str",
@@ -4372,7 +4372,7 @@ dependencies = [
[[package]]
name = "managesieve"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"ahash",
"common",
@@ -4474,7 +4474,7 @@ checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492"
[[package]]
name = "migration"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"base64 0.22.1",
"bincode 1.3.3",
@@ -4712,7 +4712,7 @@ dependencies = [
[[package]]
name = "nlp"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"ahash",
"bincode 1.3.3",
@@ -5440,7 +5440,7 @@ dependencies = [
[[package]]
name = "pop3"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"common",
"directory",
@@ -5607,7 +5607,7 @@ dependencies = [
[[package]]
name = "proc_macros"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"proc-macro2",
"quote",
@@ -7271,7 +7271,7 @@ dependencies = [
[[package]]
name = "services"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"aes-gcm",
"aes-gcm-siv",
@@ -7483,7 +7483,7 @@ checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
[[package]]
name = "smtp"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"ahash",
"blake3",
@@ -7574,7 +7574,7 @@ dependencies = [
[[package]]
name = "spam-filter"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"common",
"compact_str",
@@ -7638,14 +7638,14 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "stalwart"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"common",
"dav",
"directory",
"email",
"groupware",
"http 0.12.4",
"http 0.12.5",
"imap",
"jemallocator",
"jmap",
@@ -7664,7 +7664,7 @@ dependencies = [
[[package]]
name = "stalwart-cli"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"clap",
"console",
@@ -7695,7 +7695,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "store"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"ahash",
"arc-swap",
@@ -7904,7 +7904,7 @@ dependencies = [
[[package]]
name = "tests"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"ahash",
"async-trait",
@@ -7925,7 +7925,7 @@ dependencies = [
"form_urlencoded",
"futures",
"groupware",
"http 0.12.4",
"http 0.12.5",
"http-body-util",
"http_proto",
"hyper 1.6.0",
@@ -8451,7 +8451,7 @@ dependencies = [
[[package]]
name = "trc"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"ahash",
"base64 0.22.1",
@@ -8757,7 +8757,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "utils"
version = "0.12.4"
version = "0.12.5"
dependencies = [
"ahash",
"base64 0.22.1",

View File

@@ -5,7 +5,7 @@ authors = ["Stalwart Labs LLC <hello@stalw.art>"]
license = "AGPL-3.0-only OR LicenseRef-SEL"
repository = "https://github.com/stalwartlabs/cli"
homepage = "https://github.com/stalwartlabs/cli"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
readme = "README.md"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "common"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"
build = "build.rs"

View File

@@ -18,7 +18,7 @@ fn main() {
let generated_code = generate_locale_code(&locales);
fs::write(&dest_path, generated_code).expect("Failed to write generated locales");
fs::write(&dest_path, generated_code).expect("Failed to write generated locales.");
println!("cargo:rerun-if-changed={}", yaml_path.display());
}

View File

@@ -36,6 +36,8 @@ pub struct GroupwareConfig {
pub itip_outbound_max_recipients: usize,
pub itip_http_rsvp_url: Option<String>,
pub itip_http_rsvp_expiration: u64,
pub itip_inbox_auto_expunge: Option<u64>,
pub itip_template: Template<CalendarTemplateVariable>,
// Addressbook settings
pub max_vcard_size: usize,
@@ -55,13 +57,18 @@ pub enum CalendarTemplateVariable {
EventTitle,
EventDescription,
EventDetails,
Actions,
ActionUrl,
ActionName,
AttendeesTitle,
Attendees,
Key,
Color,
Changed,
Value,
LogoCid,
OldValue,
Rsvp,
}
impl GroupwareConfig {
@@ -126,7 +133,7 @@ impl GroupwareConfig {
.map(|s| s.to_string()),
alarms_template: Template::parse(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../resources/email-templates/calendar-alarm.html.min"
"/../../resources/html-templates/calendar-alarm.html.min"
)))
.expect("Failed to parse calendar template"),
itip_enabled: config
@@ -141,6 +148,13 @@ impl GroupwareConfig {
itip_outbound_max_recipients: config
.property("calendar.scheduling.outbound.max-recipients")
.unwrap_or(100),
itip_inbox_auto_expunge: config
.property_or_default::<Option<Duration>>(
"calendar.scheduling.inbox.auto-expunge",
"30d",
)
.map(|d| d.map(|d| d.as_secs()))
.unwrap_or(Some(30 * 24 * 60 * 60)),
itip_http_rsvp_url: if config
.property("calendar.scheduling.http-rsvp.enable")
.unwrap_or(true)
@@ -164,6 +178,11 @@ impl GroupwareConfig {
.property_or_default::<Duration>("calendar.scheduling.http-rsvp.expiration", "90d")
.map(|d| d.as_secs())
.unwrap_or(90 * 24 * 60 * 60),
itip_template: Template::parse(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../resources/html-templates/calendar-invite.html.min"
)))
.expect("Failed to parse calendar template"),
}
}
}
@@ -186,6 +205,11 @@ impl FromStr for CalendarTemplateVariable {
"key" => Ok(CalendarTemplateVariable::Key),
"value" => Ok(CalendarTemplateVariable::Value),
"logo_cid" => Ok(CalendarTemplateVariable::LogoCid),
"actions" => Ok(CalendarTemplateVariable::Actions),
"changed" => Ok(CalendarTemplateVariable::Changed),
"old_value" => Ok(CalendarTemplateVariable::OldValue),
"rsvp" => Ok(CalendarTemplateVariable::Rsvp),
"color" => Ok(CalendarTemplateVariable::Color),
_ => Err(format!("Unknown calendar template variable: {}", s)),
}
}

View File

@@ -38,7 +38,7 @@ pub struct JmapConfig {
pub mail_attachments_max_size: usize,
pub mail_parse_max_items: usize,
pub mail_max_size: usize,
pub mail_autoexpunge_after: Option<Duration>,
pub mail_autoexpunge_after: Option<u64>,
pub sieve_max_script_name: usize,
pub sieve_max_scripts: usize,
@@ -286,6 +286,7 @@ impl JmapConfig {
mail_parse_max_items: config.property("jmap.email.parse.max-items").unwrap_or(10),
mail_autoexpunge_after: config
.property_or_default::<Option<Duration>>("email.auto-expunge", "30d")
.map(|d| d.map(|d| d.as_secs()))
.unwrap_or_default(),
sieve_max_script_name: config
.property("sieve.untrusted.limits.name-length")

View File

@@ -210,7 +210,8 @@ impl Enterprise {
spam_filter_llm: SpamFilterLlmConfig::parse(config, &ai_apis),
ai_apis,
template_calendar_alarm: None,
template_calendar_invite: None,
template_scheduling_email: None,
template_scheduling_web: None,
};
// Parse templates
@@ -220,8 +221,12 @@ impl Enterprise {
&mut enterprise.template_calendar_alarm,
),
(
"calendar.scheduling.template",
&mut enterprise.template_calendar_invite,
"calendar.scheduling.template.email",
&mut enterprise.template_scheduling_email,
),
(
"calendar.scheduling.template.web",
&mut enterprise.template_scheduling_web,
),
] {
if let Some(template) = config.value(key) {

View File

@@ -42,7 +42,8 @@ pub struct Enterprise {
pub ai_apis: AHashMap<String, Arc<AiApiConfig>>,
pub spam_filter_llm: Option<SpamFilterLlmConfig>,
pub template_calendar_alarm: Option<Template<CalendarTemplateVariable>>,
pub template_calendar_invite: Option<Template<CalendarTemplateVariable>>,
pub template_scheduling_email: Option<Template<CalendarTemplateVariable>>,
pub template_scheduling_web: Option<Template<CalendarTemplateVariable>>,
}
#[derive(Debug, Clone)]

View File

@@ -109,6 +109,7 @@ pub const KV_SIEVE_ID: u8 = 26;
pub const IDX_UID: u8 = 0;
pub const IDX_EMAIL: u8 = 1;
pub const IDX_CREATED: u8 = 2;
#[derive(Clone)]
pub struct Server {

View File

@@ -1,6 +1,6 @@
[package]
name = "dav-proto"
version = "0.12.4"
version = "0.12.5"
edition = "2021"
[dependencies]

View File

@@ -1,6 +1,6 @@
[package]
name = "dav"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "directory"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "email"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -7,10 +7,10 @@
use super::metadata::MessageData;
use crate::{cache::MessageCacheFetch, mailbox::*, message::metadata::MessageMetadata};
use common::{KV_LOCK_PURGE_ACCOUNT, Server, storage::index::ObjectIndexBuilder};
use groupware::calendar::storage::ItipAutoExpunge;
use jmap_proto::types::collection::VanishedCollection;
use jmap_proto::types::{collection::Collection, property::Property};
use std::future::Future;
use std::time::Duration;
use store::rand::prelude::SliceRandom;
use store::write::key::DeserializeBigEndian;
use store::write::now;
@@ -38,7 +38,7 @@ pub trait EmailDeletion: Sync + Send {
fn emails_auto_expunge(
&self,
account_id: u32,
period: Duration,
hold_period: u64,
) -> impl Future<Output = trc::Result<()>> + Send;
fn emails_purge_tombstoned(
@@ -136,10 +136,20 @@ impl EmailDeletion for Server {
}
// Auto-expunge deleted and junk messages
if let Some(period) = self.core.jmap.mail_autoexpunge_after {
if let Err(err) = self.emails_auto_expunge(account_id, period).await {
if let Some(hold_period) = self.core.jmap.mail_autoexpunge_after {
if let Err(err) = self.emails_auto_expunge(account_id, hold_period).await {
trc::error!(
err.details("Failed to auto-expunge messages.")
err.details("Failed to auto-expunge e-mail messages.")
.account_id(account_id)
);
}
}
// Auto-expunge iMIP messages
if let Some(hold_period) = self.core.groupware.itip_inbox_auto_expunge {
if let Err(err) = self.itip_auto_expunge(account_id, hold_period).await {
trc::error!(
err.details("Failed to auto-expunge iTIP messages.")
.account_id(account_id)
);
}
@@ -173,7 +183,7 @@ impl EmailDeletion for Server {
}
}
async fn emails_auto_expunge(&self, account_id: u32, period: Duration) -> trc::Result<()> {
async fn emails_auto_expunge(&self, account_id: u32, hold_period: u64) -> trc::Result<()> {
let trashed_ids = RoaringBitmap::from_iter(
self.get_cached_messages(account_id)
.await
@@ -209,7 +219,7 @@ impl EmailDeletion for Server {
collection: Collection::Email.into(),
document_id: u32::MAX,
field: Property::ReceivedAt.into(),
key: now().saturating_sub(period.as_secs()).serialize(),
key: now().saturating_sub(hold_period).serialize(),
},
)
.no_values()
@@ -235,6 +245,7 @@ impl EmailDeletion for Server {
trc::event!(
Purge(trc::PurgeEvent::AutoExpunge),
Collection = Collection::Email.as_str(),
AccountId = account_id,
Total = destroy_ids.len(),
);

View File

@@ -1,6 +1,6 @@
[package]
name = "groupware"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -10,8 +10,8 @@ use super::{
ArchivedCalendar, ArchivedCalendarEvent, ArchivedCalendarPreferences, ArchivedDefaultAlert,
ArchivedTimezone, Calendar, CalendarEvent, CalendarPreferences, DefaultAlert, Timezone,
};
use common::IDX_UID;
use common::storage::index::{IndexValue, IndexableAndSerializableObject, IndexableObject};
use common::{IDX_CREATED, IDX_UID};
use jmap_proto::types::{collection::SyncCollection, value::AclGrant};
impl IndexableObject for Calendar {
@@ -119,6 +119,10 @@ impl IndexableObject for CalendarScheduling {
fn index_values(&self) -> impl Iterator<Item = IndexValue<'_>> {
[
IndexValue::Quota { used: self.size },
IndexValue::Index {
field: IDX_CREATED,
value: self.created.into(),
},
IndexValue::LogItem {
sync_collection: SyncCollection::CalendarScheduling.into(),
prefix: None,
@@ -134,6 +138,10 @@ impl IndexableObject for &ArchivedCalendarScheduling {
IndexValue::Quota {
used: self.size.to_native(),
},
IndexValue::Index {
field: IDX_CREATED,
value: self.created.to_native().into(),
},
IndexValue::LogItem {
sync_collection: SyncCollection::CalendarScheduling.into(),
prefix: None,

View File

@@ -10,20 +10,24 @@ use crate::{
calendar::{CalendarEvent, CalendarEventData, CalendarScheduling},
scheduling::{
ItipError, ItipMessage,
inbound::{MergeResult, itip_import_message, itip_merge_changes, itip_process_message},
inbound::{
MergeResult, itip_import_message, itip_merge_changes, itip_method, itip_process_message,
},
snapshot::itip_snapshot,
},
};
use calcard::{
common::timezone::Tz,
icalendar::{
ICalendar, ICalendarComponentType, ICalendarParameter, ICalendarParticipationStatus,
ICalendarProperty,
ICalendar, ICalendarComponentType, ICalendarMethod, ICalendarParameter,
ICalendarParticipationStatus, ICalendarProperty,
},
};
use common::{
DavName, IDX_EMAIL, IDX_UID, Server,
auth::{AccessToken, ResourceToken, oauth::GrantType},
config::groupware::CalendarTemplateVariable,
i18n,
};
use jmap_proto::types::collection::Collection;
use store::{
@@ -32,7 +36,7 @@ use store::{
write::{BatchBuilder, now},
};
use trc::AddContext;
use utils::url_params::UrlParams;
use utils::{template::Variables, url_params::UrlParams};
pub enum ItipIngestError {
Message(ItipError),
@@ -58,7 +62,11 @@ pub trait ItipIngest: Sync + Send {
attendee: &str,
) -> impl Future<Output = Option<ItipRsvpUrl>> + Send;
fn http_rsvp_handle(&self, query: &str) -> impl Future<Output = trc::Result<String>> + Send;
fn http_rsvp_handle(
&self,
query: &str,
language: &str,
) -> impl Future<Output = trc::Result<String>> + Send;
}
impl ItipIngest for Server {
@@ -222,6 +230,8 @@ impl ItipIngest for Server {
.is_empty()
{
return Err(ItipIngestError::Message(ItipError::AutoAddDisabled));
} else if itip_method(&itip)? != &ICalendarMethod::Request {
return Err(ItipIngestError::Message(ItipError::EventNotFound));
}
// Import the iTIP message
@@ -333,8 +343,8 @@ impl ItipIngest for Server {
}
}
async fn http_rsvp_handle(&self, query: &str) -> trc::Result<String> {
if let Some(rsvp) = decode_rsvp_response(self, query).await {
async fn http_rsvp_handle(&self, query: &str, language: &str) -> trc::Result<String> {
let response = if let Some(rsvp) = decode_rsvp_response(self, query).await {
if let Some(archive) = self
.get_archive(rsvp.account_id, Collection::CalendarEvent, rsvp.document_id)
.await
@@ -348,6 +358,8 @@ impl ItipIngest for Server {
.caused_by(trc::location!())?;
let mut did_change = false;
let mut summary = None;
let mut description = None;
let mut found_participant = false;
for component in &mut new_event.data.event.components {
if component.component_type.is_scheduling_object() {
@@ -380,6 +392,7 @@ impl ItipIngest for Server {
.params
.push(ICalendarParameter::Partstat(rsvp.partstat.clone()));
}
found_participant = true;
did_change = true;
} else if summary.is_none() && entry.name == ICalendarProperty::Summary
{
@@ -388,6 +401,14 @@ impl ItipIngest for Server {
.first()
.and_then(|v| v.as_text())
.map(|s| s.to_string());
} else if description.is_none()
&& entry.name == ICalendarProperty::Description
{
description = entry
.values
.first()
.and_then(|v| v.as_text())
.map(|s| s.to_string());
}
}
}
@@ -411,21 +432,24 @@ impl ItipIngest for Server {
.caused_by(trc::location!())?;
self.commit_batch(batch).await.caused_by(trc::location!())?;
}
let todo = "use templates";
Ok(format!(
"RSVP response recorded: {summary:?} {}",
rsvp.partstat.as_str()
))
} else {
Ok("No changes made to the event".to_string())
if found_participant {
Response::Success {
summary,
description,
}
} else {
Ok("Event not found".to_string())
Response::NoLongerParticipant
}
} else {
Ok("Invalid RSVP response".to_string())
Response::EventNotFound
}
} else {
Response::ParseError
};
Ok(render_response(self, response, language))
}
}
@@ -434,13 +458,11 @@ struct RsvpResponse {
document_id: u32,
attendee: String,
partstat: ICalendarParticipationStatus,
lang: String,
}
async fn decode_rsvp_response(server: &Server, query: &str) -> Option<RsvpResponse> {
let params = UrlParams::new(query.into());
let token = params.get("i")?;
let language = params.get("l").unwrap_or("en");
let method = params.get("m").and_then(|m| {
hashify::tiny_map_ignore_case!(m.as_bytes(),
"ACCEPTED" => ICalendarParticipationStatus::Accepted,
@@ -470,14 +492,117 @@ async fn decode_rsvp_response(server: &Server, query: &str) -> Option<RsvpRespon
document_id,
attendee,
partstat: method,
lang: language.to_string(),
}
.into()
}
enum Response {
Success {
summary: Option<String>,
description: Option<String>,
},
EventNotFound,
ParseError,
NoLongerParticipant,
}
fn render_response(server: &Server, response: Response, language: &str) -> String {
#[cfg(feature = "enterprise")]
let template = server
.core
.enterprise
.as_ref()
.and_then(|e| e.template_scheduling_web.as_ref())
.unwrap_or(&server.core.groupware.itip_template);
#[cfg(not(feature = "enterprise"))]
let template = &server.core.groupware.itip_template;
let locale = i18n::locale_or_default(language);
let mut variables = Variables::new();
match response {
Response::Success {
summary,
description,
} => {
variables.insert_single(
CalendarTemplateVariable::PageTitle,
locale.calendar_rsvp_recorded.to_string(),
);
variables.insert_single(
CalendarTemplateVariable::Header,
locale.calendar_rsvp_recorded.to_string(),
);
variables.insert_block(
CalendarTemplateVariable::EventDetails,
[
summary.map(|summary| {
[
(
CalendarTemplateVariable::Key,
locale.calendar_summary.to_string(),
),
(CalendarTemplateVariable::Value, summary),
]
}),
description.map(|description| {
[
(
CalendarTemplateVariable::Key,
locale.calendar_description.to_string(),
),
(CalendarTemplateVariable::Value, description),
]
}),
]
.into_iter()
.flatten(),
);
variables.insert_single(CalendarTemplateVariable::Color, "info".to_string());
}
Response::EventNotFound => {
variables.insert_single(
CalendarTemplateVariable::PageTitle,
locale.calendar_rsvp_failed.to_string(),
);
variables.insert_single(
CalendarTemplateVariable::Header,
locale.calendar_event_not_found.to_string(),
);
variables.insert_single(CalendarTemplateVariable::Color, "danger".to_string());
}
Response::ParseError => {
variables.insert_single(
CalendarTemplateVariable::PageTitle,
locale.calendar_rsvp_failed.to_string(),
);
variables.insert_single(
CalendarTemplateVariable::Header,
locale.calendar_invalid_rsvp.to_string(),
);
variables.insert_single(CalendarTemplateVariable::Color, "danger".to_string());
}
Response::NoLongerParticipant => {
variables.insert_single(
CalendarTemplateVariable::PageTitle,
locale.calendar_rsvp_failed.to_string(),
);
variables.insert_single(
CalendarTemplateVariable::Header,
locale.calendar_not_participant.to_string(),
);
variables.insert_single(CalendarTemplateVariable::Color, "warning".to_string());
}
}
variables.insert_single(CalendarTemplateVariable::LogoCid, "/logo.svg".to_string());
template.eval(&variables)
}
impl ItipRsvpUrl {
pub fn url(&self, partstat: &ICalendarParticipationStatus, language: &str) -> String {
format!("{}&m={}&l={}", self.0, partstat.as_str(), language)
pub fn url(&self, partstat: &ICalendarParticipationStatus) -> String {
format!("{}&m={}", self.0, partstat.as_str())
}
}

View File

@@ -10,11 +10,16 @@ use crate::{
scheduling::{ItipMessages, event_cancel::itip_cancel},
};
use calcard::common::timezone::Tz;
use common::{Server, auth::AccessToken, storage::index::ObjectIndexBuilder};
use common::{IDX_CREATED, Server, auth::AccessToken, storage::index::ObjectIndexBuilder};
use jmap_proto::types::collection::{Collection, VanishedCollection};
use store::{
U16_LEN, U64_LEN,
write::{Archive, BatchBuilder, TaskQueueClass, ValueClass, key::KeySerializer, now},
IndexKey, IterateParams, SerializeInfallible, U16_LEN, U32_LEN, U64_LEN,
roaring::RoaringBitmap,
write::{
Archive, BatchBuilder, TaskQueueClass, ValueClass,
key::{DeserializeBigEndian, KeySerializer},
now,
},
};
use trc::AddContext;
@@ -23,6 +28,90 @@ use super::{
alarm::CalendarAlarm,
};
pub trait ItipAutoExpunge: Sync + Send {
fn itip_auto_expunge(
&self,
account_id: u32,
hold_period: u64,
) -> impl Future<Output = trc::Result<()>> + Send;
}
impl ItipAutoExpunge for Server {
async fn itip_auto_expunge(&self, account_id: u32, hold_period: u64) -> trc::Result<()> {
// Filter messages by received date
let mut destroy_ids = RoaringBitmap::new();
self.store()
.iterate(
IterateParams::new(
IndexKey {
account_id,
collection: Collection::CalendarScheduling.into(),
document_id: 0,
field: IDX_CREATED,
key: 0u64.serialize(),
},
IndexKey {
account_id,
collection: Collection::CalendarScheduling.into(),
document_id: u32::MAX,
field: IDX_CREATED,
key: now().saturating_sub(hold_period).serialize(),
},
)
.no_values()
.ascending(),
|key, _| {
destroy_ids.insert(
key.deserialize_be_u32(key.len() - U32_LEN)
.caused_by(trc::location!())?,
);
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
if destroy_ids.is_empty() {
return Ok(());
}
trc::event!(
Purge(trc::PurgeEvent::AutoExpunge),
AccountId = account_id,
Collection = Collection::CalendarScheduling.as_str(),
Total = destroy_ids.len(),
);
// Tombstone messages
let mut batch = BatchBuilder::new();
let access_token = self
.get_access_token(account_id)
.await
.caused_by(trc::location!())?;
for document_id in destroy_ids {
// Fetch event
if let Some(event_) = self
.get_archive(account_id, Collection::CalendarScheduling, document_id)
.await
.caused_by(trc::location!())?
{
let event = event_
.to_unarchived::<CalendarScheduling>()
.caused_by(trc::location!())?;
DestroyArchive(event)
.delete(&access_token, account_id, document_id, &mut batch)
.caused_by(trc::location!())?;
}
}
self.commit_batch(batch).await.caused_by(trc::location!())?;
Ok(())
}
}
impl CalendarEvent {
pub fn update<'x>(
self,

View File

@@ -6,6 +6,7 @@
use crate::scheduling::{
Email, InstanceId, ItipEntryValue, ItipError, ItipMessage, ItipSnapshot, ItipSnapshots,
ItipSummary,
itip::{
ItipExportAs, can_attendee_modify_property, itip_add_tz, itip_build_envelope,
itip_export_component,
@@ -37,6 +38,7 @@ pub(crate) fn attendee_handle_update(
let mut mail_from = None;
let mut email_rcpt = AHashSet::new();
let mut new_delegates = AHashSet::new();
let mut part_stat = &ICalendarParticipationStatus::NeedsAction;
for (instance_id, instance) in &new_itip.components {
if let Some(old_instance) = old_itip.components.get(instance_id) {
@@ -62,6 +64,7 @@ pub(crate) fn attendee_handle_update(
cancel_comp
.entries
.push(date.to_entry(ICalendarProperty::RecurrenceId));
part_stat = &ICalendarParticipationStatus::Declined;
// Add cancel component
let comp_id = message.components.len() as u16;
@@ -101,6 +104,9 @@ pub(crate) fn attendee_handle_update(
|| send_update)
{
// Build the attendee list
if let Some(new_partstat) = local_attendee.part_stat {
part_stat = new_partstat;
}
let mut attendee_entry_uids = vec![local_attendee.entry_id];
let old_delegates = old_instance
.external_attendees()
@@ -222,11 +228,15 @@ pub(crate) fn attendee_handle_update(
itip_add_tz(&mut message, new_ical);
let mut responses = vec![ItipMessage {
method: ICalendarMethod::Reply,
from: from.to_string(),
from_organizer: false,
to: email_rcpt.into_iter().map(|e| e.to_string()).collect(),
changed_properties: vec![],
summary: ItipSummary::Rsvp {
part_stat: part_stat.clone(),
current: new_itip
.main_instance_or_default()
.build_summary(Some(&new_itip.organizer), &[]),
},
message,
}];

View File

@@ -5,7 +5,7 @@
*/
use crate::scheduling::{
InstanceId, ItipError, ItipMessage, ItipSnapshots,
InstanceId, ItipError, ItipMessage, ItipSnapshots, ItipSummary,
attendee::attendee_decline,
itip::{itip_add_tz, itip_build_envelope},
snapshot::itip_snapshot,
@@ -14,8 +14,8 @@ use ahash::AHashSet;
use calcard::{
common::PartialDateTime,
icalendar::{
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarMethod, ICalendarProperty,
ICalendarStatus, ICalendarValue,
ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarMethod,
ICalendarParticipationStatus, ICalendarProperty, ICalendarStatus, ICalendarValue,
},
};
use std::fmt::Display;
@@ -67,11 +67,12 @@ pub fn itip_cancel(
));
Ok(ItipMessage {
method: ICalendarMethod::Cancel,
to: recipients.into_iter().collect(),
summary: ItipSummary::Cancel(
itip.main_instance_or_default().build_summary(None, &[]),
),
from: itip.organizer.email.email,
from_organizer: true,
to: recipients.into_iter().collect(),
changed_properties: vec![],
message,
})
} else {
@@ -105,11 +106,13 @@ pub fn itip_cancel(
email_rcpt.insert(&itip.organizer.email.email);
Ok(ItipMessage {
method: ICalendarMethod::Reply,
from: from.to_string(),
from_organizer: false,
to: email_rcpt.into_iter().map(|e| e.to_string()).collect(),
changed_properties: vec![],
summary: ItipSummary::Rsvp {
part_stat: ICalendarParticipationStatus::Declined,
current: itip.main_instance_or_default().build_summary(None, &[]),
},
message,
})
} else {

View File

@@ -599,7 +599,7 @@ pub fn itip_merge_changes(ical: &mut ICalendar, changes: Vec<MergeAction>) {
}
}
fn itip_method(ical: &ICalendar) -> Result<&ICalendarMethod, ItipError> {
pub fn itip_method(ical: &ICalendar) -> Result<&ICalendarMethod, ItipError> {
ical.components
.first()
.and_then(|comp| {

View File

@@ -18,7 +18,7 @@ use store::{
};
use trc::AddContext;
use crate::scheduling::{ItipMessage, ItipMessages};
use crate::scheduling::{ArchivedItipSummary, ItipMessage, ItipMessages};
pub(crate) fn itip_build_envelope(method: ICalendarMethod) -> ICalendarComponent {
ICalendarComponent {
@@ -285,12 +285,22 @@ impl ItipMessages {
impl From<ItipMessage<ICalendar>> for ItipMessage<String> {
fn from(message: ItipMessage<ICalendar>) -> Self {
ItipMessage {
method: message.method,
from: message.from,
from_organizer: message.from_organizer,
to: message.to,
changed_properties: message.changed_properties,
summary: message.summary,
message: message.message.to_string(),
}
}
}
impl ArchivedItipSummary {
pub fn method(&self) -> &str {
match self {
ArchivedItipSummary::Invite(_) => ICalendarMethod::Request.as_str(),
ArchivedItipSummary::Update { method, .. } => method.as_str(),
ArchivedItipSummary::Cancel(_) => ICalendarMethod::Cancel.as_str(),
ArchivedItipSummary::Rsvp { .. } => ICalendarMethod::Reply.as_str(),
}
}
}

View File

@@ -65,6 +65,7 @@ pub enum ItipEntryValue<'x> {
pub struct ItipDateTime<'x> {
pub date: &'x PartialDateTime,
pub tz_id: Option<&'x str>,
pub tz_code: u16,
pub timestamp: i64,
}
@@ -85,6 +86,7 @@ pub struct RecurrenceId {
pub struct Attendee<'x> {
pub entry_id: u16,
pub email: Email,
pub name: Option<&'x str>,
pub part_stat: Option<&'x ICalendarParticipationStatus>,
pub delegated_from: Vec<Email>,
pub delegated_to: Vec<Email>,
@@ -100,6 +102,7 @@ pub struct Attendee<'x> {
pub struct Organizer<'x> {
pub entry_id: u16,
pub email: Email,
pub name: Option<&'x str>,
pub is_server_scheduling: bool,
pub force_send: Option<&'x ICalendarScheduleForceSendValue>,
}
@@ -144,14 +147,55 @@ pub enum ItipError {
#[derive(Debug, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
pub struct ItipMessage<T> {
pub method: ICalendarMethod,
pub from: String,
pub from_organizer: bool,
pub to: Vec<String>,
pub changed_properties: Vec<ICalendarProperty>,
pub summary: ItipSummary,
pub message: T,
}
#[derive(Debug, Clone, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
pub enum ItipSummary {
Invite(Vec<ItipField>),
Update {
method: ICalendarMethod,
current: Vec<ItipField>,
previous: Vec<ItipField>,
},
Cancel(Vec<ItipField>),
Rsvp {
part_stat: ICalendarParticipationStatus,
current: Vec<ItipField>,
},
}
#[derive(Debug, Clone, Hash, PartialEq, Eq, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
pub struct ItipField {
pub name: ICalendarProperty,
pub value: ItipValue,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
pub enum ItipValue {
Text(String),
Time(ItipTime),
Rrule(Box<ICalendarRecurrenceRule>),
Participants(Vec<ItipParticipant>),
}
#[derive(Debug, Clone, Hash, PartialEq, Eq, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
pub struct ItipTime {
pub start: i64,
pub tz_id: u16,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq, rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
pub struct ItipParticipant {
pub email: String,
pub name: Option<String>,
pub is_organizer: bool,
}
#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize)]
pub struct ItipMessages {
pub messages: Vec<ItipMessage<String>>,

View File

@@ -5,7 +5,7 @@
*/
use crate::scheduling::{
InstanceId, ItipError, ItipMessage, ItipSnapshots,
InstanceId, ItipError, ItipMessage, ItipSnapshots, ItipSummary,
event_cancel::build_cancel_component,
itip::{ItipExportAs, itip_add_tz, itip_build_envelope, itip_export_component},
};
@@ -169,9 +169,16 @@ pub(crate) fn organizer_handle_update(
}
}
// Build summary of changed properties
let new_summary = new_itip
.main_instance_or_default()
.build_summary(Some(&new_itip.organizer), &[]);
let old_summary = old_itip
.main_instance_or_default()
.build_summary(Some(&old_itip.organizer), &new_summary);
// Prepare full updates
let mut messages = Vec::new();
let changed_properties = changed_properties.into_iter().cloned().collect::<Vec<_>>();
if !send_full_update.is_empty() {
match organizer_request_full(
new_ical,
@@ -181,7 +188,11 @@ pub(crate) fn organizer_handle_update(
) {
Ok(messages_) => {
for mut message in messages_ {
message.changed_properties = changed_properties.clone();
message.summary = ItipSummary::Update {
method: ICalendarMethod::Request,
current: new_summary.clone(),
previous: old_summary.clone(),
};
messages.push(message);
}
}
@@ -279,14 +290,26 @@ pub(crate) fn organizer_handle_update(
itip_add_tz(&mut message, ical);
messages.push(ItipMessage {
method: method.clone(),
from: itip.organizer.email.email.clone(),
from_organizer: true,
to: emails.into_iter().map(|e| e.to_string()).collect(),
changed_properties: if method == &ICalendarMethod::Request {
changed_properties.clone()
summary: if method == &ICalendarMethod::Cancel {
ItipSummary::Cancel(
new_summary
.iter()
.chain(old_summary.iter())
.map(|summary| (&summary.name, summary))
.collect::<AHashMap<_, _>>()
.into_values()
.cloned()
.collect(),
)
} else {
vec![]
ItipSummary::Update {
method: method.clone(),
current: new_summary.clone(),
previous: old_summary.clone(),
}
},
message,
});
@@ -375,11 +398,13 @@ pub(crate) fn organizer_request_full(
if !recipients.is_empty() {
Ok(vec![ItipMessage {
method: ICalendarMethod::Request,
from: itip.organizer.email.email.clone(),
from_organizer: true,
to: recipients.into_iter().map(|e| e.to_string()).collect(),
changed_properties: vec![],
summary: ItipSummary::Invite(
itip.main_instance_or_default()
.build_summary(Some(&itip.organizer), &[]),
),
message,
}])
} else {

View File

@@ -5,8 +5,8 @@
*/
use crate::scheduling::{
Attendee, Email, InstanceId, ItipDateTime, ItipEntry, ItipEntryValue, ItipError, ItipSnapshot,
ItipSnapshots, Organizer, RecurrenceId,
Attendee, Email, InstanceId, ItipDateTime, ItipEntry, ItipEntryValue, ItipError, ItipField,
ItipParticipant, ItipSnapshot, ItipSnapshots, ItipTime, ItipValue, Organizer, RecurrenceId,
};
use ahash::AHashMap;
use calcard::icalendar::{
@@ -72,6 +72,7 @@ pub fn itip_snapshot<'x, 'y>(
entry_id: entry_id as u16,
email,
is_server_scheduling: true,
name: None,
force_send: None,
};
has_local_emails |= part.email.is_local;
@@ -85,6 +86,9 @@ pub fn itip_snapshot<'x, 'y>(
ICalendarParameter::ScheduleForceSend(force_send) => {
part.force_send = Some(force_send);
}
ICalendarParameter::Cn(name) => {
part.name = Some(name.as_str());
}
_ => {}
}
}
@@ -116,6 +120,7 @@ pub fn itip_snapshot<'x, 'y>(
let mut part = Attendee {
entry_id: entry_id as u16,
email,
name: None,
rsvp: None,
is_server_scheduling: true,
force_send: None,
@@ -163,6 +168,9 @@ pub fn itip_snapshot<'x, 'y>(
ICalendarParameter::SentBy(value) => {
part.sent_by = Email::from_uri(value, account_emails);
}
ICalendarParameter::Cn(name) => {
part.name = Some(name.as_str());
}
_ => {}
}
}
@@ -260,15 +268,15 @@ pub fn itip_snapshot<'x, 'y>(
ItipEntryValue::Text(v.as_str())
}
ICalendarValue::PartialDateTime(date) => {
let tz = tz_resolver
.get_or_insert_with(|| ical.build_tz_resolver())
.resolve(tz_id);
ItipEntryValue::DateTime(ItipDateTime {
date: date.as_ref(),
tz_id,
tz_code: tz.as_id(),
timestamp: date
.to_date_time_with_tz(
tz_resolver
.get_or_insert_with(|| ical.build_tz_resolver())
.resolve(tz_id),
)
.to_date_time_with_tz(tz)
.map(|dt| dt.timestamp())
.unwrap_or_else(|| {
date.to_timestamp().unwrap_or_default()
@@ -320,6 +328,15 @@ impl ItipSnapshots<'_> {
.any(|attendee| attendee.email.email == email)
})
}
pub fn main_instance(&self) -> Option<&ItipSnapshot<'_>> {
self.components.get(&InstanceId::Main)
}
pub fn main_instance_or_default(&self) -> &ItipSnapshot<'_> {
self.main_instance()
.unwrap_or_else(|| self.components.values().next().unwrap())
}
}
impl ItipSnapshot<'_> {
@@ -344,4 +361,85 @@ impl ItipSnapshot<'_> {
.iter()
.find(|attendee| attendee.email.email == email)
}
pub fn build_summary(
&self,
include_guests: Option<&Organizer<'_>>,
skip_fields: &[ItipField],
) -> Vec<ItipField> {
let mut fields = Vec::with_capacity(5);
for entry in &self.entries {
if matches!(
entry.name,
ICalendarProperty::Summary
| ICalendarProperty::Description
| ICalendarProperty::Dtstart
| ICalendarProperty::Location
| ICalendarProperty::Rrule
) {
let value = match &entry.value {
ItipEntryValue::DateTime(dt) => ItipValue::Time(ItipTime {
start: dt.timestamp,
tz_id: dt.tz_code,
}),
ItipEntryValue::RRule(rule) => ItipValue::Rrule(Box::new((*rule).clone())),
ItipEntryValue::Text(value) => ItipValue::Text(value.to_string()),
_ => continue,
};
let field = ItipField {
name: entry.name.clone(),
value,
};
if !skip_fields.contains(&field) {
fields.push(field);
}
}
}
if let Some(organizer) = include_guests {
let mut attendees = Vec::with_capacity(self.attendees.len());
for attendee in &self.attendees {
if attendee.email.email != organizer.email.email {
attendees.push(ItipParticipant {
email: attendee.email.email.to_string(),
name: attendee.name.map(|n| n.to_string()),
is_organizer: false,
});
}
}
attendees.push(ItipParticipant {
email: organizer.email.email.to_string(),
name: organizer.name.map(|n| n.to_string()),
is_organizer: true,
});
attendees.sort_by(|a, b| {
if a.is_organizer && !b.is_organizer {
std::cmp::Ordering::Less
} else if !a.is_organizer && b.is_organizer {
std::cmp::Ordering::Greater
} else if let (Some(a_name), Some(b_name)) = (a.name.as_deref(), b.name.as_deref())
{
match a_name.cmp(b_name) {
std::cmp::Ordering::Equal => a.email.cmp(&b.email),
ord => ord,
}
} else {
a.email.cmp(&b.email)
}
});
let field = ItipField {
name: ICalendarProperty::Attendee,
value: ItipValue::Participants(attendees),
};
if !skip_fields.contains(&field) {
fields.push(field);
}
}
fields
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "http_proto"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "http"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -488,7 +488,17 @@ impl ParseHttp for Server {
&& path.next().unwrap_or_default() == "rsvp"
{
return self
.http_rsvp_handle(req.uri().query().unwrap_or_default())
.http_rsvp_handle(
req.uri().query().unwrap_or_default(),
req.headers()
.get(header::ACCEPT_LANGUAGE)
.and_then(|v| v.to_str().ok())
.map(|lang| {
let lang = lang.split_once(',').map_or(lang, |(l, _)| l);
lang.split_once(';').map_or(lang, |(l, _)| l)
})
.unwrap_or("en"),
)
.await
.map(|response| {
HtmlResponse::new(response)

View File

@@ -1,6 +1,6 @@
[package]
name = "imap_proto"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "imap"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "jmap_proto"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "jmap"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -7,7 +7,7 @@ homepage = "https://stalw.art"
keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"]
categories = ["email"]
license = "AGPL-3.0-only OR LicenseRef-SEL"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "managesieve"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "migration"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "nlp"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "pop3"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "services"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -12,12 +12,13 @@ use calcard::{
use chrono::{DateTime, Locale};
use common::{
DEFAULT_LOGO, Server,
auth::AccessToken,
config::groupware::CalendarTemplateVariable,
i18n,
listener::{ServerInstance, stream::NullIo},
};
use directory::Permission;
use groupware::calendar::{CalendarEvent, alarm::CalendarAlarm};
use groupware::calendar::{ArchivedCalendarEvent, CalendarEvent, alarm::CalendarAlarm};
use jmap_proto::types::collection::Collection;
use mail_builder::{
MessageBuilder,
@@ -113,231 +114,16 @@ async fn send_alarm(
let event = event_
.unarchive::<CalendarEvent>()
.caused_by(trc::location!())?;
let (Some(event_component), Some(alarm_component)) = (
event.data.event.components.get(alarm.event_id as usize),
event.data.event.components.get(alarm.alarm_id as usize),
) else {
trc::event!(
TaskQueue(TaskQueueEvent::MetadataNotFound),
Details = "Calendar Alarm component not found",
AccountId = task.account_id,
DocumentId = task.document_id,
);
return Ok(true);
};
// Build webcal URI
let webcal_uri = match event.webcal_uri(server, &access_token).await {
Ok(uri) => uri,
Err(err) => {
trc::error!(
err.account_id(task.account_id)
.document_id(task.document_id)
.caused_by(trc::location!())
.details("Failed to generate webcal URI")
);
String::from("#")
}
};
// Obtain alarm details
let mut summary = None;
let mut description = None;
let mut rcpt_to = None;
let mut location = None;
let mut organizer = None;
let mut guests = vec![];
for entry in alarm_component.entries.iter() {
match &entry.name {
ArchivedICalendarProperty::Summary => {
summary = entry.values.first().and_then(|v| v.as_text());
}
ArchivedICalendarProperty::Description => {
description = entry.values.first().and_then(|v| v.as_text());
}
ArchivedICalendarProperty::Attendee => {
rcpt_to = entry
.values
.first()
.and_then(|v| v.as_text())
.map(|v| v.strip_prefix("mailto:").unwrap_or(v))
.and_then(sanitize_email);
}
_ => {}
}
}
for entry in event_component.entries.iter() {
match &entry.name {
ArchivedICalendarProperty::Summary if summary.is_none() => {
summary = entry.values.first().and_then(|v| v.as_text());
}
ArchivedICalendarProperty::Description if description.is_none() => {
description = entry.values.first().and_then(|v| v.as_text());
}
ArchivedICalendarProperty::Location => {
location = entry.values.first().and_then(|v| v.as_text());
}
ArchivedICalendarProperty::Organizer | ArchivedICalendarProperty::Attendee => {
let email = entry
.values
.first()
.and_then(|v| v.as_text())
.map(|v| v.strip_prefix("mailto:").unwrap_or(v));
let name = entry.params.iter().find_map(|param| {
if let ArchivedICalendarParameter::Cn(name) = param {
Some(name.as_str())
} else {
None
}
});
if email.is_some() || name.is_some() {
if matches!(entry.name, ArchivedICalendarProperty::Organizer) {
organizer = Some((email, name));
} else {
guests.push((email, name));
}
}
}
_ => {}
}
}
// Validate recipient
let account_main_email = access_token.emails.first().unwrap();
let account_main_domain = account_main_email.rsplit('@').next().unwrap_or("localhost");
let rcpt_to = if let Some(rcpt_to) = rcpt_to {
if server.core.groupware.alarms_allow_external_recipients
|| access_token.emails.iter().any(|email| email == &rcpt_to)
{
rcpt_to
} else {
trc::event!(
Calendar(trc::CalendarEvent::AlarmRecipientOverride),
Reason = "External recipient not allowed for calendar alarms",
Details = rcpt_to,
AccountId = task.account_id,
DocumentId = task.document_id,
);
account_main_email.to_string()
}
} else {
account_main_email.to_string()
};
// Build message body
#[cfg(feature = "enterprise")]
let template = server
.core
.enterprise
.as_ref()
.and_then(|e| e.template_calendar_alarm.as_ref())
.unwrap_or(&server.core.groupware.alarms_template);
#[cfg(not(feature = "enterprise"))]
let template = &server.core.groupware.alarms_template;
let locale = i18n::locale_or_default(access_token.locale.as_deref().unwrap_or("en"));
let chrono_locale = access_token
.locale
.as_deref()
.and_then(|locale| Locale::from_str(locale).ok())
.unwrap_or(Locale::en_US);
let start = format!(
"{} ({})",
DateTime::from_timestamp(alarm.event_start, 0)
.unwrap_or_default()
.format_localized(locale.calendar_date_template, chrono_locale),
Tz::from_id(alarm.event_start_tz).unwrap_or(Tz::UTC).name()
);
let end = format!(
"{} ({})",
DateTime::from_timestamp(alarm.event_end, 0)
.unwrap_or_default()
.format_localized(locale.calendar_date_template, chrono_locale),
Tz::from_id(alarm.event_end_tz).unwrap_or(Tz::UTC).name()
);
let subject = format!(
"{}: {} @ {}",
locale.calendar_alarm_subject_prefix,
summary.or(description).unwrap_or("No Subject"),
start
);
let organizer = organizer
.map(|(email, name)| match (email, name) {
(Some(email), Some(name)) => format!("{} <{}>", name, email),
(Some(email), None) => email.to_string(),
(None, Some(name)) => name.to_string(),
_ => unreachable!(),
})
.unwrap_or_else(|| access_token.name.clone());
let account_main_email = access_token.emails.first().unwrap();
let account_main_domain = account_main_email.rsplit('@').next().unwrap_or("localhost");
let logo_cid = format!("logo.{}@{account_main_domain}", now());
let mut variables = Variables::new();
variables.insert_single(CalendarTemplateVariable::PageTitle, subject.as_str());
variables.insert_single(
CalendarTemplateVariable::Header,
locale.calendar_alarm_header,
);
variables.insert_single(
CalendarTemplateVariable::Footer,
locale.calendar_alarm_footer,
);
variables.insert_single(
CalendarTemplateVariable::ActionName,
locale.calendar_alarm_open,
);
variables.insert_single(CalendarTemplateVariable::ActionUrl, webcal_uri.as_str());
variables.insert_single(
CalendarTemplateVariable::AttendeesTitle,
locale.calendar_attendees,
);
variables.insert_single(
CalendarTemplateVariable::EventTitle,
summary.unwrap_or_default(),
);
variables.insert_single(CalendarTemplateVariable::LogoCid, logo_cid.as_str());
if let Some(description) = description {
variables.insert_single(CalendarTemplateVariable::EventDescription, description);
}
variables.insert_block(
CalendarTemplateVariable::EventDetails,
[
Some([
(CalendarTemplateVariable::Key, locale.calendar_start),
(CalendarTemplateVariable::Value, start.as_str()),
]),
Some([
(CalendarTemplateVariable::Key, locale.calendar_end),
(CalendarTemplateVariable::Value, end.as_str()),
]),
location.map(|location| {
[
(CalendarTemplateVariable::Key, locale.calendar_location),
(CalendarTemplateVariable::Value, location),
]
}),
Some([
(CalendarTemplateVariable::Key, locale.calendar_organizer),
(CalendarTemplateVariable::Value, organizer.as_str()),
]),
]
.into_iter()
.flatten(),
);
if !guests.is_empty() {
variables.insert_block(
CalendarTemplateVariable::Attendees,
guests.into_iter().map(|(email, name)| {
[
(CalendarTemplateVariable::Key, name.unwrap_or_default()),
(CalendarTemplateVariable::Value, email.unwrap_or_default()),
]
}),
);
}
let html_body = template.eval(&variables);
let txt_body = html_to_text(&html_body);
let Some(tpl) = build_template(server, &access_token, task, alarm, event, &logo_cid).await?
else {
return Ok(true);
};
let txt_body = html_to_text(&tpl.body);
// Obtain logo image
let logo = match server.logo_resource(account_main_domain).await {
@@ -360,20 +146,17 @@ async fn send_alarm(
let mail_from = if let Some(from_email) = &server.core.groupware.alarms_from_email {
from_email.to_string()
} else {
format!("calendar-notification@{account_main_domain}",)
format!("calendar-notification@{account_main_domain}")
};
let message = MessageBuilder::new()
.from((
server.core.groupware.alarms_from_name.as_str(),
mail_from.as_str(),
))
.header("To", HeaderType::Text(rcpt_to.as_str().into()))
.header("To", HeaderType::Text(tpl.to.as_str().into()))
.header("Auto-Submitted", HeaderType::Text("auto-generated".into()))
.header(
"Reply-To",
HeaderType::Text(account_main_email.as_str().into()),
)
.subject(subject)
.header("Reply-To", HeaderType::Text(account_main_email.into()))
.subject(tpl.subject)
.body(MimePart::new(
ContentType::new("multipart/mixed"),
BodyPart::Multipart(vec![
@@ -386,7 +169,7 @@ async fn send_alarm(
),
MimePart::new(
ContentType::new("text/html"),
BodyPart::Text(html_body.into()),
BodyPart::Text(tpl.body.into()),
),
]),
),
@@ -395,7 +178,7 @@ async fn send_alarm(
BodyPart::Binary(logo_contents.into()),
)
.inline()
.cid(logo_cid),
.cid(&logo_cid),
]),
))
.write_to_vec()
@@ -403,7 +186,8 @@ async fn send_alarm(
// Send message
let server_ = server.clone();
let mail_from = account_main_email.clone();
let mail_from = account_main_email.to_string();
let to = tpl.to;
let result = tokio::spawn(async move {
let mut session = Session::<NullIo>::local(
server_,
@@ -426,7 +210,7 @@ async fn send_alarm(
session.params.rcpt_errors_wait = Duration::from_secs(0);
let _ = session
.handle_rcpt_to(RcptTo {
address: rcpt_to,
address: to,
..Default::default()
})
.await;
@@ -515,3 +299,243 @@ async fn send_alarm(
Ok(true)
}
struct Details {
to: String,
subject: String,
body: String,
}
async fn build_template(
server: &Server,
access_token: &AccessToken,
task: &Task,
alarm: &CalendarAlarm,
event: &ArchivedCalendarEvent,
logo_cid: &str,
) -> trc::Result<Option<Details>> {
let (Some(event_component), Some(alarm_component)) = (
event.data.event.components.get(alarm.event_id as usize),
event.data.event.components.get(alarm.alarm_id as usize),
) else {
trc::event!(
TaskQueue(TaskQueueEvent::MetadataNotFound),
Details = "Calendar Alarm component not found",
AccountId = task.account_id,
DocumentId = task.document_id,
);
return Ok(None);
};
// Build webcal URI
let webcal_uri = match event.webcal_uri(server, access_token).await {
Ok(uri) => uri,
Err(err) => {
trc::error!(
err.account_id(task.account_id)
.document_id(task.document_id)
.caused_by(trc::location!())
.details("Failed to generate webcal URI")
);
String::from("#")
}
};
// Obtain alarm details
let mut summary = None;
let mut description = None;
let mut rcpt_to = None;
let mut location = None;
let mut organizer = None;
let mut guests = vec![];
for entry in alarm_component.entries.iter() {
match &entry.name {
ArchivedICalendarProperty::Summary => {
summary = entry.values.first().and_then(|v| v.as_text());
}
ArchivedICalendarProperty::Description => {
description = entry.values.first().and_then(|v| v.as_text());
}
ArchivedICalendarProperty::Attendee => {
rcpt_to = entry
.values
.first()
.and_then(|v| v.as_text())
.map(|v| v.strip_prefix("mailto:").unwrap_or(v))
.and_then(sanitize_email);
}
_ => {}
}
}
for entry in event_component.entries.iter() {
match &entry.name {
ArchivedICalendarProperty::Summary if summary.is_none() => {
summary = entry.values.first().and_then(|v| v.as_text());
}
ArchivedICalendarProperty::Description if description.is_none() => {
description = entry.values.first().and_then(|v| v.as_text());
}
ArchivedICalendarProperty::Location => {
location = entry.values.first().and_then(|v| v.as_text());
}
ArchivedICalendarProperty::Organizer | ArchivedICalendarProperty::Attendee => {
let email = entry
.values
.first()
.and_then(|v| v.as_text())
.map(|v| v.strip_prefix("mailto:").unwrap_or(v));
let name = entry.params.iter().find_map(|param| {
if let ArchivedICalendarParameter::Cn(name) = param {
Some(name.as_str())
} else {
None
}
});
if email.is_some() || name.is_some() {
if matches!(entry.name, ArchivedICalendarProperty::Organizer) {
organizer = Some((email, name));
} else {
guests.push((email, name));
}
}
}
_ => {}
}
}
// Validate recipient
let rcpt_to = if let Some(rcpt_to) = rcpt_to {
if server.core.groupware.alarms_allow_external_recipients
|| access_token.emails.iter().any(|email| email == &rcpt_to)
{
rcpt_to
} else {
trc::event!(
Calendar(trc::CalendarEvent::AlarmRecipientOverride),
Reason = "External recipient not allowed for calendar alarms",
Details = rcpt_to,
AccountId = task.account_id,
DocumentId = task.document_id,
);
access_token.emails.first().unwrap().to_string()
}
} else {
access_token.emails.first().unwrap().to_string()
};
#[cfg(feature = "enterprise")]
let template = server
.core
.enterprise
.as_ref()
.and_then(|e| e.template_calendar_alarm.as_ref())
.unwrap_or(&server.core.groupware.alarms_template);
#[cfg(not(feature = "enterprise"))]
let template = &server.core.groupware.alarms_template;
let locale = i18n::locale_or_default(access_token.locale.as_deref().unwrap_or("en"));
let chrono_locale = access_token
.locale
.as_deref()
.and_then(|locale| Locale::from_str(locale).ok())
.unwrap_or(Locale::en_US);
let start = format!(
"{} ({})",
DateTime::from_timestamp(alarm.event_start, 0)
.unwrap_or_default()
.format_localized(locale.calendar_date_template, chrono_locale),
Tz::from_id(alarm.event_start_tz).unwrap_or(Tz::UTC).name()
);
let end = format!(
"{} ({})",
DateTime::from_timestamp(alarm.event_end, 0)
.unwrap_or_default()
.format_localized(locale.calendar_date_template, chrono_locale),
Tz::from_id(alarm.event_end_tz).unwrap_or(Tz::UTC).name()
);
let subject = format!(
"{}: {} @ {}",
locale.calendar_alarm_subject_prefix,
summary.or(description).unwrap_or("No Subject"),
start
);
let organizer = organizer
.map(|(email, name)| match (email, name) {
(Some(email), Some(name)) => format!("{} <{}>", name, email),
(Some(email), None) => email.to_string(),
(None, Some(name)) => name.to_string(),
_ => unreachable!(),
})
.unwrap_or_else(|| access_token.name.clone());
let mut variables = Variables::new();
variables.insert_single(CalendarTemplateVariable::PageTitle, subject.as_str());
variables.insert_single(
CalendarTemplateVariable::Header,
locale.calendar_alarm_header,
);
variables.insert_single(
CalendarTemplateVariable::Footer,
locale.calendar_alarm_footer,
);
variables.insert_single(
CalendarTemplateVariable::ActionName,
locale.calendar_alarm_open,
);
variables.insert_single(CalendarTemplateVariable::ActionUrl, webcal_uri.as_str());
variables.insert_single(
CalendarTemplateVariable::AttendeesTitle,
locale.calendar_attendees,
);
variables.insert_single(
CalendarTemplateVariable::EventTitle,
summary.unwrap_or_default(),
);
variables.insert_single(CalendarTemplateVariable::LogoCid, logo_cid);
if let Some(description) = description {
variables.insert_single(CalendarTemplateVariable::EventDescription, description);
}
variables.insert_block(
CalendarTemplateVariable::EventDetails,
[
Some([
(CalendarTemplateVariable::Key, locale.calendar_start),
(CalendarTemplateVariable::Value, start.as_str()),
]),
Some([
(CalendarTemplateVariable::Key, locale.calendar_end),
(CalendarTemplateVariable::Value, end.as_str()),
]),
location.map(|location| {
[
(CalendarTemplateVariable::Key, locale.calendar_location),
(CalendarTemplateVariable::Value, location),
]
}),
Some([
(CalendarTemplateVariable::Key, locale.calendar_organizer),
(CalendarTemplateVariable::Value, organizer.as_str()),
]),
]
.into_iter()
.flatten(),
);
if !guests.is_empty() {
variables.insert_block(
CalendarTemplateVariable::Attendees,
guests.into_iter().map(|(email, name)| {
[
(CalendarTemplateVariable::Key, name.unwrap_or_default()),
(CalendarTemplateVariable::Value, email.unwrap_or_default()),
]
}),
);
}
Ok(Some(Details {
to: rcpt_to,
body: template.eval(&variables),
subject,
}))
}

View File

@@ -5,25 +5,43 @@
*/
use crate::task_manager::Task;
use calcard::icalendar::{ICalendarMethod, ICalendarParticipationStatus};
use calcard::{
common::timezone::Tz,
icalendar::{
ArchivedICalendarDay, ArchivedICalendarFrequency, ArchivedICalendarParticipationStatus,
ArchivedICalendarRecurrenceRule, ArchivedICalendarWeekday, ICalendarParticipationStatus,
ICalendarProperty,
},
};
use chrono::{DateTime, Locale};
use common::{
DEFAULT_LOGO, Server,
auth::AccessToken,
config::groupware::CalendarTemplateVariable,
i18n,
listener::{ServerInstance, stream::NullIo},
};
use groupware::{calendar::itip::ItipIngest, scheduling::ItipMessages};
use groupware::{
calendar::itip::ItipIngest,
scheduling::{ArchivedItipSummary, ArchivedItipValue, ItipMessages},
};
use mail_builder::{
MessageBuilder,
headers::{HeaderType, content_type::ContentType},
mime::{BodyPart, MimePart},
};
use mail_parser::decoders::html::html_to_text;
use smtp::core::{Session, SessionData};
use smtp_proto::{MailFrom, RcptTo};
use std::{sync::Arc, time::Duration};
use std::{str::FromStr, sync::Arc, time::Duration};
use store::{
ValueKey,
ahash::AHashMap,
rkyv::rend::{i16_le, i32_le},
write::{AlignedBytes, Archive, TaskQueueClass, ValueClass, now},
};
use trc::AddContext;
use utils::template::{Variable, Variables};
pub trait SendImipTask: Sync + Send {
fn send_imip(
@@ -115,32 +133,20 @@ async fn send_imip(
for itip_message in imip.messages.iter() {
for recipient in itip_message.to.iter() {
let mut rsvp_urls = Vec::new();
if itip_message.method == ICalendarMethod::Request {
if let Some(rsvp_url) = server
.http_rsvp_url(task.account_id, task.document_id, recipient.as_str())
.await
{
rsvp_urls = [
ICalendarParticipationStatus::Accepted,
ICalendarParticipationStatus::Declined,
ICalendarParticipationStatus::Tentative,
]
.into_iter()
.map(|status| (rsvp_url.url(&status, "en"), status))
.collect();
}
}
let todo = "use templates";
let subject = "subject";
let txt_body = "text body";
let mut html_body = "<html><body>HTML body</body></html>".to_string();
for (url, method) in rsvp_urls {
html_body.push_str(&format!("<a href=\"{url}\">{}</a>", method.as_str()));
}
// Build template
let tpl = build_itip_template(
server,
&access_token,
task,
itip_message.from.as_str(),
recipient.as_str(),
&itip_message.summary,
&logo_cid,
)
.await;
let txt_body = html_to_text(&tpl.body);
// Build message
let message = MessageBuilder::new()
.from((access_token.name.as_str(), itip_message.from.as_str()))
.to(recipient.as_str())
@@ -149,7 +155,7 @@ async fn send_imip(
"Reply-To",
HeaderType::Text(itip_message.from.as_str().into()),
)
.subject(subject)
.subject(&tpl.subject)
.body(MimePart::new(
ContentType::new("multipart/mixed"),
BodyPart::Multipart(vec![
@@ -162,13 +168,13 @@ async fn send_imip(
),
MimePart::new(
ContentType::new("text/html"),
BodyPart::Text(html_body.into()),
BodyPart::Text(tpl.body.as_str().into()),
),
]),
),
MimePart::new(
ContentType::new("text/calendar")
.attribute("method", itip_message.method.as_str())
.attribute("method", itip_message.summary.method())
.attribute("charset", "utf-8"),
BodyPart::Text(itip_message.message.as_str().into()),
)
@@ -274,3 +280,567 @@ async fn send_imip(
Ok(true)
}
pub struct Details {
pub subject: String,
pub body: String,
}
pub async fn build_itip_template(
server: &Server,
access_token: &AccessToken,
task: &Task,
from: &str,
to: &str,
summary: &ArchivedItipSummary,
logo_cid: &str,
) -> Details {
#[cfg(feature = "enterprise")]
let template = server
.core
.enterprise
.as_ref()
.and_then(|e| e.template_scheduling_email.as_ref())
.unwrap_or(&server.core.groupware.itip_template);
#[cfg(not(feature = "enterprise"))]
let template = &server.core.groupware.itip_template;
let locale = i18n::locale_or_default(access_token.locale.as_deref().unwrap_or("en"));
let chrono_locale = access_token
.locale
.as_deref()
.and_then(|locale| Locale::from_str(locale).ok())
.unwrap_or(Locale::en_US);
let mut variables = Variables::new();
let mut subject;
let (fields, old_fields) = match summary {
ArchivedItipSummary::Invite(fields) => {
subject = format!("{}: ", locale.calendar_invitation);
(fields, None)
}
ArchivedItipSummary::Update {
current, previous, ..
} => {
subject = format!("{}: ", locale.calendar_updated_invitation);
variables.insert_single(
CalendarTemplateVariable::Header,
locale.calendar_event_updated.to_string(),
);
variables.insert_single(CalendarTemplateVariable::Color, "info".to_string());
(current, Some(previous))
}
ArchivedItipSummary::Cancel(fields) => {
subject = format!("{}: ", locale.calendar_cancelled);
variables.insert_single(
CalendarTemplateVariable::Header,
locale.calendar_event_cancelled.to_string(),
);
variables.insert_single(CalendarTemplateVariable::Color, "danger".to_string());
(fields, None)
}
ArchivedItipSummary::Rsvp { part_stat, current } => {
let (color, value) = match part_stat {
ArchivedICalendarParticipationStatus::Accepted => {
subject = format!("{}: ", locale.calendar_accepted);
(
"info",
locale.calendar_participant_accepted.replace("$name", from),
)
}
ArchivedICalendarParticipationStatus::Declined => {
subject = format!("{}: ", locale.calendar_declined);
(
"danger",
locale.calendar_participant_declined.replace("$name", from),
)
}
ArchivedICalendarParticipationStatus::Tentative => {
subject = format!("{}: ", locale.calendar_tentative);
(
"warning",
locale.calendar_participant_tentative.replace("$name", from),
)
}
ArchivedICalendarParticipationStatus::Delegated => {
subject = format!("{}: ", locale.calendar_delegated);
(
"warning",
locale.calendar_participant_delegated.replace("$name", from),
)
}
_ => {
subject = format!("{}: ", locale.calendar_reply);
(
"info",
locale.calendar_participant_reply.replace("$name", from),
)
}
};
variables.insert_single(CalendarTemplateVariable::Header, value);
variables.insert_single(CalendarTemplateVariable::Color, color.to_string());
(current, None)
}
};
let mut has_rrule = false;
let mut details = Vec::with_capacity(4);
for field in [
ICalendarProperty::Summary,
ICalendarProperty::Description,
ICalendarProperty::Rrule,
ICalendarProperty::Dtstart,
ICalendarProperty::Location,
] {
let Some(entry) = fields.iter().find(|e| e.name == field) else {
continue;
};
let field_name = match &field {
ICalendarProperty::Summary => locale.calendar_summary,
ICalendarProperty::Description => locale.calendar_description,
ICalendarProperty::Rrule => {
has_rrule = true;
locale.calendar_when
}
ICalendarProperty::Dtstart if !has_rrule => locale.calendar_when,
ICalendarProperty::Location => locale.calendar_location,
_ => continue,
};
let value = format_field(
&entry.value,
locale.calendar_date_template_long,
chrono_locale,
);
match &field {
ICalendarProperty::Summary => {
subject.push_str(&value);
}
ICalendarProperty::Dtstart | ICalendarProperty::Rrule => {
subject.push_str(" @ ");
subject.push_str(&value);
}
_ => (),
}
let mut fields = AHashMap::with_capacity(3);
fields.insert(CalendarTemplateVariable::Key, field_name.to_string());
fields.insert(CalendarTemplateVariable::Value, value);
if let Some(old_entry) =
old_fields.and_then(|fields| fields.iter().find(|e| e.name == field))
{
fields.insert(
CalendarTemplateVariable::Changed,
locale.calendar_changed.to_string(),
);
fields.insert(
CalendarTemplateVariable::OldValue,
format_field(
&old_entry.value,
locale.calendar_date_template,
chrono_locale,
),
);
}
details.push(fields);
}
variables.items.insert(
CalendarTemplateVariable::EventDetails,
Variable::Block(details),
);
variables.insert_single(CalendarTemplateVariable::PageTitle, subject.clone());
variables.insert_single(CalendarTemplateVariable::LogoCid, format!("cid:{logo_cid}"));
if let Some(guests) = fields
.iter()
.find(|e| e.name == ICalendarProperty::Attendee)
{
if let ArchivedItipValue::Participants(guests) = &guests.value {
variables.insert_single(
CalendarTemplateVariable::AttendeesTitle,
locale.calendar_attendees.to_string(),
);
variables.insert_block(
CalendarTemplateVariable::Attendees,
guests.iter().map(|guest| {
[
(
CalendarTemplateVariable::Key,
if guest.is_organizer {
if let Some(name) = guest.name.as_ref() {
format!("{name} - {}", locale.calendar_organizer)
} else {
locale.calendar_organizer.to_string()
}
} else {
guest
.name
.as_ref()
.map(|n| n.as_str())
.unwrap_or_default()
.to_string()
},
),
(CalendarTemplateVariable::Value, guest.email.to_string()),
]
}),
);
}
}
// Add RSVP buttons
if matches!(
summary,
ArchivedItipSummary::Invite(_) | ArchivedItipSummary::Update { .. }
) {
if let Some(rsvp_url) = server
.http_rsvp_url(task.account_id, task.document_id, to)
.await
{
variables.insert_single(
CalendarTemplateVariable::Rsvp,
locale.calendar_reply_as.replace("$name", to),
);
variables.insert_block(
CalendarTemplateVariable::Actions,
[
(
ICalendarParticipationStatus::Accepted,
locale.calendar_yes.to_string(),
"info",
),
(
ICalendarParticipationStatus::Declined,
locale.calendar_no.to_string(),
"danger",
),
(
ICalendarParticipationStatus::Tentative,
locale.calendar_maybe.to_string(),
"warning",
),
]
.into_iter()
.map(|(status, title, color)| {
[
(CalendarTemplateVariable::ActionName, title.to_string()),
(CalendarTemplateVariable::ActionUrl, rsvp_url.url(&status)),
(CalendarTemplateVariable::Color, color.to_string()),
]
}),
);
}
}
// Add footer
variables.insert_block(
CalendarTemplateVariable::Footer,
[
[(
CalendarTemplateVariable::Key,
locale.calendar_imip_footer_1.to_string(),
)],
[(
CalendarTemplateVariable::Key,
locale.calendar_imip_footer_2.to_string(),
)],
]
.into_iter(),
);
Details {
subject,
body: template.eval(&variables),
}
}
fn format_field(value: &ArchivedItipValue, template: &str, chrono_locale: Locale) -> String {
match value {
ArchivedItipValue::Text(text) => text.to_string(),
ArchivedItipValue::Time(time) => {
use chrono::TimeZone;
let tz = Tz::from_id(time.tz_id.to_native()).unwrap_or(Tz::UTC);
format!(
"{} ({})",
tz.from_utc_datetime(
&DateTime::from_timestamp(time.start.to_native(), 0)
.unwrap_or_default()
.naive_local()
)
.format_localized(template, chrono_locale),
tz.name()
)
}
ArchivedItipValue::Rrule(rrule) => RecurrenceFormatter.format(rrule),
ArchivedItipValue::Participants(_) => String::new(), // Handled separately
}
}
#[derive(Default)]
pub struct RecurrenceFormatter;
impl RecurrenceFormatter {
pub fn format(&self, rule: &ArchivedICalendarRecurrenceRule) -> String {
let mut parts = Vec::new();
// Format frequency and interval
let freq_part = self.format_frequency(
&rule.freq,
rule.interval.as_ref().map(|i| i.to_native()).unwrap_or(1),
);
parts.push(freq_part);
// Format day constraints
if !rule.byday.is_empty() {
parts.push(self.format_by_day(&rule.byday));
}
// Format time constraints
if !rule.byhour.is_empty() || !rule.byminute.is_empty() {
parts.push(self.format_time_constraints(&rule.byhour, &rule.byminute));
}
// Format month day constraints
if !rule.bymonthday.is_empty() {
parts.push(self.format_month_days(&rule.bymonthday));
}
// Format month constraints
if !rule.bymonth.is_empty() {
parts.push(self.format_months(&rule.bymonth));
}
// Format year day constraints
if !rule.byyearday.is_empty() {
parts.push(self.format_year_days(&rule.byyearday));
}
// Format week number constraints
if !rule.byweekno.is_empty() {
parts.push(self.format_week_numbers(&rule.byweekno));
}
// Format set position constraints
if !rule.bysetpos.is_empty() {
parts.push(self.format_set_positions(&rule.bysetpos));
}
// Format termination (until/count)
/*if let Some(until) = &rule.until {
parts.push(format!("until {}", self.format_datetime(until)));
} else*/
if let Some(count) = rule.count.as_ref() {
let times = if *count == 1 { "time" } else { "times" };
parts.push(format!("for {} {}", count, times));
}
parts.join(" ")
}
fn format_frequency(&self, freq: &ArchivedICalendarFrequency, interval: u16) -> String {
let (singular, plural) = match freq {
ArchivedICalendarFrequency::Daily => ("day", "days"),
ArchivedICalendarFrequency::Weekly => ("week", "weeks"),
ArchivedICalendarFrequency::Monthly => ("month", "months"),
ArchivedICalendarFrequency::Yearly => ("year", "years"),
ArchivedICalendarFrequency::Hourly => ("hour", "hours"),
ArchivedICalendarFrequency::Minutely => ("minute", "minutes"),
ArchivedICalendarFrequency::Secondly => ("second", "seconds"),
};
if interval == 1 {
format!("Every {}", singular)
} else {
format!("Every {} {}", interval, plural)
}
}
fn format_by_day(&self, days: &[ArchivedICalendarDay]) -> String {
let day_names: Vec<String> = days.iter().map(|day| self.format_day(day)).collect();
format!("on {}", self.format_list(&day_names))
}
fn format_day(&self, day: &ArchivedICalendarDay) -> String {
let day_name = match day.weekday {
ArchivedICalendarWeekday::Monday => "Monday",
ArchivedICalendarWeekday::Tuesday => "Tuesday",
ArchivedICalendarWeekday::Wednesday => "Wednesday",
ArchivedICalendarWeekday::Thursday => "Thursday",
ArchivedICalendarWeekday::Friday => "Friday",
ArchivedICalendarWeekday::Saturday => "Saturday",
ArchivedICalendarWeekday::Sunday => "Sunday",
};
if let Some(occurrence) = day.ordwk.as_ref().map(|o| o.to_native()) {
if occurrence > 0 {
format!("the {} {}", self.ordinal(occurrence as u32), day_name)
} else {
format!(
"the {} {} from the end",
self.ordinal((-occurrence) as u32),
day_name
)
}
} else {
day_name.to_string()
}
}
fn format_time_constraints(&self, hours: &[u8], minutes: &[u8]) -> String {
let mut time_parts = Vec::new();
if !hours.is_empty() && !minutes.is_empty() {
// Combine hours and minutes
for &hour in hours {
for &minute in minutes {
time_parts.push(format!("{}:{:02}", self.format_hour(hour), minute));
}
}
} else if !hours.is_empty() {
for &hour in hours {
time_parts.push(self.format_hour(hour));
}
} else if !minutes.is_empty() {
for &minute in minutes {
time_parts.push(format!(":{:02}", minute));
}
}
if !time_parts.is_empty() {
format!("at {}", self.format_list(&time_parts))
} else {
String::new()
}
}
fn format_hour(&self, hour: u8) -> String {
match hour {
0 => "12:00 AM".to_string(),
1..=11 => format!("{}:00 AM", hour),
12 => "12:00 PM".to_string(),
13..=23 => format!("{}:00 PM", hour - 12),
_ => format!("{:02}:00", hour),
}
}
fn format_month_days(&self, days: &[i8]) -> String {
let day_strings: Vec<String> = days
.iter()
.map(|&day| {
if day > 0 {
self.ordinal(day as u32)
} else {
format!("{} from the end", self.ordinal((-day) as u32))
}
})
.collect();
format!("on the {}", self.format_list(&day_strings))
}
fn format_months(&self, months: &[u8]) -> String {
let month_names: Vec<String> = months.iter().map(|&month| self.month_name(month)).collect();
format!("in {}", self.format_list(&month_names))
}
fn format_year_days(&self, days: &[i16_le]) -> String {
let day_strings: Vec<String> = days
.iter()
.map(|&day| {
if day > 0 {
format!("day {} of the year", day)
} else {
format!("day {} from the end of the year", -day)
}
})
.collect();
format!("on {}", self.format_list(&day_strings))
}
fn format_week_numbers(&self, weeks: &[i8]) -> String {
let week_strings: Vec<String> = weeks
.iter()
.map(|&week| {
if week > 0 {
format!("week {}", week)
} else {
format!("week {} from the end", -week)
}
})
.collect();
format!("in {}", self.format_list(&week_strings))
}
fn format_set_positions(&self, positions: &[i32_le]) -> String {
let pos_strings: Vec<String> = positions
.iter()
.map(|&pos| {
if pos > 0 {
self.ordinal(pos.to_native() as u32)
} else {
format!("{} from the end", self.ordinal((-pos) as u32))
}
})
.collect();
format!(
"limited to the {} occurrence",
self.format_list(&pos_strings)
)
}
fn format_list(&self, items: &[String]) -> String {
match items.len() {
0 => String::new(),
1 => items[0].clone(),
2 => format!("{} and {}", items[0], items[1]),
_ => {
let rest = &items[..items.len() - 1];
format!("{}, and {}", rest.join(", "), items.last().unwrap())
}
}
}
fn ordinal(&self, n: u32) -> String {
let suffix = match n % 100 {
11..=13 => "th",
_ => match n % 10 {
1 => "st",
2 => "nd",
3 => "rd",
_ => "th",
},
};
format!("{}{}", n, suffix)
}
fn month_name(&self, month: u8) -> String {
match month {
1 => "January",
2 => "February",
3 => "March",
4 => "April",
5 => "May",
6 => "June",
7 => "July",
8 => "August",
9 => "September",
10 => "October",
11 => "November",
12 => "December",
_ => "Unknown",
}
.to_string()
}
/*fn format_datetime(&self, dt: &PartialDateTime) -> String {
format!("{:?}", dt)
}*/
}

View File

@@ -41,10 +41,10 @@ pub mod imip;
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct Task {
account_id: u32,
document_id: u32,
due: u64,
action: TaskAction,
pub account_id: u32,
pub document_id: u32,
pub due: u64,
pub action: TaskAction,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]

View File

@@ -7,7 +7,7 @@ homepage = "https://stalw.art/smtp"
keywords = ["smtp", "email", "mail", "server"]
categories = ["email"]
license = "AGPL-3.0-only OR LicenseRef-SEL"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "spam-filter"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "store"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "trc"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "event_macro"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
[lib]

View File

@@ -1,6 +1,6 @@
[package]
name = "utils"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -1,6 +1,6 @@
[package]
name = "proc_macros"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
[lib]

View File

@@ -16,7 +16,7 @@ pub struct Template<T> {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TemplateItem<T> {
Static(String),
Variable(T),
Variable { name: T, escape: bool },
If { variable: T, block_end: usize },
ForEach { variable: T, block_end: usize },
}
@@ -104,8 +104,10 @@ impl<T: FromStr + Eq + Hash + std::fmt::Debug> Template<T> {
}
}
} else {
let var = T::from_str(var).map_err(|_| format!("Invalid variable: {}", var))?;
items.push(TemplateItem::Variable(var));
let (name, escape) = var.strip_prefix("!").map_or((var, true), |v| (v, false));
let name =
T::from_str(name).map_err(|_| format!("Invalid variable: {}", name))?;
items.push(TemplateItem::Variable { name, escape });
}
} else {
if !template.is_empty() {
@@ -135,9 +137,13 @@ impl<T: FromStr + Eq + Hash + std::fmt::Debug> Template<T> {
let idx = idx + base_offset;
match item {
TemplateItem::Static(s) => result.push_str(s),
TemplateItem::Variable(variable) => {
if let Some(Variable::Single(variable)) = variables.items.get(variable) {
TemplateItem::Variable { name, escape } => {
if let Some(Variable::Single(variable)) = variables.items.get(name) {
if *escape {
html_escape(&mut result, variable.as_ref())
} else {
result.push_str(variable.as_ref());
}
}
}
TemplateItem::If {
@@ -156,12 +162,25 @@ impl<T: FromStr + Eq + Hash + std::fmt::Debug> Template<T> {
if let Some(Variable::Block(entries)) = variables.items.get(variable) {
let slice = &self.items[idx + 1..*block_end];
for entry in entries {
for sub_item in slice {
let mut slice = slice.iter();
while let Some(sub_item) = slice.next() {
match sub_item {
TemplateItem::Static(s) => result.push_str(s),
TemplateItem::Variable(var) => {
if let Some(variable) = entry.get(var) {
TemplateItem::Variable { name, escape } => {
if let Some(variable) = entry.get(name) {
if *escape {
html_escape(&mut result, variable.as_ref())
} else {
result.push_str(variable.as_ref());
}
}
}
TemplateItem::If {
variable,
block_end: start_pos,
} => {
if !entry.contains_key(variable) {
slice = self.items[*start_pos..*block_end].iter();
}
}
_ => {}

View File

@@ -0,0 +1,345 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>{{page_title}}</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
.color-info {
background: #4CAF50;
background-color: #4CAF50;
}
.color-warning {
background: #FF9800;
background-color: #FF9800;
}
.color-danger {
background: #f44336;
background-color: #f44336;
}
:root {
color-scheme: light only;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
.mj-column-per-33-3 {
width: 33.3% !important;
max-width: 33.3%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
.moz-text-html .mj-column-per-33-3 {
width: 33.3% !important;
max-width: 33.3%;
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
</head>
<body style="word-spacing:normal;background-color:#f4f4f4;">
<div style="background-color:#f4f4f4;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#4CAF50" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
{{#if header}}
<div class="color-{{color}}" style="margin:0px auto;max-width:600px;">
<table class="color-{{color}}" align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:590px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center"
style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:16px;font-weight:bold;line-height:1.4;text-align:center;color:white;">
{{header}}</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
{{/if header}}
<!--[if mso | IE]></td></tr></table><![endif]-->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:white;background-color:white;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 5px 5px 5px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:590px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center"
style="font-size:0px;padding:10px 25px;padding-bottom:20px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:200px;">
<img height="auto" src="{{logo_cid}}"
style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
width="200">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
{{#each event_details}}
<tr>
<td align="left" class="event-detail"
style="margin-bottom: 16px; font-size: 0px; padding: 10px 25px; word-break: break-word;">
<div
style="font-family:Arial, sans-serif;font-size:14px;line-height:1.4;text-align:left;color:#333333;">
<div class="event-detail-label"
style="font-weight: bold; color: #666666; margin-bottom: 4px;">
{{key}}
{{#if changed}}<span class="changed-pill"
style="background-color: #4CAF50; color: white; padding: 4px 8px; border-radius: 12px; font-size: 8px; font-weight: bold; margin-left: 8px; display: inline-block;">{{changed}}</span>
{{/if changed}}
</div>
{{#if old_value}}<div class="strikethrough"
style="text-decoration: line-through; color: #999999;">
{{old_value}}</div>{{/if old_value}}
<div style="display: flex; align-items: center;">
<span>{{value}}</span>
</div>
</div>
</td>
</tr>
{{/each event_details}}
{{#if attendees}}
<tr>
<td align="left" class="event-detail"
style="margin-bottom: 16px; font-size: 0px; padding: 10px 25px; padding-bottom: 30px; word-break: break-word;">
<div
style="font-family:Arial, sans-serif;font-size:14px;line-height:1.4;text-align:left;color:#333333;">
<div class="event-detail-label"
style="font-weight: bold; color: #666666; margin-bottom: 4px;">
{{attendees_title}}</div>
{{#each attendees}}<div class="guest-item"
style="margin-bottom: 4px;">• {{key}}
({{value}})</div>{{/each attendees}}
</div>
</td>
</tr>
{{/if attendees}}
{{#if rsvp}}
<tr>
<td align="center"
style="font-size:0px;padding:10px 0;word-break:break-word;">
<p
style="border-top:solid 1px #e0e0e0;font-size:1px;margin:0px auto;width:100%;">
</p>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 1px #e0e0e0;font-size:1px;margin:0px auto;width:590px;" role="presentation" width="590px" ><tr><td style="height:0;line-height:0;"> &nbsp;
</td></tr></table><![endif]-->
</td>
</tr>
<tr>
<td align="left"
style="font-size:0px;padding:10px 0px 5px 20px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:14px;line-height:1.4;text-align:left;color:#333333;">
{{rsvp}}
</div>
</td>
</tr>
{{/if rsvp}}
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
{{#if rsvp}}
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:white;background-color:white;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 5px 5px 5px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="width:590px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0;line-height:0;text-align:left;display:inline-block;width:100%;direction:ltr;">
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><![endif]-->
{{#each actions}}
<!--[if mso | IE]><td style="vertical-align:top;width:196px;" ><![endif]-->
<div class="mj-column-per-33-3 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:33.3%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" vertical-align="middle"
style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0"
role="presentation"
style="border-collapse:separate;width:100%;line-height:100%;">
<tr>
<td align="center" bgcolor="#4CAF50" role="presentation"
class="color-{{color}}"
style="border:none;border-radius:4px;cursor:auto;mso-padding-alt:8px 4px;"
valign="middle">
<a href="{{!action_url}}" class="color-{{color}}"
style="display:inline-block;color:white;font-family:Arial, sans-serif;font-size:11px;font-weight:bold;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:8px 4px;mso-padding-alt:0px;border-radius:4px;"
target="_blank"> {{action_name}} </a>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><![endif]-->
{{/each actions}}
<!--[if mso | IE]></tr></table><![endif]-->
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
{{/if rsvp}}
{{#if footer}}
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#f8f8f8" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#f8f8f8;background-color:#f8f8f8;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
style="background:#f8f8f8;background-color:#f8f8f8;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:560px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix"
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
style="vertical-align:top;" width="100%">
<tbody>
{{#each footer}}
<tr>
<td align="center"
style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div
style="font-family:Arial, sans-serif;font-size:12px;line-height:1.5;text-align:center;color:#666666;">
{{key}}</div>
</td>
</tr>
{{/each footer}}
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
{{/if footer}}
</div>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,132 @@
<mjml>
<mj-head>
<mj-title>Event Changed</mj-title>
<mj-preview>An event you're attending has been updated</mj-preview>
<mj-attributes>
<mj-all font-family="Arial, sans-serif" />
<mj-text font-size="14px" color="#333333" line-height="1.4" />
<mj-button background-color="#4CAF50" color="white" font-size="11px" font-weight="bold" border-radius="4px" align="left"/>
</mj-attributes>
<mj-style inline="inline">
.changed-pill {
background-color: #4CAF50;
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 8px;
font-weight: bold;
margin-left: 8px;
display: inline-block;
}
.event-detail {
margin-bottom: 16px;
}
.event-detail-label {
font-weight: bold;
color: #666666;
margin-bottom: 4px;
}
.guest-item {
margin-bottom: 4px;
}
.strikethrough {
text-decoration: line-through;
color: #999999;
}
</mj-style>
</mj-head>
<mj-body background-color="#f4f4f4">
<!-- Header Alert -->
<mj-section background-color="#4CAF50" padding="5px">
<mj-column>
<mj-text align="center" color="white" font-size="16px" font-weight="bold">
This event has been updated
</mj-text>
</mj-column>
</mj-section>
<!-- Main Content -->
<mj-section background-color="white" padding="5px 5px 5px 5px">
<mj-column>
<!-- Logo -->
<mj-image src="https://stalw.art/img/logo-dark@2x.png" alt="Stalwart Logo" width="200px" align="center" padding-bottom="20px" />
<!-- Title -->
<mj-text css-class="event-detail">
<div class="event-detail-label">Title</div>
<div style="display: flex; align-items: center;">
<span>Annual Team Building Workshop 2025</span>
</div>
</mj-text>
<!-- Description -->
<mj-text css-class="event-detail">
<div class="event-detail-label">Description</div>
<div>Join us for our annual team building workshop featuring interactive activities, networking sessions, and strategic planning for the upcoming year. This year's theme focuses on collaboration and innovation.</div>
</mj-text>
<!-- When -->
<mj-text css-class="event-detail">
<div class="event-detail-label">When <span class="changed-pill">CHANGED</span></div>
<div class="strikethrough">Friday, March 15, 2025 9:00 AM - 5:00 PM (PST)</div>
<div>Friday, March 15, 2025 9:00 AM - 5:00 PM (PST)</div>
</mj-text>
<!-- Location -->
<mj-text css-class="event-detail">
<div class="event-detail-label">Location</div>
<div>Grand Conference Center<br>123 Business Park Drive<br>San Francisco, CA 94105</div>
</mj-text>
<!-- Guest List -->
<mj-text css-class="event-detail" padding-bottom="30px">
<div class="event-detail-label">Guest List</div>
<div class="guest-item">• John Smith (john.smith@company.com)</div>
<div style="margin-top: 8px; color: #666666; font-style: italic;">+ 12 more attendees</div>
</mj-text>
<!-- RSVP legend -->
<mj-divider border-color="#e0e0e0" border-width="1px" padding="10px 0" />
<mj-text font-size="14px" color="#333333" padding="10px 0px 5px 20px">
<b>Reply</b> as email@domain.com for this event series:
</mj-text>
</mj-column>
</mj-section>
<!-- RSVP buttons -->
<mj-section background-color="white" padding="0 5px 5px 5px">
<mj-group>
<mj-column width="33.3%">
<mj-button background-color="#4CAF50" href="#" width="100%" inner-padding="8px 4px">
YES
</mj-button>
</mj-column>
<mj-column width="33.3%">
<mj-button background-color="#f44336" href="#" width="100%" inner-padding="8px 4px">
NO
</mj-button>
</mj-column>
<mj-column width="33.3%">
<mj-button background-color="#FF9800" href="#" width="100%" inner-padding="8px 4px">
MAYBE
</mj-button>
</mj-column>
</mj-group>
</mj-section>
<!-- Footer -->
<mj-section background-color="#f8f8f8" padding="20px">
<mj-column>
<mj-text font-size="12px" color="#666666" align="center" line-height="1.5">
Youre receiving this e-mail as you're listed as a participant for this event.
</mj-text>
<mj-text font-size="12px" color="#666666" align="center" line-height="1.5" padding-top="10px">
Forwarding this e-mail could allow any recipient to reply to the organizer, join the guest list, extend the invitation to others, or alter your RSVP.
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>

View File

@@ -8,44 +8,7 @@ calendar.alarm_subject_prefix:
de: Benachrichtigung
it: Notifica
pt: Notificação
ru: Уведомление
zh: 通知
ja: 通知
ko: 알림
ar: إشعار
hi: सूचना
nl: Melding
sv: Meddelande
da: Besked
no: Varsel
fi: Ilmoitus
pl: Powiadomienie
cs: Oznámení
sk: Oznámenie
hu: Értesítés
ro: Notificare
bg: Известие
hr: Obavijest
sl: Obvestilo
et: Teade
lv: Paziņojums
lt: Pranešimas
el: Ειδοποίηση
tr: Bildirim
he: הודעה
th: การแจ้งเตือน
vi: Thông báo
id: Pemberitahuan
ms: Pemberitahuan
tl: Abiso
uk: Сповіщення
be: Паведамленне
mk: Известување
sq: Njoftim
mt: Notifika
cy: Hysbysiad
ga: Fógra
is: Tilkynning
calendar.alarm_header:
en: You have an upcoming event
@@ -54,44 +17,7 @@ calendar.alarm_header:
de: Sie haben einen bevorstehenden Termin
it: Hai un evento in programma
pt: Você tem um evento próximo
ru: У вас предстоящее событие
zh: 您有即将到来的活动
ja: 予定されたイベントがあります
ko: 다가오는 이벤트가 있습니다
ar: لديك حدث قادم
hi: आपका एक आगामी कार्यक्रम है
nl: U heeft een aankomende gebeurtenis
sv: Du har ett kommande evenemang
da: Du har en kommende begivenhed
no: Du har en kommende hendelse
fi: Sinulla on tuleva tapahtuma
pl: Masz nadchodzące wydarzenie
cs: Máte nadcházející událost
sk: Máte nadchádzajúcu udalosť
hu: Közelgő eseménye van
ro: Aveți un eveniment viitor
bg: Имате предстоящо събитие
hr: Imate nadolazeći događaj
sl: Imate prihajajoči dogodek
et: Teil on tulev sündmus
lv: Jums ir gaidāms notikums
lt: Turite artėjantį įvykį
el: Έχετε μια επερχόμενη εκδήλωση
tr: Yaklaşan bir etkinliğiniz var
he: יש לך אירוע קרוב
th: คุณมีกิจกรรมที่กำลังจะมาถึง
vi: Bạn có một sự kiện sắp tới
id: Anda memiliki acara yang akan datang
ms: Anda mempunyai acara yang akan datang
tl: Mayroon kayong paparating na kaganapan
uk: У вас є майбутня подія
be: У вас ёсць будучая падзея
mk: Имате претстојувачки настан
sq: Keni një ngjarje të ardhshme
mt: Għandkom avveniment li ġej
cy: Mae gennych chi ddigwyddiad sydd ar ddod
ga: Tá imeacht ag teacht agat
is: Þú átt komandi viðburð
calendar.alarm_footer:
en: You are receiving this email because you have enabled calendar notifications. To stop receiving these emails, login to the self-service portal and disable event notifications.
@@ -100,44 +26,7 @@ calendar.alarm_footer:
de: Sie erhalten diese E-Mail, weil Sie Kalender-Benachrichtigungen aktiviert haben. Um diese E-Mails nicht mehr zu erhalten, melden Sie sich im Self-Service-Portal an und deaktivieren Sie Ereignisbenachrichtigungen.
it: Ricevi questa email perché hai abilitato le notifiche del calendario. Per smettere di ricevere queste email, accedi al portale self-service e disabilita le notifiche degli eventi.
pt: Você está recebendo este e-mail porque habilitou as notificações do calendário. Para parar de receber estes e-mails, faça login no portal de autoatendimento e desative as notificações de eventos.
ru: Вы получаете это письмо, потому что включили уведомления календаря. Чтобы прекратить получать эти письма, войдите в портал самообслуживания и отключите уведомления о событиях.
zh: 您收到此邮件是因为您已启用日历通知。要停止接收这些邮件,请登录自助服务门户并禁用事件通知。
ja: カレンダー通知を有効にしているため、このメールを受信しています。これらのメールの受信を停止するには、セルフサービスポータルにログインしてイベント通知を無効にしてください。
ko: 캘린더 알림을 활성화했기 때문에 이 이메일을 받고 있습니다. 이러한 이메일 수신을 중지하려면 셀프서비스 포털에 로그인하여 이벤트 알림을 비활성화하십시오.
ar: تتلقى هذا البريد الإلكتروني لأنك قمت بتمكين إشعارات التقويم. لإيقاف تلقي هذه الرسائل الإلكترونية، قم بتسجيل الدخول إلى بوابة الخدمة الذاتية وإلغاء تنشيط إشعارات الأحداث.
hi: आप यह ईमेल इसलिए प्राप्त कर रहे हैं क्योंकि आपने कैलेंडर सूचनाएं सक्षम की हैं। इन ईमेल प्राप्त करना बंद करने के लिए, स्व-सेवा पोर्टल में लॉगिन करें और इवेंट सूचनाएं अक्षम करें।
nl: U ontvangt deze e-mail omdat u kalendernotificaties heeft ingeschakeld. Om deze e-mails niet meer te ontvangen, logt u in op de selfservice-portal en schakelt u gebeurtenismeldingen uit.
sv: Du får detta e-postmeddelande eftersom du har aktiverat kalendernotifieringar. För att sluta få dessa e-postmeddelanden, logga in på självbetjäningsportalen och inaktivera händelsenotifieringar.
da: Du modtager denne e-mail, fordi du har aktiveret kalendernotifikationer. For at stoppe med at modtage disse e-mails skal du logge ind på selvbetjeningsportalen og deaktivere begivenhedsnotifikationer.
no: Du mottar denne e-posten fordi du har aktivert kalendervarslinger. For å slutte å motta disse e-postene, logg inn på selvbetjeningsportalen og deaktiver hendelsesvarslinger.
fi: Saat tämän sähköpostin, koska olet ottanut kalenteritiedotukset käyttöön. Lopettaaksesi näiden sähköpostien vastaanottamisen, kirjaudu itsepalveluportaaliin ja poista käytöstä tapahtumatiedotukset.
pl: Otrzymujesz ten e-mail, ponieważ włączyłeś powiadomienia kalendarza. Aby przestać otrzymywać te e-maile, zaloguj się do portalu samoobsługowego i wyłącz powiadomienia o zdarzeniach.
cs: Tento e-mail dostáváte, protože máte povolená oznámení kalendáře. Chcete-li přestat dostávat tyto e-maily, přihlaste se na portál samoobsluhy a zakažte oznámení událostí.
sk: Tento e-mail dostávate, pretože máte povolené oznámenia kalendára. Ak chcete prestať dostávať tieto e-maily, prihláste sa na portál samoobsluhy a zakážte oznámenia udalostí.
hu: Azért kapja ezt az e-mailt, mert engedélyezte a naptár értesítéseket. Az e-mailek fogadásának leállításához jelentkezzen be az önkiszolgáló portálra és tiltsa le az esemény értesítéseket.
ro: Primiți acest e-mail pentru că ați activat notificările de calendar. Pentru a opri primirea acestor e-mailuri, autentificați-vă în portalul de autoservire și dezactivați notificările de evenimente.
bg: Получавате този имейл, защото сте разрешили известията за календара. За да спрете получаването на тези имейли, влезте в портала за самообслужване и изключете известията за събития.
hr: Primate ovaj e-mail jer ste omogućili obavijesti kalendara. Da prestanete primati ove e-mailove, prijavite se na portal za samoposluživanje i onemogućite obavijesti o događajima.
sl: To e-pošto prejemate, ker ste omogočili obvestila koledarja. Če želite prenehati prejemati te e-pošte, se prijavite v portal za samopostrežbo in onemogočite obvestila o dogodkih.
et: Saate seda e-kirja, kuna olete lubanud kalendri teatised. Nende e-kirjade saamise lõpetamiseks logige sisse iseteenindusportaali ja keelake sündmuste teatised.
lv: Jūs saņemat šo e-pastu, jo esat iespējojuši kalendāra paziņojumus. Lai pārtrauktu šo e-pastu saņemšanu, piesakieties pašapkalpošanās portālā un atspējojiet notikumu paziņojumus.
lt: Gaunate šį el. laišką, nes įjungėte kalendoriaus pranešimus. Norėdami nebegauti šių el. laiškų, prisijunkite prie savitarnos portalo ir išjunkite įvykių pranešimus.
el: Λαμβάνετε αυτό το email επειδή έχετε ενεργοποιήσει τις ειδοποιήσεις ημερολογίου. Για να σταματήσετε να λαμβάνετε αυτά τα emails, συνδεθείτε στην πύλη αυτοεξυπηρέτησης και απενεργοποιήστε τις ειδοποιήσεις εκδηλώσεων.
tr: Bu e-postayı alıyorsunuz çünkü takvim bildirimlerini etkinleştirdiniz. Bu e-postaları almayı durdurmak için self-servis portalına giriş yapın ve etkinlik bildirimlerini devre dışı bırakın.
he: אתה מקבל את המייל הזה כי הפעלת התראות לוח שנה. כדי להפסיק לקבל מיילים אלה, התחבר לפורטל השירות העצמי והשבת התראות אירועים.
th: คุณได้รับอีเมลนี้เพราะคุณได้เปิดใช้งานการแจ้งเตือนปฏิทิน หากต้องการหยุดรับอีเมลเหล่านี้ ให้เข้าสู่ระบบพอร์ทัลบริการตนเองและปิดใช้งานการแจ้งเตือนเหตุการณ์
vi: Bạn nhận được email này vì bạn đã bật thông báo lịch. Để ngừng nhận những email này, hãy đăng nhập vào cổng tự phục vụ và tắt thông báo sự kiện.
id: Anda menerima email ini karena Anda telah mengaktifkan notifikasi kalender. Untuk berhenti menerima email ini, masuk ke portal layanan mandiri dan nonaktifkan notifikasi acara.
ms: Anda menerima e-mel ini kerana anda telah mendayakan pemberitahuan kalendar. Untuk berhenti menerima e-mel ini, log masuk ke portal layan diri dan lumpuhkan pemberitahuan acara.
tl: Natatanggap ninyo ang email na ito dahil pinagana ninyo ang mga abiso sa kalendaryo. Para tumigil sa pagtanggap ng mga email na ito, mag-login sa self-service portal at i-disable ang mga abiso sa kaganapan.
uk: Ви отримуєте цей електронний лист, оскільки ввімкнули сповіщення календаря. Щоб припинити отримувати ці листи, увійдіть до порталу самообслуговування та вимкніть сповіщення про події.
be: Вы атрымліваеце гэты ліст, таму што ўключылі паведамленні календара. Каб спыніць атрыманне гэтых лістоў, увайдзіце ў партал самаабслугоўвання і адключыце паведамленні пра падзеі.
mk: Го примате овој е-мејл бидејќи сте овозможиле известувања за календар. За да престанете да примате овие е-мејлови, најавете се на порталот за самоуслуга и оневозможете ги известувањата за настани.
sq: Po merrni këtë email sepse keni aktivizuar njoftimet e kalendarit. Për të ndalur marrjen e këtyre emaileve, hyni në portalin e vetëshërbimit dhe çaktivizoni njoftimet e ngjarjeve.
mt: Qed tirċievi din l-email għax inti bbelit in-notifiki tal-kalendarju. Biex tieqaf tirċievi dawn l-emails, idħol fil-portal tas-self-service u diżattiva n-notifiki tal-avvenimenti.
cy: Rydych yn derbyn yr e-bost hwn oherwydd eich bod wedi galluogi hysbysiadau calendr. I roi'r gorau i dderbyn yr e-byst hyn, mewngofnodwch i'r porth hunanwasanaeth ac analluogi hysbysiadau digwyddiadau.
ga: Tá tú ag fáil an ríomhphoist seo toisc go bhfuil fógraí féilire cumasaithe agat. Chun stop a chur le fáil na ríomhphoist seo, logáil isteach sa phortán féinseirbhíse agus díchumasaigh fógraí imeachtaí.
is: Þú færð þetta tölvupóst vegna þess að þú hefur virkjað dagbókartilkynningar. Til að hætta að fá þessa tölvupósta skaltu skrá þig inn á sjálfsafgreiðslugáttina og slökkva á viðburðartilkynningum.
calendar.alarm_open:
en: View Event
@@ -146,44 +35,7 @@ calendar.alarm_open:
de: Termin Anzeigen
it: Visualizza Evento
pt: Ver Evento
ru: Просмотреть Событие
zh: 查看活动
ja: イベントを表示
ko: 이벤트 보기
ar: عرض الحدث
hi: इवेंट देखें
nl: Gebeurtenis Bekijken
sv: Visa Händelse
da: Se Begivenhed
no: Vis Hendelse
fi: Näytä Tapahtuma
pl: Zobacz Wydarzenie
cs: Zobrazit Událost
sk: Zobraziť Udalosť
hu: Esemény Megtekintése
ro: Vezi Evenimentul
bg: Виж Събитието
hr: Pogledaj Događaj
sl: Ogled Dogodka
et: Vaata Sündmust
lv: Skatīt Notikumu
lt: Peržiūrėti Įvykį
el: Προβολή Εκδήλωσης
tr: Etkinliği Görüntüle
he: צפייה באירוע
th: ดูกิจกรรม
vi: Xem Sự kiện
id: Lihat Acara
ms: Lihat Acara
tl: Tingnan ang Kaganapan
uk: Переглянути Подію
be: Прагледзець Падзею
mk: Погледај Настан
sq: Shiko Ngjarjen
mt: Ara l-Avveniment
cy: Gweld Digwyddiad
ga: Féach ar Imeacht
is: Skoða Viðburð
calendar.organizer:
en: Organizer
@@ -192,44 +44,7 @@ calendar.organizer:
de: Organisator
it: Organizzatore
pt: Organizador
ru: Организатор
zh: 组织者
ja: 主催者
ko: 주최자
ar: المنظم
hi: आयोजक
nl: Organisator
sv: Arrangör
da: Arrangør
no: Arrangør
fi: Järjestäjä
pl: Organizator
cs: Organizátor
sk: Organizátor
hu: Szervező
ro: Organizator
bg: Организатор
hr: Organizator
sl: Organizator
et: Korraldaja
lv: Organizētājs
lt: Organizatorius
el: Διοργανωτής
tr: Organizatör
he: מארגן
th: ผู้จัดงาน
vi: Người tổ chức
id: Penyelenggara
ms: Penganjur
tl: Tagaayos
uk: Організатор
be: Арганізатар
mk: Организатор
sq: Organizatori
mt: Organizzatur
cy: Trefnydd
ga: Eagraí
is: Skipuleggjandi
calendar.attendees:
en: Guests
@@ -238,44 +53,7 @@ calendar.attendees:
de: Gäste
it: Ospiti
pt: Convidados
ru: Гости
zh: 客人
ja: ゲスト
ko: 게스트
ar: الضيوف
hi: अतिथि
nl: Gasten
sv: Gäster
da: Gæster
no: Gjester
fi: Vieraat
pl: Goście
cs: Hosté
sk: Hostia
hu: Vendégek
ro: Oaspeți
bg: Гости
hr: Gosti
sl: Gostje
et: Külalised
lv: Viesi
lt: Svečiai
el: Καλεσμένοι
tr: Konuklar
he: אורחים
th: แขก
vi: Khách mời
id: Tamu
ms: Tetamu
tl: Mga Bisita
uk: Гості
be: Госці
mk: Гости
sq: Mysafirë
mt: Mistednin
cy: Gwesteion
ga: Aíonna
is: Gestir
calendar.start:
en: Start
@@ -284,44 +62,7 @@ calendar.start:
de: Beginn
it: Inizio
pt: Início
ru: Начало
zh: 开始
ja: 開始
ko: 시작
ar: البداية
hi: प्रारंभ
nl: Begin
sv: Start
da: Start
no: Start
fi: Alkaa
pl: Początek
cs: Začátek
sk: Začiatok
hu: Kezdés
ro: Început
bg: Начало
hr: Početak
sl: Začetek
et: Algus
lv: Sākums
lt: Pradžia
el: Έναρξη
tr: Başlangıç
he: התחלה
th: เริ่ม
vi: Bắt đầu
id: Mulai
ms: Mula
tl: Simula
uk: Початок
be: Пачатак
mk: Почеток
sq: Fillimi
mt: Bidu
cy: Dechrau
ga: Tosaigh
is: Byrjun
calendar.end:
en: End
@@ -330,44 +71,7 @@ calendar.end:
de: Ende
it: Fine
pt: Fim
ru: Конец
zh: 结束
ja: 終了
ko:
ar: النهاية
hi: समाप्ति
nl: Einde
sv: Slut
da: Slut
no: Slutt
fi: Loppu
pl: Koniec
cs: Konec
sk: Koniec
hu: Vége
ro: Sfârșit
bg: Край
hr: Kraj
sl: Konec
et: Lõpp
lv: Beigas
lt: Pabaiga
el: Τέλος
tr: Bitiş
he: סוף
th: สิ้นสุด
vi: Kết thúc
id: Selesai
ms: Tamat
tl: Wakas
uk: Кінець
be: Канец
mk: Крај
sq: Fundi
mt: Tmiem
cy: Diwedd
ga: Deireadh
is: Endir
calendar.location:
en: Location
@@ -376,174 +80,306 @@ calendar.location:
de: Ort
it: Luogo
pt: Local
ru: Место
zh: 地点
ja: 場所
ko: 위치
ar: الموقع
hi: स्थान
nl: Locatie
sv: Plats
da: Sted
no: Sted
fi: Paikka
pl: Miejsce
cs: Místo
sk: Miesto
hu: Helyszín
ro: Locație
bg: Местоположение
hr: Lokacija
sl: Lokacija
et: Asukoht
lv: Atrašanās vieta
lt: Vieta
el: Τοποθεσία
tr: Konum
he: מיקום
th: สถานที่
vi: Địa điểm
id: Lokasi
ms: Lokasi
tl: Lokasyon
uk: Місце
be: Месца
mk: Локација
sq: Vendndodhja
mt: Post
cy: Lleoliad
ga: Suíomh
is: Staðsetning
calendar.date_template:
# English: "Sun May 25, 2025 9am"
en: "%a %b %-d, %Y %-I%P"
# Spanish: "dom 25 may 2025 9h" (day month year hour)
es: "%a %-d %b %Y %-Hh"
# French: "dim 25 mai 2025 9h" (day month year hour)
fr: "%a %-d %b %Y %-Hh"
# German: "So 25. Mai 2025 9 Uhr" (day date month year hour)
de: "%a %-d. %b %Y %-H Uhr"
# Italian: "dom 25 mag 2025 ore 9" (day date month year hour)
it: "%a %-d %b %Y ore %-H"
# Portuguese: "dom 25 mai 2025 9h" (day date month year hour)
pt: "%a %-d %b %Y %-Hh"
# Russian: "вс 25 мая 2025 9:00" (day date month year time)
ru: "%a %-d %b %Y %-H:%M"
# Chinese (Simplified): "2025年5月25日 周日 9时" (year month date weekday hour)
zh: "%Y年%-m月%-d日 %a %-H时"
# Japanese: "2025年5月25日9時" (year month date weekday hour)
ja: "%Y年%-m月%-d日%a%-H時"
# Korean: "2025년 5월 25일 일요일 오전 9시" (year month date weekday AM/PM hour)
ko: "%Y년 %-m월 %-d일 %a %p %-I시"
# Arabic: "الأحد 25 مايو 2025 9 ص" (weekday date month year hour AM/PM)
ar: "%a %-d %b %Y %-I %p"
# Hindi: "रवि 25 मई 2025 सुबह 9 बजे" (weekday date month year morning/evening hour)
hi: "%a %-d %b %Y %p %-I बजे"
# Dutch: "zo 25 mei 2025 9u" (weekday date month year hour)
nl: "%a %-d %b %Y %-Hu"
# Swedish: "sön 25 maj 2025 09:00" (weekday date month year time)
sv: "%a %-d %b %Y %H:%M"
calendar.date_template_long:
# English: "Sunday May 25, 2025 9am"
en: "%A %B %-d, %Y %-I%P"
# Spanish: "domingo 25 mayo 2025 9h" (day month year hour)
es: "%A %-d %B %Y %-Hh"
# French: "dimanche 25 mai 2025 9h" (day month year hour)
fr: "%A %-d %B %Y %-Hh"
# German: "Sonntag 25. Mai 2025 9 Uhr" (day date month year hour)
de: "%A %-d. %B %Y %-H Uhr"
# Italian: "domenica 25 maggio 2025 ore 9" (day date month year hour)
it: "%A %-d %B %Y ore %-H"
# Portuguese: "domingo 25 maio 2025 9h" (day date month year hour)
pt: "%A %-d %B %Y %-Hh"
# Dutch: "zondag 25 mei 2025 9u" (weekday date month year hour)
nl: "%A %-d %B %Y %-Hu"
# Danish: "søn 25. maj 2025 09.00" (weekday date month year time with periods)
da: "%a %-d. %b %Y %H.%M"
calendar.invitation:
en: Invitation
es: Invitación
fr: Invitation
de: Einladung
it: Invito
pt: Convite
nl: Uitnodiging
# Norwegian: "søn 25. mai 2025 09:00" (weekday date month year time)
no: "%a %-d. %b %Y %H:%M"
calendar.updated_invitation:
en: Updated invitation
es: Invitación actualizada
fr: Invitation mise à jour
de: Aktualisierte Einladung
it: Invito aggiornato
pt: Convite atualizado
nl: Bijgewerkte uitnodiging
# Finnish: "su 25. toukokuuta 2025 klo 9.00" (weekday date month year clock time)
fi: "%a %-d. %b %Y klo %-H.%M"
calendar.event_updated:
en: This event has been updated
es: Este evento ha sido actualizado
fr: Cet événement a été mis à jour
de: Dieses Ereignis wurde aktualisiert
it: Questo evento è stato aggiornato
pt: Este evento foi atualizado
nl: Dit evenement is bijgewerkt
# Polish: "ndz 25 maj 2025 9:00" (weekday date month year time)
pl: "%a %-d %b %Y %-H:%M"
calendar.cancelled:
en: Cancelled
es: Cancelado
fr: Annulé
de: Abgesagt
it: Annullato
pt: Cancelado
nl: Geannuleerd
# Czech: "ne 25. 5. 2025 9:00" (weekday date month year time)
cs: "%a %-d. %-m. %Y %-H:%M"
calendar.event_cancelled:
en: This event has been canceled
es: Este evento ha sido cancelado
fr: Cet événement a été annulé
de: Dieses Ereignis wurde abgesagt
it: Questo evento è stato annullato
pt: Este evento foi cancelado
nl: Dit evenement is geannuleerd
# Slovak: "ne 25. 5. 2025 9:00" (weekday date month year time)
sk: "%a %-d. %-m. %Y %-H:%M"
calendar.accepted:
en: Accepted
es: Aceptado
fr: Accepté
de: Angenommen
it: Accettato
pt: Aceito
nl: Geaccepteerd
# Hungarian: "v 2025. 05. 25. 9:00" (weekday year month date time)
hu: "%a %Y. %m. %d. %-H:%M"
calendar.participant_accepted:
en: Participant $name accepted the invitation
es: El participante $name aceptó la invitación
fr: Le participant $name a accepté l'invitation
de: Teilnehmer $name hat die Einladung angenommen
it: Il partecipante $name ha accettato l'invito
pt: O participante $name aceitou o convite
nl: Deelnemer $name heeft de uitnodiging geaccepteerd
# Romanian: "dum 25 mai 2025 9:00" (weekday date month year time)
ro: "%a %-d %b %Y %-H:%M"
calendar.declined:
en: Declined
es: Rechazado
fr: Refusé
de: Abgelehnt
it: Rifiutato
pt: Recusado
nl: Afgewezen
# Bulgarian: "нед 25 май 2025 9:00" (weekday date month year time)
bg: "%a %-d %b %Y %-H:%M"
calendar.participant_declined:
en: Participant $name declined the invitation
es: El participante $name rechazó la invitación
fr: Le participant $name a refusé l'invitation
de: Teilnehmer $name hat die Einladung abgelehnt
it: Il partecipante $name ha rifiutato l'invito
pt: O participante $name recusou o convite
nl: Deelnemer $name heeft de uitnodiging afgewezen
# Croatian: "ned 25. svi 2025 9:00" (weekday date month year time)
hr: "%a %-d. %b %Y %-H:%M"
calendar.tentative:
en: Tentative
es: Provisional
fr: Provisoire
de: Vorläufig
it: Provvisorio
pt: Provisório
nl: Voorlopig
# Slovenian: "ned 25. maj 2025 9:00" (weekday date month year time)
sl: "%a %-d. %b %Y %-H:%M"
calendar.participant_tentative:
en: Participant $name tentatively accepted the invitation
es: El participante $name aceptó provisionalmente la invitación
fr: Le participant $name a accepté provisoirement l'invitation
de: Teilnehmer $name hat die Einladung vorläufig angenommen
it: Il partecipante $name ha accettato provvisoriamente l'invito
pt: O participante $name aceitou provisoriamente o convite
nl: Deelnemer $name heeft de uitnodiging voorlopig geaccepteerd
# Estonian: "P 25. mai 2025 9:00" (weekday date month year time)
et: "%a %-d. %b %Y %-H:%M"
calendar.delegated:
en: Delegated
es: Delegado
fr: Délégué
de: Delegiert
it: Delegato
pt: Delegado
nl: Gedelegeerd
# Latvian: "sv 25. maijs 2025 9:00" (weekday date month year time)
lv: "%a %-d. %b %Y %-H:%M"
calendar.participant_delegated:
en: Participant $name delegated the invitation
es: El participante $name delegó la invitación
fr: Le participant $name a délégué l'invitation
de: Teilnehmer $name hat die Einladung delegiert
it: Il partecipante $name ha delegato l'invito
pt: O participante $name delegou o convite
nl: Deelnemer $name heeft de uitnodiging gedelegeerd
# Lithuanian: "sk 2025 m. gegužės 25 d. 9:00" (weekday year month date time)
lt: "%a %Y m. %b %-d d. %-H:%M"
calendar.reply:
en: Reply
es: Respuesta
fr: Réponse
de: Antwort
it: Risposta
pt: Resposta
nl: Antwoord
# Greek: "Κυρ 25 Μάι 2025 9:00 πμ" (weekday date month year time AM/PM)
el: "%a %-d %b %Y %-I:%M %p"
calendar.participant_reply:
en: Participant $name replied to the invitation
es: El participante $name respondió a la invitación
fr: Le participant $name a répondu à l'invitation
de: Teilnehmer $name hat auf die Einladung geantwortet
it: Il partecipante $name ha risposto all'invito
pt: O participante $name respondeu ao convite
nl: Deelnemer $name heeft gereageerd op de uitnodiging
# Turkish: "Paz 25 May 2025 09:00" (weekday date month year time)
tr: "%a %-d %b %Y %H:%M"
calendar.summary:
en: Summary
es: Resumen
fr: Résumé
de: Zusammenfassung
it: Riepilogo
pt: Resumo
nl: Samenvatting
# Hebrew: "א׳ 25 מאי 2025 9:00" (weekday date month year time)
he: "%a %-d %b %Y %-H:%M"
calendar.description:
en: Description
es: Descripción
fr: Description
de: Beschreibung
it: Descrizione
pt: Descrição
nl: Beschrijving
# Thai: "อา. 25 พ.ค. 2568 9:00 น." (weekday date month Buddhist year time)
th: "%a %-d %b %Y %-H:%M น."
calendar.when:
en: When
es: Cuándo
fr: Quand
de: Wann
it: Quando
pt: Quando
nl: Wanneer
# Vietnamese: "CN 25 thg 5 2025 9:00" (weekday date month year time)
vi: "%a %-d thg %-m %Y %-H:%M"
calendar.changed:
en: Changed
es: Cambiado
fr: Modifié
de: Geändert
it: Modificato
pt: Alterado
nl: Gewijzigd
# Indonesian: "Min 25 Mei 2025 09.00" (weekday date month year time with periods)
id: "%a %-d %b %Y %H.%M"
calendar.reply_as:
en: Reply as $name for this event series
es: Responder como $name para esta serie de eventos
fr: Répondre en tant que $name pour cette série d'événements
de: Als $name für diese Ereignisserie antworten
it: Rispondi come $name per questa serie di eventi
pt: Responder como $name para esta série de eventos
nl: Antwoord als $name voor deze evenementenreeks
# Malaysian: "Ahd 25 Mei 2025 9:00 PG" (weekday date month year time AM/PM)
ms: "%a %-d %b %Y %-I:%M %p"
calendar.yes:
en: Yes
es:
fr: Oui
de: Ja
it:
pt: Sim
nl: Ja
# Filipino/Tagalog: "Lin 25 May 2025 9:00 ng umaga" (weekday date month year time AM/PM)
tl: "%a %-d %b %Y %-I:%M %p"
calendar.no:
en: No
es: No
fr: Non
de: Nein
it: No
pt: Não
nl: Nee
# Ukrainian: "нд 25 тра 2025 9:00" (weekday date month year time)
uk: "%a %-d %b %Y %-H:%M"
calendar.maybe:
en: Maybe
es: Quizás
fr: Peut-être
de: Vielleicht
it: Forse
pt: Talvez
nl: Misschien
# Belarusian: "нд 25 мая 2025 9:00" (weekday date month year time)
be: "%a %-d %b %Y %-H:%M"
calendar.imip_footer_1:
en: You're receiving this e-mail as you're listed as a participant for this event.
es: Recibes este correo electrónico porque estás registrado como participante de este evento.
fr: Vous recevez cet e-mail car vous êtes inscrit comme participant à cet événement.
de: Sie erhalten diese E-Mail, weil Sie als Teilnehmer für dieses Ereignis aufgeführt sind.
it: Ricevi questa e-mail perché sei elencato come partecipante a questo evento.
pt: Você está recebendo este e-mail porque está listado como participante deste evento.
nl: U ontvangt deze e-mail omdat u staat vermeld als deelnemer aan dit evenement.
# Macedonian: "нед 25 мај 2025 9:00" (weekday date month year time)
mk: "%a %-d %b %Y %-H:%M"
calendar.imip_footer_2:
en: Forwarding this e-mail could allow any recipient to reply to the organizer, join the guest list, extend the invitation to others, or alter your RSVP.
es: Reenviar este correo electrónico podría permitir que cualquier destinatario responda al organizador, se una a la lista de invitados, extienda la invitación a otros o modifique tu confirmación de asistencia.
fr: Le transfert de cet e-mail pourrait permettre à tout destinataire de répondre à l'organisateur, de rejoindre la liste des invités, d'étendre l'invitation à d'autres ou de modifier votre RSVP.
de: Das Weiterleiten dieser E-Mail könnte es jedem Empfänger ermöglichen, dem Organisator zu antworten, der Gästeliste beizutreten, die Einladung an andere weiterzugeben oder Ihre Zusage zu ändern.
it: L'inoltro di questa e-mail potrebbe consentire a qualsiasi destinatario di rispondere all'organizzatore, unirsi alla lista degli ospiti, estendere l'invito ad altri o modificare la tua conferma di partecipazione.
pt: Encaminhar este e-mail pode permitir que qualquer destinatário responda ao organizador, se junte à lista de convidados, estenda o convite a outros ou altere sua confirmação de presença.
nl: Het doorsturen van deze e-mail kan elke ontvanger in staat stellen om te reageren op de organisator, deel te nemen aan de gastenlijst, de uitnodiging uit te breiden naar anderen, of uw RSVP te wijzigen.
# Albanian: "Die 25 Maj 2025 9:00" (weekday date month year time)
sq: "%a %-d %b %Y %-H:%M"
calendar.rsvp_recorded:
en: Your RSVP has been recorded.
es: Tu confirmación de asistencia ha sido registrada.
fr: Votre RSVP a été enregistré.
de: Ihre Zusage wurde aufgezeichnet.
it: La tua conferma di partecipazione è stata registrata.
pt: Sua confirmação de presença foi registrada.
nl: Uw RSVP is geregistreerd.
# Maltese: "Ħad 25 Mej 2025 9:00" (weekday date month year time)
mt: "%a %-d %b %Y %-H:%M"
calendar.rsvp_failed:
en: Failed to record your RSVP.
es: No se pudo registrar tu confirmación de asistencia.
fr: Impossible d'enregistrer votre RSVP.
de: Ihre Zusage konnte nicht aufgezeichnet werden.
it: Impossibile registrare la tua conferma di partecipazione.
pt: Falha ao registrar sua confirmação de presença.
nl: Kan uw RSVP niet registreren.
# Welsh: "Sul 25 Mai 2025 9:00" (weekday date month year time)
cy: "%a %-d %b %Y %-H:%M"
calendar.event_not_found:
en: The event you are trying to RSVP to was not found.
es: No se encontró el evento al que intentas confirmar asistencia.
fr: L'événement auquel vous essayez de répondre n'a pas été trouvé.
de: Das Ereignis, für das Sie eine Zusage geben möchten, wurde nicht gefunden.
it: L'evento a cui stai cercando di confermare la partecipazione non è stato trovato.
pt: O evento para o qual você está tentando confirmar presença não foi encontrado.
nl: Het evenement waarvoor u probeert te reageren is niet gevonden.
# Irish: "Domh 25 Beal 2025 9:00" (weekday date month year time)
ga: "%a %-d %b %Y %-H:%M"
calendar.invalid_rsvp:
en: The RSVP request was invalid or malformed.
es: La solicitud de confirmación de asistencia era inválida o estaba mal formada.
fr: La demande de RSVP était invalide ou mal formée.
de: Die Zusage-Anfrage war ungültig oder fehlerhaft.
it: La richiesta di conferma di partecipazione era non valida o mal formata.
pt: A solicitação de confirmação de presença era inválida ou mal formada.
nl: Het RSVP-verzoek was ongeldig of onjuist gevormd.
# Icelandic: "sun 25. maí 2025 09:00" (weekday date month year time)
is: "%a %-d. %b %Y %H:%M"
calendar.not_participant:
en: You are no longer a participant in this event.
es: Ya no eres participante de este evento.
fr: Vous n'êtes plus participant à cet événement.
de: Sie sind kein Teilnehmer dieses Ereignisses mehr.
it: Non sei più un partecipante a questo evento.
pt: Você não é mais um participante deste evento.
nl: U bent geen deelnemer meer aan dit evenement.

View File

@@ -1,6 +1,6 @@
[package]
name = "tests"
version = "0.12.4"
version = "0.12.5"
edition = "2024"
resolver = "2"

View File

@@ -53,7 +53,12 @@ END:VCALENDAR
> expect
from: a@gmail.com
to: b@gmail.com
changes:
summary: invite
summary.attendee: Participants([ItipParticipant { email: "a@gmail.com", name: Some("John Doe"), is_organizer: true }, ItipParticipant { email: "b@gmail.com", name: Some("b@gmail.com"), is_organizer: false }])
summary.description: Text("This is the event description")
summary.dtstart: Time(ItipTime { start: 1750338000, tz_id: 148 })
summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count: Some(2), interval: None, bysecond: [], byminute: [], byhour: [], byday: [ICalendarDay { ordwk: None, weekday: Thursday }], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: Some(Sunday) })
summary.summary: Text("Meet me maybe")
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -162,7 +167,13 @@ END:VCALENDAR
> expect
from: a@gmail.com
to: b@gmail.com
changes: DESCRIPTION
summary: update REQUEST
summary.attendee: Participants([ItipParticipant { email: "a@gmail.com", name: Some("John Doe"), is_organizer: true }, ItipParticipant { email: "b@gmail.com", name: Some("b@gmail.com"), is_organizer: false }])
summary.description: Text("This is the updated event description")
summary.dtstart: Time(ItipTime { start: 1750338000, tz_id: 148 })
summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count: Some(2), interval: None, bysecond: [], byminute: [], byhour: [], byday: [ICalendarDay { ordwk: None, weekday: Thursday }], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: Some(Sunday) })
summary.summary: Text("Meet me maybe")
~summary.description: Text("This is the event description")
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN

View File

@@ -57,7 +57,11 @@ END:VCALENDAR
> expect
from: a@example.com
to: b@example.com
changes:
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: 889034400, tz_id: 32768 })
summary.location: Text("Conference Room A")
summary.summary: Text("Review Accounts")
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -103,7 +107,11 @@ END:VCALENDAR
> send
from: a@example.com
to: b@example.com
changes:
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: 889034400, tz_id: 32768 })
summary.location: Text("Conference Room A")
summary.summary: Text("Review Accounts")
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN

View File

@@ -140,7 +140,10 @@ END:VCALENDAR
> expect
from: a@example.com
to: d@example.com
changes:
summary: invite
summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: Some("B"), is_organizer: false }, ItipParticipant { email: "d@example.com", name: Some("B"), is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 867787200, tz_id: 32768 })
summary.summary: Text("Conference")
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN

View File

@@ -28,7 +28,13 @@ END:VCALENDAR
> expect
from: a@example.com
to: b@example.com, c@example.com, d@example.com
changes:
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 }, ItipParticipant { email: "c@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: None, is_organizer: false }])
summary.description: Text("IETF-C&S Conference Call")
summary.dtstart: Time(ItipTime { start: 865198800, tz_id: 32768 })
summary.location: Text("Conference Call")
summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: Some(PartialDateTime { year: Some(1998), month: Some(9), day: Some(1), hour: Some(21), minute: Some(0), second: Some(0), tz_hour: Some(0), tz_minute: Some(0), tz_minus: false }), count: None, interval: None, bysecond: [], byminute: [], byhour: [], byday: [], bymonthday: [1], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: None })
summary.summary: Text("IETF Calendaring Working Group Meeting")
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -102,7 +108,13 @@ END:VCALENDAR
> expect
from: a@example.com
to: b@example.com, c@example.com, d@example.com
changes:
summary: update ADD
summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: None, is_organizer: false }, ItipParticipant { email: "c@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: None, is_organizer: false }])
summary.description: Text("IETF-C&S Conference Call")
summary.dtstart: Time(ItipTime { start: 865198800, tz_id: 32768 })
summary.location: Text("Conference Call")
summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: Some(PartialDateTime { year: Some(1998), month: Some(9), day: Some(1), hour: Some(21), minute: Some(0), second: Some(0), tz_hour: Some(0), tz_minute: Some(0), tz_minus: false }), count: None, interval: None, bysecond: [], byminute: [], byhour: [], byday: [], bymonthday: [1], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: None })
summary.summary: Text("IETF Calendaring Working Group Meeting")
BEGIN:VCALENDAR
METHOD:ADD
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -186,7 +198,13 @@ END:VCALENDAR
> expect
from: a@example.com
to: b@example.com
changes:
summary: cancel
summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: None, is_organizer: false }, ItipParticipant { email: "c@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: None, is_organizer: false }])
summary.description: Text("IETF-C&S Conference Call")
summary.dtstart: Time(ItipTime { start: 865198800, tz_id: 32768 })
summary.location: Text("Conference Call")
summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: Some(PartialDateTime { year: Some(1998), month: Some(9), day: Some(1), hour: Some(21), minute: Some(0), second: Some(0), tz_hour: Some(0), tz_minute: Some(0), tz_minus: false }), count: None, interval: None, bysecond: [], byminute: [], byhour: [], byday: [], bymonthday: [1], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: None })
summary.summary: Text("IETF Calendaring Working Group Meeting")
BEGIN:VCALENDAR
METHOD:CANCEL
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -331,7 +349,13 @@ END:VCALENDAR
> expect
from: a@example.com
to: b@example.com, c@example.com, d@example.com
changes:
summary: update ADD
summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: None, is_organizer: false }, ItipParticipant { email: "c@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: None, is_organizer: false }])
summary.description: Text("IETF-C&S Conference Call")
summary.dtstart: Time(ItipTime { start: 865198800, tz_id: 32768 })
summary.location: Text("Conference Call")
summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: Some(PartialDateTime { year: Some(1998), month: Some(9), day: Some(1), hour: Some(21), minute: Some(0), second: Some(0), tz_hour: Some(0), tz_minute: Some(0), tz_minus: false }), count: None, interval: None, bysecond: [], byminute: [], byhour: [], byday: [], bymonthday: [1], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: None })
summary.summary: Text("IETF Calendaring Working Group Meeting")
BEGIN:VCALENDAR
METHOD:ADD
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -506,7 +530,12 @@ END:VCALENDAR
> expect
from: a@example.com
to: b@example.com, c@example.com, d@example.com
changes:
summary: cancel
summary.description: Text("IETF-C&S Conference Call")
summary.dtstart: Time(ItipTime { start: 865198800, tz_id: 32768 })
summary.location: Text("Conference Call")
summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: Some(PartialDateTime { year: Some(1998), month: Some(9), day: Some(1), hour: Some(21), minute: Some(0), second: Some(0), tz_hour: Some(0), tz_minute: Some(0), tz_minus: false }), count: None, interval: None, bysecond: [], byminute: [], byhour: [], byday: [], bymonthday: [1], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: None })
summary.summary: Text("IETF Calendaring Working Group Meeting")
BEGIN:VCALENDAR
METHOD:CANCEL
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -635,7 +664,12 @@ END:VCALENDAR
> expect
from: a@example.com
to: b@example.com, c@example.com
changes:
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: 888958800, tz_id: 32768 })
summary.location: Text("The White Room")
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: Some(Sunday) })
summary.summary: Text("Review Accounts")
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -694,7 +728,13 @@ END:VCALENDAR
> expect
from: a@example.com
to: b@example.com
changes: EXDATE, RRULE
summary: update REQUEST
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: 888958800, tz_id: 32768 })
summary.location: Text("The White Room")
summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count: None, interval: None, bysecond: [], byminute: [], byhour: [], byday: [ICalendarDay { ordwk: None, weekday: Tuesday }, ICalendarDay { ordwk: None, weekday: Thursday }], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: Some(Sunday) })
summary.summary: Text("Review Accounts")
~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: Some(Sunday) })
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -717,7 +757,12 @@ END:VCALENDAR
================================
from: a@example.com
to: c@example.com
changes:
summary: cancel
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: 888958800, tz_id: 32768 })
summary.location: Text("The White Room")
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: Some(Sunday) })
summary.summary: Text("Review Accounts")
BEGIN:VCALENDAR
METHOD:CANCEL
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -794,7 +839,11 @@ END:VCALENDAR
> expect
from: b@example.com
to: a@example.com
changes:
summary: rsvp DECLINED
summary.dtstart: Time(ItipTime { start: 888958800, tz_id: 32768 })
summary.location: Text("The White Room")
summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count: None, interval: None, bysecond: [], byminute: [], byhour: [], byday: [ICalendarDay { ordwk: None, weekday: Tuesday }, ICalendarDay { ordwk: None, weekday: Thursday }], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: Some(Sunday) })
summary.summary: Text("Review Accounts")
BEGIN:VCALENDAR
METHOD:REPLY
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN

View File

@@ -26,7 +26,10 @@ END:VCALENDAR
> expect
from: a@example.com
to: b@example.com, c@example.com, d@example.com
changes:
summary: invite
summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: Some("B"), is_organizer: false }, ItipParticipant { email: "c@example.com", name: Some("C"), is_organizer: false }, ItipParticipant { email: "conf_big@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: Some("Hal"), is_organizer: false }, ItipParticipant { email: "e@example.com", name: None, is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 867787200, tz_id: 32768 })
summary.summary: Text("Conference")
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -110,7 +113,10 @@ END:VCALENDAR
> expect
from: b@example.com
to: a@example.com
changes:
summary: rsvp ACCEPTED
summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: Some("B"), is_organizer: false }, ItipParticipant { email: "c@example.com", name: Some("C"), is_organizer: false }, ItipParticipant { email: "conf_big@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: Some("Hal"), is_organizer: false }, ItipParticipant { email: "e@example.com", name: None, is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 867787200, tz_id: 32768 })
summary.summary: Text("Conference")
BEGIN:VCALENDAR
METHOD:REPLY
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -179,7 +185,13 @@ END:VCALENDAR
> expect
from: a@example.com
to: b@example.com, c@example.com, d@example.com
changes: ATTENDEE, DTEND, DTSTART, SUMMARY
summary: update REQUEST
summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: None, is_organizer: false }, ItipParticipant { email: "c@example.com", name: None, is_organizer: false }, ItipParticipant { email: "conf@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: Some("Hal"), is_organizer: false }, ItipParticipant { email: "e@example.com", name: None, is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
summary.summary: Text("Phone Conference")
~summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: Some("B"), is_organizer: false }, ItipParticipant { email: "c@example.com", name: Some("C"), is_organizer: false }, ItipParticipant { email: "conf_big@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: Some("Hal"), is_organizer: false }, ItipParticipant { email: "e@example.com", name: None, is_organizer: false }])
~summary.dtstart: Time(ItipTime { start: 867787200, tz_id: 32768 })
~summary.summary: Text("Conference")
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -262,7 +274,10 @@ END:VCALENDAR
> expect
from: c@example.com
to: a@example.com
changes:
summary: rsvp DELEGATED
summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: None, is_organizer: false }, ItipParticipant { email: "c@example.com", name: None, is_organizer: false }, ItipParticipant { email: "conf@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: Some("Hal"), is_organizer: false }, ItipParticipant { email: "e@example.com", name: None, is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
summary.summary: Text("Phone Conference")
BEGIN:VCALENDAR
METHOD:REPLY
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -282,7 +297,10 @@ END:VCALENDAR
================================
from: c@example.com
to: e@example.com
changes:
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 }, ItipParticipant { email: "c@example.com", name: None, is_organizer: false }, ItipParticipant { email: "conf@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: Some("Hal"), is_organizer: false }, ItipParticipant { email: "e@example.com", name: None, is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
summary.summary: Text("Phone Conference")
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -392,7 +410,10 @@ END:VCALENDAR
> expect
from: e@example.com
to: a@example.com, c@example.com
changes:
summary: rsvp ACCEPTED
summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: None, is_organizer: false }, ItipParticipant { email: "c@example.com", name: None, is_organizer: false }, ItipParticipant { email: "conf@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: Some("Hal"), is_organizer: false }, ItipParticipant { email: "e@example.com", name: None, is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
summary.summary: Text("Phone Conference")
BEGIN:VCALENDAR
METHOD:REPLY
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -493,7 +514,10 @@ END:VCALENDAR
> expect
from: e@example.com
to: a@example.com, c@example.com
changes:
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 }, ItipParticipant { email: "c@example.com", name: None, is_organizer: false }, ItipParticipant { email: "conf@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: Some("Hal"), is_organizer: false }, ItipParticipant { email: "e@example.com", name: None, is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
summary.summary: Text("Phone Conference")
BEGIN:VCALENDAR
METHOD:REPLY
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -593,7 +617,11 @@ END:VCALENDAR
> expect
from: a@example.com
to: c@example.com, d@example.com
changes: ATTENDEE
summary: update REQUEST
summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "c@example.com", name: None, is_organizer: false }, ItipParticipant { email: "conf@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: Some("Hal"), is_organizer: false }, ItipParticipant { email: "e@example.com", name: None, is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
summary.summary: Text("Phone Conference")
~summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: None, is_organizer: false }, ItipParticipant { email: "c@example.com", name: None, is_organizer: false }, ItipParticipant { email: "conf@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: Some("Hal"), is_organizer: false }, ItipParticipant { email: "e@example.com", name: None, is_organizer: false }])
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -620,7 +648,10 @@ END:VCALENDAR
================================
from: a@example.com
to: b@example.com
changes:
summary: cancel
summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: None, is_organizer: false }, ItipParticipant { email: "c@example.com", name: None, is_organizer: false }, ItipParticipant { email: "conf@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: Some("Hal"), is_organizer: false }, ItipParticipant { email: "e@example.com", name: None, is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
summary.summary: Text("Phone Conference")
BEGIN:VCALENDAR
METHOD:CANCEL
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -661,7 +692,9 @@ END:VCALENDAR
> expect
from: a@example.com
to: c@example.com, d@example.com
changes:
summary: cancel
summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
summary.summary: Text("Phone Conference")
BEGIN:VCALENDAR
METHOD:CANCEL
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN

View File

@@ -26,7 +26,10 @@ END:VCALENDAR
> expect
from: a@example.com
to: b@example.com, c@example.com, d@example.com
changes:
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 }, ItipParticipant { email: "c@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: None, is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 })
summary.summary: Text("Create the requirements document")
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -95,7 +98,9 @@ END:VCALENDAR
> expect
from: b@example.com
to: a@example.com
changes:
summary: rsvp ACCEPTED
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: 867776400, tz_id: 32768 })
BEGIN:VCALENDAR
METHOD:REPLY
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -160,7 +165,9 @@ END:VCALENDAR
> expect
from: b@example.com
to: a@example.com
changes:
summary: rsvp IN-PROCESS
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: 867776400, tz_id: 32768 })
BEGIN:VCALENDAR
METHOD:REPLY
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -225,7 +232,9 @@ END:VCALENDAR
> expect
from: d@example.com
to: a@example.com
changes:
summary: rsvp COMPLETED
summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "d@example.com", name: None, is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 })
BEGIN:VCALENDAR
METHOD:REPLY
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -292,7 +301,14 @@ END:VCALENDAR
> expect
from: a@example.com
to: b@example.com, d@example.com
changes: ATTENDEE, DTSTART, DUE, PERCENT-COMPLETE, RRULE, STATUS, SUMMARY
summary: update REQUEST
summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: None, is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 883648800, tz_id: 32768 })
summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: None, count: Some(10), interval: None, bysecond: [], byminute: [], byhour: [], byday: [ICalendarDay { ordwk: Some(1), weekday: Friday }], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: None })
summary.summary: Text("Send Status Reports to Area Managers")
~summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: None, is_organizer: false }, ItipParticipant { email: "c@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: None, is_organizer: false }])
~summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 })
~summary.summary: Text("Create the requirements document")
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -318,7 +334,11 @@ END:VCALENDAR
================================
from: a@example.com
to: c@example.com
changes:
summary: cancel
summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: None, is_organizer: false }, ItipParticipant { email: "c@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: None, is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 })
summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: None, count: Some(10), interval: None, bysecond: [], byminute: [], byhour: [], byday: [ICalendarDay { ordwk: Some(1), weekday: Friday }], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: None })
summary.summary: Text("Create the requirements document")
BEGIN:VCALENDAR
METHOD:CANCEL
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -415,7 +435,11 @@ END:VCALENDAR
> expect
from: b@example.com
to: a@example.com
changes:
summary: rsvp NEEDS-ACTION
summary.attendee: Participants([ItipParticipant { email: "a@example.com", name: None, is_organizer: true }, ItipParticipant { email: "b@example.com", name: None, is_organizer: false }, ItipParticipant { email: "d@example.com", name: None, is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 883648800, tz_id: 32768 })
summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: None, count: Some(10), interval: None, bysecond: [], byminute: [], byhour: [], byday: [ICalendarDay { ordwk: Some(1), weekday: Friday }], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: None })
summary.summary: Text("Send Status Reports to Area Managers")
BEGIN:VCALENDAR
METHOD:REPLY
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN

View File

@@ -40,7 +40,11 @@ END:VCALENDAR
> expect
from: cyrus@example.com
to: bernard@example.net
changes:
summary: invite
summary.attendee: Participants([ItipParticipant { email: "cyrus@example.com", name: Some("Cyrus Daboo"), is_organizer: true }, ItipParticipant { email: "bernard@example.net", name: Some("Bernard Desruisseaux"), is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 1243882800, tz_id: 167 })
summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Daily, until: None, count: Some(5), interval: Some(1), bysecond: [], byminute: [], byhour: [], byday: [], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: None })
summary.summary: Text("Review Internet-Draft")
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -174,7 +178,11 @@ END:VCALENDAR
> expect
from: bernard@example.net
to: cyrus@example.com
changes:
summary: rsvp ACCEPTED
summary.attendee: Participants([ItipParticipant { email: "cyrus@example.com", name: Some("Cyrus Daboo"), is_organizer: true }, ItipParticipant { email: "bernard@example.net", name: Some("Bernard Desruisseaux"), is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 1243882800, tz_id: 167 })
summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Daily, until: None, count: Some(5), interval: Some(1), bysecond: [], byminute: [], byhour: [], byday: [], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: None })
summary.summary: Text("Review Internet-Draft")
BEGIN:VCALENDAR
METHOD:REPLY
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -322,7 +330,11 @@ END:VCALENDAR
> expect
from: bernard@example.net
to: cyrus@example.com
changes:
summary: rsvp DECLINED
summary.attendee: Participants([ItipParticipant { email: "cyrus@example.com", name: Some("Cyrus Daboo"), is_organizer: true }, ItipParticipant { email: "bernard@example.net", name: Some("Bernard Desruisseaux"), is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 1243882800, tz_id: 167 })
summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Daily, until: None, count: Some(5), interval: Some(1), bysecond: [], byminute: [], byhour: [], byday: [], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: None })
summary.summary: Text("Review Internet-Draft")
BEGIN:VCALENDAR
METHOD:REPLY
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN

View File

@@ -24,7 +24,10 @@ END:VCALENDAR
> expect
from: cyrus@example.com
to: bernard@example.net, mike@example.org, wilfredo@example.com
changes:
summary: invite
summary.attendee: Participants([ItipParticipant { email: "cyrus@example.com", name: Some("Cyrus Daboo"), is_organizer: true }, ItipParticipant { email: "bernard@example.net", name: Some("Bernard Desruisseaux"), is_organizer: false }, ItipParticipant { email: "mike@example.org", name: Some("Mike Douglass"), is_organizer: false }, ItipParticipant { email: "wilfredo@example.com", name: Some("Wilfredo Sanchez Vega"), is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 1243958400, tz_id: 32768 })
summary.summary: Text("Lunch")
BEGIN:VCALENDAR
METHOD:REQUEST
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN
@@ -161,7 +164,10 @@ END:VCALENDAR
> expect
from: wilfredo@example.com
to: cyrus@example.com
changes:
summary: rsvp ACCEPTED
summary.attendee: Participants([ItipParticipant { email: "cyrus@example.com", name: Some("Cyrus Daboo"), is_organizer: true }, ItipParticipant { email: "bernard@example.net", name: Some("Bernard Desruisseaux"), is_organizer: false }, ItipParticipant { email: "mike@example.org", name: Some("Mike Douglass"), is_organizer: false }, ItipParticipant { email: "wilfredo@example.com", name: Some("Wilfredo Sanchez Vega"), is_organizer: false }])
summary.dtstart: Time(ItipTime { start: 1243958400, tz_id: 32768 })
summary.summary: Text("Lunch")
BEGIN:VCALENDAR
METHOD:REPLY
PRODID:-//Stalwart Labs LLC//Stalwart Server//EN

View File

@@ -110,7 +110,8 @@ pub async fn test(params: &mut JMAPTest) {
ai_apis: Default::default(),
spam_filter_llm: None,
template_calendar_alarm: None,
template_calendar_invite: None,
template_scheduling_email: None,
template_scheduling_web: None,
}
.into();
config.assert_no_errors();
@@ -180,7 +181,8 @@ impl EnterpriseCore for Core {
ai_apis: Default::default(),
spam_filter_llm: None,
template_calendar_alarm: None,
template_calendar_invite: None,
template_scheduling_email: None,
template_scheduling_web: None,
}
.into();
self

View File

@@ -7,10 +7,10 @@
use ahash::AHashMap;
use calcard::{
common::PartialDateTime,
icalendar::{ICalendar, ICalendarMethod, ICalendarProperty, ICalendarValue},
icalendar::{ICalendar, ICalendarProperty, ICalendarValue},
};
use groupware::scheduling::{
ItipMessage,
ItipMessage, ItipSummary,
event_cancel::itip_cancel,
event_create::itip_create,
event_update::itip_update,
@@ -337,14 +337,13 @@ pub fn test() {
Command::Itip => {
let mut commands = command.parameters.iter();
last_itip = Some(Ok(vec![ItipMessage {
method: ICalendarMethod::Request,
from_organizer: false,
from: commands
.next()
.expect("From parameter is required")
.to_string(),
to: commands.map(|s| s.to_string()).collect::<Vec<_>>(),
changed_properties: vec![],
summary: ItipSummary::Invite(vec![]),
message: ICalendar::parse(&command.payload)
.expect("Failed to parse iCalendar payload"),
}]))
@@ -369,15 +368,45 @@ impl ItipMessageExt for ItipMessage<ICalendar> {
let mut f = String::new();
let mut to = self.to.iter().map(|t| t.as_str()).collect::<Vec<_>>();
to.sort_unstable();
let mut changed = self
.changed_properties
.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>();
changed.sort_unstable();
writeln!(&mut f, "from: {}", self.from).unwrap();
writeln!(&mut f, "to: {}", to.join(", ")).unwrap();
writeln!(&mut f, "changes: {}", changed.join(", ")).unwrap();
write!(&mut f, "summary: ").unwrap();
let mut fields = Vec::new();
match &self.summary {
ItipSummary::Invite(itip_fields) => {
writeln!(&mut f, "invite").unwrap();
fields.push(itip_fields);
}
ItipSummary::Update {
method,
current,
previous,
} => {
writeln!(&mut f, "update {}", method.as_str()).unwrap();
fields.push(current);
fields.push(previous);
}
ItipSummary::Cancel(itip_fields) => {
writeln!(&mut f, "cancel").unwrap();
fields.push(itip_fields);
}
ItipSummary::Rsvp { part_stat, current } => {
writeln!(&mut f, "rsvp {}", part_stat.as_str()).unwrap();
fields.push(current);
}
}
for (pos, fields) in fields.into_iter().enumerate() {
let prefix = if pos > 0 { "~summary." } else { "summary." };
let mut fields = fields
.iter()
.map(|f| format!("{}: {:?}", f.name.as_str().to_lowercase(), f.value))
.collect::<Vec<_>>();
fields.sort_unstable();
for field in fields {
writeln!(&mut f, "{prefix}{}", field).unwrap();
}
}
write!(&mut f, "{}", normalize_ical(self.message.clone(), map)).unwrap();
f
}

View File

@@ -4,18 +4,32 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::WebDavTest;
use crate::{
jmap::mailbox::destroy_all_mailboxes_for_account,
webdav::{DummyWebDavClient, prop::ALL_DAV_PROPERTIES},
};
use super::WebDavTest;
use calcard::{
common::timezone::Tz,
icalendar::{
ICalendarDay, ICalendarFrequency, ICalendarMethod, ICalendarParticipationStatus,
ICalendarProperty, ICalendarRecurrenceRule, ICalendarWeekday,
},
};
use common::{Server, auth::AccessToken};
use dav_proto::schema::property::{CalDavProperty, DavProperty, WebDavProperty};
use email::cache::MessageCacheFetch;
use groupware::cache::GroupwareCache;
use groupware::{
cache::GroupwareCache,
scheduling::{
ArchivedItipSummary, ItipField, ItipParticipant, ItipSummary, ItipTime, ItipValue,
},
};
use hyper::StatusCode;
use jmap_proto::types::collection::SyncCollection;
use mail_parser::{DateTime, MessageParser};
use services::task_manager::{Task, TaskAction, imip::build_itip_template};
use std::str::FromStr;
use store::write::now;
pub async fn test(test: &WebDavTest) {
@@ -341,7 +355,7 @@ pub async fn test(test: &WebDavTest) {
.body
.unwrap();
assert!(
response.contains("Lunch") && response.contains("ACCEPTED"),
response.contains("Lunch") && response.contains("RSVP has been recorded"),
"failed for response: {response}"
);
let cals = fetch_icals(john_client).await;
@@ -578,6 +592,224 @@ async fn fetch_icals(client: &DummyWebDavClient) -> Vec<CalEntry> {
cals
}
pub async fn test_build_itip_templates(server: &Server) {
let dummy_access_token = AccessToken::from_id(0);
let dummy_task = Task {
account_id: 123,
document_id: 156,
due: 0,
action: TaskAction::SendImip,
};
for (idx, summary) in [
ItipSummary::Invite(vec![
ItipField {
name: ICalendarProperty::Summary,
value: ItipValue::Text("Lunch".to_string()),
},
ItipField {
name: ICalendarProperty::Description,
value: ItipValue::Text("Lunch at the cafe".to_string()),
},
ItipField {
name: ICalendarProperty::Location,
value: ItipValue::Text("Cafe Corner".to_string()),
},
ItipField {
name: ICalendarProperty::Dtstart,
value: ItipValue::Time(ItipTime {
start: 1750616068,
tz_id: Tz::from_str("New Zealand").unwrap().as_id(),
}),
},
ItipField {
name: ICalendarProperty::Attendee,
value: ItipValue::Participants(vec![
ItipParticipant {
email: "jdoe@domain.com".to_string(),
name: Some("John Doe".to_string()),
is_organizer: true,
},
ItipParticipant {
email: "jane@domain.com".to_string(),
name: Some("Jane Smith".to_string()),
is_organizer: false,
},
]),
},
]),
ItipSummary::Cancel(vec![
ItipField {
name: ICalendarProperty::Summary,
value: ItipValue::Text("Lunch".to_string()),
},
ItipField {
name: ICalendarProperty::Description,
value: ItipValue::Text("Lunch at the cafe".to_string()),
},
ItipField {
name: ICalendarProperty::Location,
value: ItipValue::Text("Cafe Corner".to_string()),
},
ItipField {
name: ICalendarProperty::Dtstart,
value: ItipValue::Time(ItipTime {
start: 1750616068,
tz_id: Tz::from_str("New Zealand").unwrap().as_id(),
}),
},
]),
ItipSummary::Rsvp {
part_stat: ICalendarParticipationStatus::Accepted,
current: vec![
ItipField {
name: ICalendarProperty::Summary,
value: ItipValue::Text("Lunch".to_string()),
},
ItipField {
name: ICalendarProperty::Description,
value: ItipValue::Text("Lunch at the cafe".to_string()),
},
ItipField {
name: ICalendarProperty::Location,
value: ItipValue::Text("Cafe Corner".to_string()),
},
ItipField {
name: ICalendarProperty::Dtstart,
value: ItipValue::Time(ItipTime {
start: 1750616068,
tz_id: Tz::from_str("New Zealand").unwrap().as_id(),
}),
},
ItipField {
name: ICalendarProperty::Rrule,
value: ItipValue::Rrule(Box::new(ICalendarRecurrenceRule {
freq: ICalendarFrequency::Weekly,
until: None,
count: Some(2),
interval: Some(3),
bysecond: Default::default(),
byday: vec![
ICalendarDay {
ordwk: None,
weekday: ICalendarWeekday::Monday,
},
ICalendarDay {
ordwk: None,
weekday: ICalendarWeekday::Wednesday,
},
],
..Default::default()
})),
},
],
},
ItipSummary::Rsvp {
part_stat: ICalendarParticipationStatus::Declined,
current: vec![
ItipField {
name: ICalendarProperty::Summary,
value: ItipValue::Text("Lunch".to_string()),
},
ItipField {
name: ICalendarProperty::Description,
value: ItipValue::Text("Lunch at the cafe".to_string()),
},
ItipField {
name: ICalendarProperty::Location,
value: ItipValue::Text("Cafe Corner".to_string()),
},
ItipField {
name: ICalendarProperty::Dtstart,
value: ItipValue::Time(ItipTime {
start: 1750616068,
tz_id: Tz::from_str("New Zealand").unwrap().as_id(),
}),
},
],
},
ItipSummary::Update {
method: ICalendarMethod::Request,
current: vec![
ItipField {
name: ICalendarProperty::Summary,
value: ItipValue::Text("Lunch".to_string()),
},
ItipField {
name: ICalendarProperty::Description,
value: ItipValue::Text("Lunch at the cafe".to_string()),
},
ItipField {
name: ICalendarProperty::Location,
value: ItipValue::Text("Cafe Corner".to_string()),
},
ItipField {
name: ICalendarProperty::Dtstart,
value: ItipValue::Time(ItipTime {
start: 1750616068,
tz_id: Tz::from_str("New Zealand").unwrap().as_id(),
}),
},
ItipField {
name: ICalendarProperty::Attendee,
value: ItipValue::Participants(vec![
ItipParticipant {
email: "jdoe@domain.com".to_string(),
name: Some("John Doe".to_string()),
is_organizer: true,
},
ItipParticipant {
email: "jane@domain.com".to_string(),
name: Some("Jane Smith".to_string()),
is_organizer: false,
},
]),
},
],
previous: vec![
ItipField {
name: ICalendarProperty::Summary,
value: ItipValue::Text("Dinner".to_string()),
},
ItipField {
name: ICalendarProperty::Description,
value: ItipValue::Text("Dinner at the cafe".to_string()),
},
ItipField {
name: ICalendarProperty::Dtstart,
value: ItipValue::Time(ItipTime {
start: 1750916068,
tz_id: Tz::from_str("New Zealand").unwrap().as_id(),
}),
},
],
},
]
.into_iter()
.enumerate()
{
let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&summary)
.unwrap()
.to_vec();
let summary = rkyv::access::<ArchivedItipSummary, rkyv::rancor::Error>(&bytes).unwrap();
let html = build_itip_template(
server,
&dummy_access_token,
&dummy_task,
"john.doe@example.org",
"jane.smith@example.net",
summary,
"124",
)
.await;
println!("iTIP template {idx}: {}", html.subject);
std::fs::write(format!("itip_template_{idx}.html"), html.body)
.expect("Failed to write iTIP template to file");
}
}
const TEST_ITIP: &str = r#"BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN

View File

@@ -74,7 +74,9 @@ pub async fn webdav_tests() {
)
.await;
basic::test(&handle).await;
//test_build_itip_templates(&handle.server).await;
/* basic::test(&handle).await;
put_get::test(&handle).await;
mkcol::test(&handle).await;
copy_move::test(&handle).await;
@@ -87,7 +89,7 @@ pub async fn webdav_tests() {
card_query::test(&handle).await;
cal_query::test(&handle).await;
cal_alarm::test(&handle).await;
cal_itip::test();
cal_itip::test();*/
cal_scheduling::test(&handle).await;
// Print elapsed time