mirror of
https://github.com/stalwartlabs/stalwart.git
synced 2026-03-17 14:34:03 +00:00
CalDAV Scheduling (closes #1514)
This commit is contained in:
58
Cargo.lock
generated
58
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "common"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
build = "build.rs"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "dav-proto"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "dav"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "directory"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "email"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "groupware"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "http_proto"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "http"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "imap_proto"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "imap"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jmap_proto"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jmap"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "managesieve"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "migration"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nlp"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pop3"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "services"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}*/
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "spam-filter"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "store"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "trc"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "event_macro"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "utils"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "proc_macros"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
345
resources/html-templates/calendar-invite.html
Normal file
345
resources/html-templates/calendar-invite.html
Normal 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;">
|
||||
</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>
|
||||
1
resources/html-templates/calendar-invite.html.min
Normal file
1
resources/html-templates/calendar-invite.html.min
Normal file
File diff suppressed because one or more lines are too long
132
resources/html-templates/calendar-invite.mjml
Normal file
132
resources/html-templates/calendar-invite.mjml
Normal 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">
|
||||
You’re 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>
|
||||
@@ -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: Sí
|
||||
fr: Oui
|
||||
de: Ja
|
||||
it: Sì
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tests"
|
||||
version = "0.12.4"
|
||||
version = "0.12.5"
|
||||
edition = "2024"
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user