diff --git a/Cargo.lock b/Cargo.lock index b245d3d2..df0381bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1123,6 +1123,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "pure-rust-locales", "serde", "wasm-bindgen", "windows-link", @@ -5690,6 +5691,12 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "pure-rust-locales" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1190fd18ae6ce9e137184f207593877e70f39b015040156b1e05081cdfe3733a" + [[package]] name = "pwhash" version = "1.0.0" @@ -7264,12 +7271,16 @@ dependencies = [ "aes-gcm", "aes-gcm-siv", "base64 0.22.1", + "calcard", + "chrono", "common", "compact_str", "directory", "email", + "groupware", "hkdf", "jmap_proto", + "mail-builder", "mail-parser", "memory-stats", "p256", @@ -7279,6 +7290,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smtp", + "smtp-proto", "store", "tokio", "trc", diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index e6850d2c..17f497d1 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -3,6 +3,7 @@ name = "common" version = "0.12.4" edition = "2024" resolver = "2" +build = "build.rs" [dependencies] utils = { path = "../utils" } diff --git a/crates/common/build.rs b/crates/common/build.rs new file mode 100644 index 00000000..3234d19e --- /dev/null +++ b/crates/common/build.rs @@ -0,0 +1,100 @@ +use std::collections::HashMap; +use std::env; +use std::fs; +use std::path::Path; + +fn main() { + let out_dir = env::var("OUT_DIR").unwrap(); + let dest_path = Path::new(&out_dir).join("locales.rs"); + + // Read the YAML file + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let repo_root = Path::new(&manifest_dir).parent().unwrap().parent().unwrap(); + let yaml_path = repo_root.join("resources/locales/i18n.yml"); + let yaml_content = + fs::read_to_string(&yaml_path).unwrap_or_else(|_| panic!("Failed to read {yaml_path:?}")); + + let locales = parse_yaml(&yaml_content); + + let generated_code = generate_locale_code(&locales); + + fs::write(&dest_path, generated_code).expect("Failed to write generated locales"); + + println!("cargo:rerun-if-changed={yaml_path:?}"); +} + +fn parse_yaml(content: &str) -> HashMap> { + let mut result: HashMap> = HashMap::new(); + let mut current_key = None; + + for line in content.lines() { + if let Some((key, value)) = line.split_once(':') { + let is_translation = key + .as_bytes() + .first() + .is_some_and(|&b| b.is_ascii_whitespace()); + let key = key.trim(); + if !key.starts_with('#') && !key.is_empty() { + if !is_translation { + current_key = result.entry(key.replace('.', "_")).or_default().into(); + } else { + current_key + .as_mut() + .unwrap() + .insert(key.to_string(), value.trim().trim_matches('"').to_string()); + } + } + } + } + + result +} + +fn generate_locale_code(locales: &HashMap>) -> String { + let mut code = String::new(); + + code.push_str("#[derive(Debug, Clone)]\n"); + code.push_str("pub struct Locale {\n"); + + for key in locales.keys() { + code.push_str(&format!(" pub {}: &'static str,\n", key)); + } + + code.push_str("}\n\n"); + + let mut languages = std::collections::HashSet::new(); + for translations in locales.values() { + for lang in translations.keys() { + languages.insert(lang.clone()); + } + } + + for lang in &languages { + code.push_str(&format!( + "pub static {}_LOCALES: Locale = Locale {{\n", + lang.to_uppercase() + )); + + for (key, translations) in locales { + let value = translations + .get(lang) + .unwrap_or_else(|| panic!("Missing: {}", key)); + code.push_str(&format!(" {key}: {value:?},\n")); + } + + code.push_str("};\n\n"); + } + + code.push_str("pub fn locale(name: &str) -> Option<&'static Locale> {\n"); + code.push_str(" hashify::tiny_map!(name.as_bytes(),\n"); + for lang in &languages { + code.push_str(&format!( + " \"{}\" => &{}_LOCALES,\n", + lang, + lang.to_uppercase() + )); + } + code.push_str(" )\n"); + code.push_str("}\n"); + code +} diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 299d778a..f52eeae3 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -6,7 +6,7 @@ use ahash::AHashSet; use directory::{ - Permission, Principal, QueryBy, Type, + Permission, Principal, PrincipalData, QueryBy, Type, backend::internal::{ lookup::DirectoryStore, manage::{ChangedPrincipals, ManageDirectory}, @@ -111,6 +111,13 @@ impl Server { description: principal.description, emails: principal.emails, quota: principal.quota.unwrap_or_default(), + locale: principal.data.iter().find_map(|data| { + if let PrincipalData::Locale(v) = data { + Some(v.to_string()) + } else { + None + } + }), permissions, concurrent_imap_requests: self.core.imap.rate_concurrent.map(ConcurrencyLimiter::new), concurrent_http_requests: self diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index ec7297f2..25eac5ad 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -32,6 +32,7 @@ pub struct AccessToken { pub access_to: VecMap>, pub name: String, pub description: Option, + pub locale: Option, pub emails: Vec, pub quota: u64, pub permissions: Permissions, diff --git a/crates/common/src/config/groupware.rs b/crates/common/src/config/groupware.rs index 7b771d4d..d111deef 100644 --- a/crates/common/src/config/groupware.rs +++ b/crates/common/src/config/groupware.rs @@ -4,9 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::time::Duration; +use std::{str::FromStr, time::Duration}; -use utils::config::Config; +use utils::{config::Config, template::Template}; #[derive(Debug, Clone, Default)] pub struct GroupwareConfig { @@ -24,6 +24,12 @@ pub struct GroupwareConfig { pub max_ical_attendees_per_instance: usize, pub default_calendar_name: Option, pub default_calendar_display_name: Option, + pub alarms_enabled: bool, + pub alarms_minimum_interval: i64, + pub alarms_allow_external_recipients: bool, + pub alarms_from_name: String, + pub alarms_from_email: Option, + pub alarms_template: Template, // Addressbook settings pub max_vcard_size: usize, @@ -34,6 +40,24 @@ pub struct GroupwareConfig { pub max_file_size: usize, } +#[derive(Debug, Clone, PartialEq, Eq, Default, Hash)] +pub enum CalendarTemplateVariable { + #[default] + PageTitle, + Header, + Footer, + EventTitle, + EventDescription, + EventDetails, + ActionUrl, + ActionName, + AttendeesTitle, + Attendees, + Key, + Value, + LogoCid, +} + impl GroupwareConfig { pub fn parse(config: &mut Config) -> Self { GroupwareConfig { @@ -79,6 +103,49 @@ impl GroupwareConfig { max_file_size: config .property("file-storage.max-size") .unwrap_or(25 * 1024 * 1024), + alarms_enabled: config.property("calendar.alarms.enabled").unwrap_or(true), + alarms_minimum_interval: config + .property_or_default::("calendar.alarms.minimum-interval", "1h") + .unwrap_or(Duration::from_secs(60 * 60)) + .as_secs() as i64, + alarms_allow_external_recipients: config + .property("calendar.alarms.allow-external-recipients") + .unwrap_or(false), + alarms_from_name: config + .value("calendar.alarms.from.name") + .unwrap_or("Stalwart Calendar") + .to_string(), + alarms_from_email: config + .value("calendar.alarms.from.email") + .map(|s| s.to_string()), + alarms_template: Template::parse(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../resources/email-templates/calendar-alarm.html" + ))) + .expect("Failed to parse calendar template"), + } + } +} + +impl FromStr for CalendarTemplateVariable { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "page_title" => Ok(CalendarTemplateVariable::PageTitle), + "header" => Ok(CalendarTemplateVariable::Header), + "footer" => Ok(CalendarTemplateVariable::Footer), + "event_title" => Ok(CalendarTemplateVariable::EventTitle), + "event_description" => Ok(CalendarTemplateVariable::EventDescription), + "event_details" => Ok(CalendarTemplateVariable::EventDetails), + "action_url" => Ok(CalendarTemplateVariable::ActionUrl), + "action_name" => Ok(CalendarTemplateVariable::ActionName), + "attendees" => Ok(CalendarTemplateVariable::Attendees), + "attendees_title" => Ok(CalendarTemplateVariable::AttendeesTitle), + "key" => Ok(CalendarTemplateVariable::Key), + "value" => Ok(CalendarTemplateVariable::Value), + "logo_cid" => Ok(CalendarTemplateVariable::LogoCid), + _ => Err(format!("Unknown calendar template variable: {}", s)), } } } diff --git a/crates/common/src/config/mod.rs b/crates/common/src/config/mod.rs index a357fbba..38dbc46e 100644 --- a/crates/common/src/config/mod.rs +++ b/crates/common/src/config/mod.rs @@ -4,8 +4,6 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{str::FromStr, sync::Arc}; - use self::{ imap::ImapConfig, jmap::settings::JmapConfig, scripts::Scripting, smtp::SmtpConfig, storage::Storage, @@ -24,6 +22,7 @@ use hyper::{ }; use ring::signature::{EcdsaKeyPair, RsaKeyPair}; use spamfilter::SpamFilterConfig; +use std::{str::FromStr, sync::Arc}; use store::{BlobBackend, BlobStore, FtsStore, InMemoryStore, Store, Stores}; use telemetry::Metrics; use utils::config::{Config, utils::AsKey}; diff --git a/crates/common/src/core.rs b/crates/common/src/core.rs index 11731ea5..ca90226d 100644 --- a/crates/common/src/core.rs +++ b/crates/common/src/core.rs @@ -471,7 +471,7 @@ impl Server { #[inline(always)] pub fn notify_task_queue(&self) { - self.inner.ipc.index_tx.notify_one(); + self.inner.ipc.task_tx.notify_one(); } pub async fn total_queued_messages(&self) -> trc::Result { diff --git a/crates/common/src/enterprise/config.rs b/crates/common/src/enterprise/config.rs index 0bc30d68..2ef70429 100644 --- a/crates/common/src/enterprise/config.rs +++ b/crates/common/src/enterprise/config.rs @@ -14,10 +14,13 @@ use ahash::AHashMap; use directory::{Type, backend::internal::manage::ManageDirectory}; use store::{Store, Stores}; use trc::{EventType, MetricType, TOTAL_EVENT_COUNT}; -use utils::config::{ - Config, ConfigKey, - cron::SimpleCron, - utils::{AsKey, ParseValue}, +use utils::{ + config::{ + Config, ConfigKey, + cron::SimpleCron, + utils::{AsKey, ParseValue}, + }, + template::Template, }; use crate::{ @@ -193,7 +196,8 @@ impl Enterprise { } } - Some(Enterprise { + // Build the enterprise configuration + let mut enterprise = Enterprise { license, undelete: config .property_or_default::>("storage.undelete.retention", "false") @@ -205,7 +209,32 @@ impl Enterprise { metrics_alerts: parse_metric_alerts(config), spam_filter_llm: SpamFilterLlmConfig::parse(config, &ai_apis), ai_apis, - }) + template_calendar_alarm: None, + template_calendar_invite: None, + }; + + // Parse templates + for (key, value) in [ + ( + "calendar.alarms.template", + &mut enterprise.template_calendar_alarm, + ), + ( + "calendar.scheduling.template", + &mut enterprise.template_calendar_invite, + ), + ] { + if let Some(template) = config.value(key) { + match Template::parse(template) { + Ok(template) => *value = Some(template), + Err(err) => { + config.new_build_error(key, format!("Invalid template: {err}")); + } + } + } + } + + Some(enterprise) } } diff --git a/crates/common/src/enterprise/mod.rs b/crates/common/src/enterprise/mod.rs index 9fc8de49..b37209ff 100644 --- a/crates/common/src/enterprise/mod.rs +++ b/crates/common/src/enterprise/mod.rs @@ -24,9 +24,12 @@ use llm::AiApiConfig; use mail_parser::DateTime; use store::Store; use trc::{AddContext, EventType, MetricType}; -use utils::{HttpLimitResponse, config::cron::SimpleCron}; +use utils::{HttpLimitResponse, config::cron::SimpleCron, template::Template}; -use crate::{Core, Server, expr::Expression, manager::webadmin::Resource}; +use crate::{ + Core, Server, config::groupware::CalendarTemplateVariable, expr::Expression, + manager::webadmin::Resource, +}; #[derive(Clone)] pub struct Enterprise { @@ -38,6 +41,8 @@ pub struct Enterprise { pub metrics_alerts: Vec, pub ai_apis: AHashMap>, pub spam_filter_llm: Option, + pub template_calendar_alarm: Option>, + pub template_calendar_invite: Option>, } #[derive(Debug, Clone)] diff --git a/crates/common/src/i18n.rs b/crates/common/src/i18n.rs new file mode 100644 index 00000000..eb5dd36f --- /dev/null +++ b/crates/common/src/i18n.rs @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +include!(concat!(env!("OUT_DIR"), "/locales.rs")); + +pub fn locale_or_default(name: &str) -> &'static Locale { + locale(name) + .or_else(|| name.split_once('_').and_then(|(lang, _)| locale(lang))) + .unwrap_or(&EN_LOCALES) +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 9b25270b..873383bd 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -54,6 +54,7 @@ pub mod dns; #[cfg(feature = "enterprise")] pub mod enterprise; pub mod expr; +pub mod i18n; pub mod ipc; pub mod listener; pub mod manager; @@ -101,7 +102,7 @@ pub const KV_TRUSTED_REPLY: u8 = 19; pub const KV_LOCK_PURGE_ACCOUNT: u8 = 20; pub const KV_LOCK_QUEUE_MESSAGE: u8 = 21; pub const KV_LOCK_QUEUE_REPORT: u8 = 22; -pub const KV_LOCK_EMAIL_TASK: u8 = 23; +pub const KV_LOCK_TASK: u8 = 23; pub const KV_LOCK_HOUSEKEEPER: u8 = 24; pub const KV_LOCK_DAV: u8 = 25; pub const KV_SIEVE_ID: u8 = 26; @@ -229,7 +230,7 @@ pub struct HttpAuthCache { pub struct Ipc { pub state_tx: mpsc::Sender, pub housekeeper_tx: mpsc::Sender, - pub index_tx: Arc, + pub task_tx: Arc, pub queue_tx: mpsc::Sender, pub report_tx: mpsc::Sender, pub broadcast_tx: Option>, @@ -473,7 +474,7 @@ impl Default for Ipc { Self { state_tx: mpsc::channel(IPC_CHANNEL_BUFFER).0, housekeeper_tx: mpsc::channel(IPC_CHANNEL_BUFFER).0, - index_tx: Default::default(), + task_tx: Default::default(), queue_tx: mpsc::channel(IPC_CHANNEL_BUFFER).0, report_tx: mpsc::channel(IPC_CHANNEL_BUFFER).0, broadcast_tx: None, @@ -816,3 +817,13 @@ impl MailboxCache { self.parent_id == u32::MAX } } + +pub const DEFAULT_LOGO: &str = r#" + + + + + +"#; diff --git a/crates/common/src/manager/boot.rs b/crates/common/src/manager/boot.rs index 4064ff09..c0853b05 100644 --- a/crates/common/src/manager/boot.rs +++ b/crates/common/src/manager/boot.rs @@ -506,7 +506,7 @@ pub fn build_ipc(config: &mut Config, has_pubsub: bool) -> (Ipc, IpcReceivers) { queue_tx, report_tx, broadcast_tx: has_pubsub.then_some(broadcast_tx), - index_tx: Arc::new(Notify::new()), + task_tx: Arc::new(Notify::new()), local_delivery_sm: Arc::new(Semaphore::new( config .property_or_default::("queue.threads.local", "10") diff --git a/crates/common/src/manager/restore.rs b/crates/common/src/manager/restore.rs index 869353de..c5be426f 100644 --- a/crates/common/src/manager/restore.rs +++ b/crates/common/src/manager/restore.rs @@ -17,7 +17,7 @@ use store::{ roaring::RoaringBitmap, write::{ AnyClass, BatchBuilder, BitmapClass, BitmapHash, BlobOp, DirectoryClass, InMemoryClass, - Operation, TagValue, TaskQueueClass, ValueClass, ValueOp, key::DeserializeBigEndian, + Operation, TagValue, TaskQueueClass, ValueClass, ValueOp, key::DeserializeBigEndian, now, }, }; use store::{ @@ -68,7 +68,7 @@ async fn restore_file(store: Store, blob_store: BlobStore, path: &Path) { let mut collection = u8::MAX; let mut family = Family::None; let email_collection = u8::from(Collection::Email); - let mut seq = 0; + let mut due = now(); let mut batch_size = 0; let mut batch = BatchBuilder::new(); @@ -148,12 +148,12 @@ async fn restore_file(store: Store, blob_store: BlobStore, path: &Path) { if reader.version == 1 && collection == email_collection { batch.set( ValueClass::TaskQueue(TaskQueueClass::IndexEmail { - seq, + due, hash: hash.clone(), }), 0u64.serialize(), ); - seq += 1; + due += 1; } batch.set(ValueClass::Blob(BlobOp::Link { hash }), vec![]); } else { diff --git a/crates/dav/src/calendar/copy_move.rs b/crates/dav/src/calendar/copy_move.rs index 7e8c9867..b91560f0 100644 --- a/crates/dav/src/calendar/copy_move.rs +++ b/crates/dav/src/calendar/copy_move.rs @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use calcard::common::timezone::Tz; use common::{DavName, Server, auth::AccessToken}; use dav_proto::{Depth, RequestHeaders}; use groupware::{ @@ -17,7 +18,7 @@ use jmap_proto::types::{ acl::Acl, collection::{Collection, SyncCollection, VanishedCollection}, }; -use store::write::BatchBuilder; +use store::write::{BatchBuilder, now}; use trc::AddContext; use crate::{ @@ -480,6 +481,7 @@ async fn copy_event( ) .caused_by(trc::location!())?; } else { + let next_email_alarm = event.inner.data.next_alarm(now() as i64, Tz::Floating); let mut new_event = event .deserialize::() .caused_by(trc::location!())?; @@ -493,7 +495,13 @@ async fn copy_event( .await .caused_by(trc::location!())?; new_event - .insert(access_token, to_account_id, to_document_id, &mut batch) + .insert( + access_token, + to_account_id, + to_document_id, + next_email_alarm, + &mut batch, + ) .caused_by(trc::location!())?; } @@ -610,6 +618,7 @@ async fn move_event( .caused_by(trc::location!())?; batch.log_vanished_item(VanishedCollection::Calendar, from_resource_path); } else { + let next_email_alarm = event.inner.data.next_alarm(now() as i64, Tz::Floating); let mut new_event = event .deserialize::() .caused_by(trc::location!())?; @@ -635,7 +644,13 @@ async fn move_event( .await .caused_by(trc::location!())?; new_event - .insert(access_token, to_account_id, to_document_id, &mut batch) + .insert( + access_token, + to_account_id, + to_document_id, + next_email_alarm, + &mut batch, + ) .caused_by(trc::location!())?; } @@ -869,6 +884,7 @@ async fn copy_container( ) .caused_by(trc::location!())?; } else { + let next_email_alarm = event.inner.data.next_alarm(now() as i64, Tz::Floating); if remove_source { DestroyArchive(event) .delete( @@ -881,7 +897,6 @@ async fn copy_container( ) .caused_by(trc::location!())?; } - let to_document_id = server .store() .assign_document_ids(to_account_id, Collection::CalendarEvent, 1) @@ -890,7 +905,13 @@ async fn copy_container( new_event.names = vec![new_name]; required_space += new_event.size as u64; new_event - .insert(access_token, to_account_id, to_document_id, &mut batch) + .insert( + access_token, + to_account_id, + to_document_id, + next_email_alarm, + &mut batch, + ) .caused_by(trc::location!())?; } } diff --git a/crates/dav/src/calendar/query.rs b/crates/dav/src/calendar/query.rs index 137cb64a..2a55735d 100644 --- a/crates/dav/src/calendar/query.rs +++ b/crates/dav/src/calendar/query.rs @@ -34,7 +34,10 @@ use http_proto::HttpResponse; use hyper::StatusCode; use jmap_proto::types::{acl::Acl, collection::SyncCollection}; use std::{fmt::Write, slice::Iter, str::FromStr}; -use store::{ahash::AHashMap, write::serialize::rkyv_deserialize}; +use store::{ + ahash::{AHashMap, AHashSet}, + write::serialize::rkyv_deserialize, +}; use trc::AddContext; use super::freebusy::freebusy_in_range; @@ -377,26 +380,27 @@ impl CalendarQueryHandler { .data .alarms .iter() - .map(|alarm| (alarm.comp_id.to_native(), alarm)) - .collect::>(); + .map(|alarm| alarm.parent_id.to_native()) + .collect::>(); !matching_comp_ids.is_empty() - && self.expanded_times.iter().any(|event| { - matching_comp_ids.get(&event.comp_id).is_some_and(|ct| { - ct.alarms.iter().any(|alarm| { - alarm - .to_timestamp( - event.start, - event.end, - self.default_tz, - ) - .is_some_and(|timestamp| { - range.is_in_range( - false, timestamp, timestamp, + && self.expanded_times.iter().any(|time| { + matching_comp_ids.contains(&time.comp_id) + && event.data.alarms.iter().any(|alarm| { + alarm.parent_id.to_native() == time.comp_id + && alarm + .delta + .to_timestamp( + time.start, + time.end, + self.default_tz, ) - }) + .is_some_and(|timestamp| { + range.is_in_range( + false, timestamp, timestamp, + ) + }) }) - }) }) } } diff --git a/crates/dav/src/calendar/update.rs b/crates/dav/src/calendar/update.rs index 3ba5b671..5d6187a9 100644 --- a/crates/dav/src/calendar/update.rs +++ b/crates/dav/src/calendar/update.rs @@ -26,7 +26,7 @@ use jmap_proto::types::{ acl::Acl, collection::{Collection, SyncCollection}, }; -use store::write::BatchBuilder; +use store::write::{BatchBuilder, now}; use trc::AddContext; use crate::{ @@ -173,13 +173,22 @@ impl CalendarUpdateRequestHandler for Server { ))); } - // Build node + // Obtain previous alarm + let prev_email_alarm = event.inner.data.next_alarm(now() as i64, Tz::Floating); + + // Build event + let mut next_email_alarm = None; let mut new_event = event .deserialize::() .caused_by(trc::location!())?; new_event.size = bytes.len() as u32; - new_event.data = - CalendarEventData::new(ical, Tz::Floating, self.core.groupware.max_ical_instances); + new_event.data = CalendarEventData::new( + ical, + Tz::Floating, + self.core.groupware.max_ical_instances, + &mut next_email_alarm, + ); + let has_alarms = next_email_alarm.is_some(); // Prepare write batch let mut batch = BatchBuilder::new(); @@ -187,7 +196,18 @@ impl CalendarUpdateRequestHandler for Server { .update(access_token, event, account_id, document_id, &mut batch) .caused_by(trc::location!())? .etag(); + if prev_email_alarm != next_email_alarm { + if let Some(prev_alarm) = prev_email_alarm { + prev_alarm.delete_task(&mut batch); + } + if let Some(next_alarm) = next_email_alarm { + next_alarm.write_task(&mut batch); + } + } self.commit_batch(batch).await.caused_by(trc::location!())?; + if has_alarms { + self.notify_task_queue(); + } Ok(HttpResponse::new(StatusCode::NO_CONTENT).with_etag_opt(etag)) } else if let Some((Some(parent), name)) = resources.map_parent(resource_name) { @@ -241,7 +261,8 @@ impl CalendarUpdateRequestHandler for Server { ) .await?; - // Build node + // Build event + let mut next_email_alarm = None; let event = CalendarEvent { names: vec![DavName { name: name.to_string(), @@ -251,10 +272,12 @@ impl CalendarUpdateRequestHandler for Server { ical, Tz::Floating, self.core.groupware.max_ical_instances, + &mut next_email_alarm, ), size: bytes.len() as u32, ..Default::default() }; + let has_alarms = next_email_alarm.is_some(); // Prepare write batch let mut batch = BatchBuilder::new(); @@ -264,11 +287,22 @@ impl CalendarUpdateRequestHandler for Server { .await .caused_by(trc::location!())?; let etag = event - .insert(access_token, account_id, document_id, &mut batch) + .insert( + access_token, + account_id, + document_id, + next_email_alarm, + &mut batch, + ) .caused_by(trc::location!())? .etag(); + self.commit_batch(batch).await.caused_by(trc::location!())?; + if has_alarms { + self.notify_task_queue(); + } + Ok(HttpResponse::new(StatusCode::CREATED).with_etag_opt(etag)) } else { Err(DavError::Code(StatusCode::CONFLICT))? diff --git a/crates/directory/src/core/mod.rs b/crates/directory/src/core/mod.rs index 82f4d835..56db2e78 100644 --- a/crates/directory/src/core/mod.rs +++ b/crates/directory/src/core/mod.rs @@ -246,6 +246,7 @@ impl Permission { Permission::DavCalQuery => "Search for calendar entries matching criteria", Permission::DavCalMultiGet => "Retrieve multiple calendar entries in a single request", Permission::DavCalFreeBusyQuery => "Query free/busy time information for scheduling", + Permission::CalendarAlarms => "Receive calendar alarms via e-mail", } } } diff --git a/crates/directory/src/core/principal.rs b/crates/directory/src/core/principal.rs index 0b9c51d8..1b792602 100644 --- a/crates/directory/src/core/principal.rs +++ b/crates/directory/src/core/principal.rs @@ -1407,6 +1407,7 @@ impl Permission { | Permission::DavCalQuery | Permission::DavCalMultiGet | Permission::DavCalFreeBusyQuery + | Permission::CalendarAlarms ) } diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index f0a10858..e5c3999d 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -56,7 +56,7 @@ pub enum PrincipalData { ExternalMembers(Vec), Urls(Vec), PrincipalQuota(Vec), - Language(String), + Locale(String), } #[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] @@ -373,6 +373,8 @@ pub enum Permission { DavCalQuery, DavCalMultiGet, DavCalFreeBusyQuery, + + CalendarAlarms, // WARNING: add new ids at the end (TODO: use static ids) } diff --git a/crates/email/src/message/bayes.rs b/crates/email/src/message/bayes.rs index 522a34f2..8a2cb208 100644 --- a/crates/email/src/message/bayes.rs +++ b/crates/email/src/message/bayes.rs @@ -12,7 +12,7 @@ use mail_parser::Message; use spam_filter::{ SpamFilterInput, analysis::init::SpamFilterInit, modules::bayes::BayesClassifier, }; -use store::write::TaskQueueClass; +use store::write::{TaskQueueClass, now}; use trc::StoreEvent; use utils::BlobHash; @@ -74,7 +74,7 @@ impl EmailBayesTrain for Server { })?; Ok(TaskQueueClass::BayesTrain { - seq: self.generate_snowflake_id(), + due: now(), hash: BlobHash::from(&metadata.unarchive::()?.blob_hash), learn_spam, }) diff --git a/crates/email/src/message/copy.rs b/crates/email/src/message/copy.rs index 71a5753d..0cfe0843 100644 --- a/crates/email/src/message/copy.rs +++ b/crates/email/src/message/copy.rs @@ -25,7 +25,7 @@ use jmap_proto::{ use mail_parser::{HeaderName, HeaderValue, parsers::fields::thread::thread_name}; use store::{ BlobClass, - write::{BatchBuilder, TaskQueueClass, ValueClass}, + write::{BatchBuilder, TaskQueueClass, ValueClass, now}, }; use trc::AddContext; @@ -200,7 +200,7 @@ impl EmailCopy for Server { .caused_by(trc::location!())? .set( ValueClass::TaskQueue(TaskQueueClass::IndexEmail { - seq: self.generate_snowflake_id(), + due: now(), hash: metadata.blob_hash.clone(), }), vec![], diff --git a/crates/email/src/message/ingest.rs b/crates/email/src/message/ingest.rs index 49ee4ad1..e3ddbfe1 100644 --- a/crates/email/src/message/ingest.rs +++ b/crates/email/src/message/ingest.rs @@ -530,7 +530,7 @@ impl EmailIngest for Server { .log_container_insert(SyncCollection::Thread); } - let seq = self.generate_snowflake_id(); + let due = now(); let document_id = self .store() .assign_document_ids(account_id, Collection::Email, 1) @@ -554,7 +554,7 @@ impl EmailIngest for Server { .caused_by(trc::location!())? .set( ValueClass::TaskQueue(TaskQueueClass::IndexEmail { - seq, + due, hash: blob_id.hash.clone(), }), vec![], @@ -564,7 +564,7 @@ impl EmailIngest for Server { if let Some(learn_spam) = train_spam { batch.set( ValueClass::TaskQueue(TaskQueueClass::BayesTrain { - seq, + due, hash: blob_id.hash.clone(), learn_spam, }), diff --git a/crates/groupware/src/calendar/alarm.rs b/crates/groupware/src/calendar/alarm.rs new file mode 100644 index 00000000..484e5e5e --- /dev/null +++ b/crates/groupware/src/calendar/alarm.rs @@ -0,0 +1,261 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{Alarm, AlarmDelta, ArchivedAlarmDelta, ArchivedCalendarEventData}; +use calcard::{ + common::timezone::Tz, + icalendar::{ + ICalendarComponent, ICalendarParameter, ICalendarProperty, ICalendarValue, Related, + }, +}; +use chrono::{DateTime, TimeZone}; +use std::str::FromStr; +use store::write::bitpack::BitpackIterator; +use utils::codec::leb128::Leb128Reader; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct CalendarAlarm { + pub alarm_id: u16, + pub event_id: u16, + pub alarm_time: i64, + pub event_start: i64, + pub event_start_tz: u16, + pub event_end: i64, + pub event_end_tz: u16, +} + +impl ArchivedCalendarEventData { + pub fn next_alarm(&self, start_time: i64, default_tz: Tz) -> Option { + if self.alarms.is_empty() { + return None; + } + + let base_offset = self.base_offset.to_native(); + let mut next_alarm: Option = None; + + 'outer: for range in self.time_ranges.iter() { + let comp_id = range.id.to_native(); + let Some(alarm) = self + .alarms + .iter() + .find(|a| a.is_email_alert && a.parent_id == comp_id) + else { + continue; + }; + + let instances = range.instances.as_ref(); + let (offset_or_count, bytes_read) = instances.read_leb128::()?; + + let duration = range.duration.to_native() as i64; + let mut start_tz = Tz::from_id(range.start_tz.to_native())?; + let mut end_tz = Tz::from_id(range.end_tz.to_native())?; + + if start_tz.is_floating() && !default_tz.is_floating() { + start_tz = default_tz; + } + if end_tz.is_floating() && !default_tz.is_floating() { + end_tz = default_tz; + } + + if instances.len() > bytes_read { + // Recurring event + let unpacker = + BitpackIterator::from_bytes_and_offset(instances, bytes_read, offset_or_count); + for start_offset in unpacker { + let start_date_naive = start_offset as i64 + base_offset; + let end_date_naive = start_date_naive + duration; + let start = start_tz + .from_local_datetime( + &DateTime::from_timestamp(start_date_naive, 0)?.naive_local(), + ) + .single()? + .timestamp(); + let end = end_tz + .from_local_datetime( + &DateTime::from_timestamp(end_date_naive, 0)?.naive_local(), + ) + .single()? + .timestamp(); + + if let Some(alarm_time) = alarm.delta.to_timestamp(start, end, default_tz) { + if alarm_time > start_time { + if let Some(next) = next_alarm { + if alarm_time < next.alarm_time { + next_alarm = Some(CalendarAlarm { + alarm_id: alarm.id.to_native(), + event_id: alarm.parent_id.to_native(), + alarm_time, + event_start: start_date_naive, + event_start_tz: start_tz.as_id(), + event_end: end_date_naive, + event_end_tz: end_tz.as_id(), + }); + } + } else { + next_alarm = Some(CalendarAlarm { + alarm_id: alarm.id.to_native(), + event_id: alarm.parent_id.to_native(), + alarm_time, + event_start: start_date_naive, + event_start_tz: start_tz.as_id(), + event_end: end_date_naive, + event_end_tz: end_tz.as_id(), + }); + } + continue 'outer; + } + } + } + } else { + // Single event + let start_date_naive = offset_or_count as i64 + base_offset; + let end_date_naive = start_date_naive + duration; + let start = start_tz + .from_local_datetime( + &DateTime::from_timestamp(start_date_naive, 0)?.naive_local(), + ) + .single()? + .timestamp(); + let end = end_tz + .from_local_datetime( + &DateTime::from_timestamp(end_date_naive, 0)?.naive_local(), + ) + .single()? + .timestamp(); + + if let Some(alarm_time) = alarm.delta.to_timestamp(start, end, default_tz) { + if alarm_time > start_time { + if let Some(next) = next_alarm { + if alarm_time < next.alarm_time { + next_alarm = Some(CalendarAlarm { + alarm_id: alarm.id.to_native(), + event_id: alarm.parent_id.to_native(), + alarm_time, + event_start: start_date_naive, + event_start_tz: start_tz.as_id(), + event_end: end_date_naive, + event_end_tz: end_tz.as_id(), + }); + } + } else { + next_alarm = Some(CalendarAlarm { + alarm_id: alarm.id.to_native(), + event_id: alarm.parent_id.to_native(), + alarm_time, + event_start: start_date_naive, + event_start_tz: start_tz.as_id(), + event_end: end_date_naive, + event_end_tz: end_tz.as_id(), + }); + } + } + } + } + } + + next_alarm + } +} + +pub trait ExpandAlarm { + fn expand_alarm(&self, id: u16, parent_id: u16) -> Option; +} + +impl ExpandAlarm for ICalendarComponent { + fn expand_alarm(&self, id: u16, parent_id: u16) -> Option { + let mut trigger = None; + let mut is_email_alert = false; + + for entry in self.entries.iter() { + match &entry.name { + ICalendarProperty::Trigger => { + let mut tz = None; + let mut trigger_start = true; + + for param in entry.params.iter() { + match param { + ICalendarParameter::Related(related) => { + trigger_start = matches!(related, Related::Start); + } + ICalendarParameter::Tzid(tz_id) => { + tz = Tz::from_str(tz_id).ok(); + } + _ => {} + } + } + + trigger = match entry.values.first()? { + ICalendarValue::PartialDateTime(dt) => { + let tz = tz.unwrap_or(Tz::Floating); + + dt.to_date_time_with_tz(tz).map(|dt| { + let timestamp = dt.timestamp(); + if !dt.timezone().is_floating() { + AlarmDelta::FixedUtc(timestamp) + } else { + AlarmDelta::FixedFloating(timestamp) + } + }) + } + ICalendarValue::Duration(duration) => { + if trigger_start { + Some(AlarmDelta::Start(duration.as_seconds())) + } else { + Some(AlarmDelta::End(duration.as_seconds())) + } + } + _ => None, + }; + } + ICalendarProperty::Action => { + is_email_alert = entry + .values + .first() + .and_then(|v| v.as_text()) + .is_some_and(|v| v.eq_ignore_ascii_case("email")); + } + _ => {} + } + } + + trigger.map(|delta| Alarm { + id, + parent_id, + delta, + is_email_alert, + }) + } +} + +impl AlarmDelta { + pub fn to_timestamp(&self, start: i64, end: i64, default_tz: Tz) -> Option { + match self { + AlarmDelta::Start(delta) => Some(start + delta), + AlarmDelta::End(delta) => Some(end + delta), + AlarmDelta::FixedUtc(timestamp) => Some(*timestamp), + AlarmDelta::FixedFloating(timestamp) => default_tz + .from_local_datetime(&DateTime::from_timestamp(*timestamp, 0)?.naive_local()) + .single() + .map(|dt| dt.timestamp()), + } + } +} + +impl ArchivedAlarmDelta { + pub fn to_timestamp(&self, start: i64, end: i64, default_tz: Tz) -> Option { + match self { + ArchivedAlarmDelta::Start(delta) => Some(start + delta.to_native()), + ArchivedAlarmDelta::End(delta) => Some(end + delta.to_native()), + ArchivedAlarmDelta::FixedUtc(timestamp) => Some(timestamp.to_native()), + ArchivedAlarmDelta::FixedFloating(timestamp) => default_tz + .from_local_datetime( + &DateTime::from_timestamp(timestamp.to_native(), 0)?.naive_local(), + ) + .single() + .map(|dt| dt.timestamp()), + } + } +} diff --git a/crates/groupware/src/calendar/dates.rs b/crates/groupware/src/calendar/dates.rs index 196c0d2c..2f0720d2 100644 --- a/crates/groupware/src/calendar/dates.rs +++ b/crates/groupware/src/calendar/dates.rs @@ -5,31 +5,29 @@ */ use super::{ - Alarm, AlarmDelta, ArchivedAlarmDelta, ArchivedCalendarEventData, ArchivedTimezone, - CalendarEventData, Timezone, + ArchivedCalendarEventData, ArchivedTimezone, CalendarEventData, Timezone, + alarm::{CalendarAlarm, ExpandAlarm}, }; use crate::calendar::ComponentTimeRange; use calcard::{ common::timezone::Tz, - icalendar::{ - ICalendar, ICalendarComponent, ICalendarParameter, ICalendarProperty, ICalendarValue, - Related, - dates::{CalendarEvent, TimeOrDelta}, - }, + icalendar::{ICalendar, ICalendarComponentType, dates::TimeOrDelta}, }; -use chrono::{DateTime, TimeZone}; use compact_str::ToCompactString; -use dav_proto::schema::property::TimeRange; -use std::str::FromStr; use store::{ ahash::AHashMap, - write::{bitpack::BitpackIterator, key::KeySerializer}, + write::{key::KeySerializer, now}, }; -use utils::codec::leb128::Leb128Reader; impl CalendarEventData { - pub fn new(ical: ICalendar, default_tz: Tz, max_expansions: usize) -> Self { + pub fn new( + ical: ICalendar, + default_tz: Tz, + max_expansions: usize, + next_email_alarm: &mut Option, + ) -> Self { let mut ranges = TimeRanges::default(); + let now = now() as i64; let expanded = ical.expand_dates(default_tz, max_expansions); let mut groups: AHashMap<(u16, u16, u16, i32), Vec> = AHashMap::with_capacity(16); @@ -64,14 +62,25 @@ impl CalendarEventData { // Expand alarms let mut min = std::cmp::min(start_timestamp_utc, end_timestamp_utc); let mut max = std::cmp::max(start_timestamp_utc, end_timestamp_utc); - for alarm_delta in alarms.entry(event.comp_id).or_insert_with(|| { - ical.alarms_for_id(event.comp_id) - .filter_map(|alarm| alarm.expand_alarm()) + for alarm in alarms.entry(event.comp_id).or_insert_with(|| { + ical.component_by_id(event.comp_id) + .map_or(&[][..], |c| c.component_ids.as_slice()) + .iter() + .filter_map(|alarm_id| { + ical.component_by_id(*alarm_id).and_then(|alarm| { + if alarm.component_type == ICalendarComponentType::VAlarm { + alarm.expand_alarm(*alarm_id, event.comp_id) + } else { + None + } + }) + }) .collect::>() - .into_boxed_slice() }) { if let Some(alarm_time) = - alarm_delta.to_timestamp(start_timestamp_utc, end_timestamp_utc, default_tz) + alarm + .delta + .to_timestamp(start_timestamp_utc, end_timestamp_utc, default_tz) { if alarm_time < min { min = alarm_time; @@ -79,6 +88,31 @@ impl CalendarEventData { if alarm_time > max { max = alarm_time; } + if alarm.is_email_alert && alarm_time > now { + if let Some(next) = next_email_alarm { + if alarm_time < next.alarm_time { + *next = CalendarAlarm { + alarm_id: alarm.id, + event_id: alarm.parent_id, + alarm_time, + event_start: start_timestamp_naive, + event_end: end_timestamp_naive, + event_start_tz: start_tz, + event_end_tz: end_tz, + }; + } + } else { + *next_email_alarm = Some(CalendarAlarm { + alarm_id: alarm.id, + event_id: alarm.parent_id, + alarm_time, + event_start: start_timestamp_naive, + event_end: end_timestamp_naive, + event_start_tz: start_tz, + event_end_tz: end_tz, + }); + } + } } } @@ -141,14 +175,8 @@ impl CalendarEventData { event: ical, time_ranges: events.into_boxed_slice(), alarms: alarms - .into_iter() - .filter_map(|(comp_id, alarms)| { - if !alarms.is_empty() { - Some(Alarm { comp_id, alarms }) - } else { - None - } - }) + .into_values() + .flatten() .collect::>() .into_boxed_slice(), base_offset: ranges.base_offset, @@ -166,92 +194,6 @@ impl CalendarEventData { } } -impl ArchivedCalendarEventData { - pub fn expand(&self, default_tz: Tz, limit: TimeRange) -> Option>> { - let mut expansion = Vec::with_capacity(self.time_ranges.len()); - let base_offset = self.base_offset.to_native(); - - 'outer: for range in self.time_ranges.iter() { - let instances = range.instances.as_ref(); - let (offset_or_count, bytes_read) = instances.read_leb128::()?; - - let comp_id = range.id.to_native(); - let duration = range.duration.to_native() as i64; - let mut start_tz = Tz::from_id(range.start_tz.to_native())?; - let mut end_tz = Tz::from_id(range.end_tz.to_native())?; - - if start_tz.is_floating() && !default_tz.is_floating() { - start_tz = default_tz; - } - if end_tz.is_floating() && !default_tz.is_floating() { - end_tz = default_tz; - } - - if instances.len() > bytes_read { - // Recurring event - let unpacker = - BitpackIterator::from_bytes_and_offset(instances, bytes_read, offset_or_count); - for start_offset in unpacker { - let start_date_naive = start_offset as i64 + base_offset; - let end_date_naive = start_date_naive + duration; - let start = start_tz - .from_local_datetime( - &DateTime::from_timestamp(start_date_naive, 0)?.naive_local(), - ) - .single()? - .timestamp(); - let end = end_tz - .from_local_datetime( - &DateTime::from_timestamp(end_date_naive, 0)?.naive_local(), - ) - .single()? - .timestamp(); - - if ((start < limit.end) || (start <= limit.start)) - && (end > limit.start || end >= limit.end) - { - expansion.push(CalendarEvent { - comp_id, - start, - end, - }); - } else if start > limit.end { - continue 'outer; - } - } - } else { - // Single event - let start_date_naive = offset_or_count as i64 + base_offset; - let end_date_naive = start_date_naive + duration; - let start = start_tz - .from_local_datetime( - &DateTime::from_timestamp(start_date_naive, 0)?.naive_local(), - ) - .single()? - .timestamp(); - let end = end_tz - .from_local_datetime( - &DateTime::from_timestamp(end_date_naive, 0)?.naive_local(), - ) - .single()? - .timestamp(); - - if ((start < limit.end) || (start <= limit.start)) - && (end > limit.start || end >= limit.end) - { - expansion.push(CalendarEvent { - comp_id, - start, - end, - }); - } - } - } - - Some(expansion) - } -} - #[derive(Default, Debug)] struct TimeRanges { max_time_utc: i64, @@ -318,85 +260,3 @@ impl ArchivedTimezone { } } } - -pub trait ExpandAlarm { - fn expand_alarm(&self) -> Option; -} - -impl ExpandAlarm for ICalendarComponent { - fn expand_alarm(&self) -> Option { - for entry in self.entries.iter() { - if matches!(entry.name, ICalendarProperty::Trigger) { - let mut tz = None; - let mut trigger_start = true; - - for param in entry.params.iter() { - match param { - ICalendarParameter::Related(related) => { - trigger_start = matches!(related, Related::Start); - } - ICalendarParameter::Tzid(tz_id) => { - tz = Tz::from_str(tz_id).ok(); - } - _ => {} - } - } - - return match entry.values.first()? { - ICalendarValue::PartialDateTime(dt) => { - let tz = tz.unwrap_or(Tz::Floating); - - dt.to_date_time_with_tz(tz).map(|dt| { - let timestamp = dt.timestamp(); - if !dt.timezone().is_floating() { - AlarmDelta::FixedUtc(timestamp) - } else { - AlarmDelta::FixedFloating(timestamp) - } - }) - } - ICalendarValue::Duration(duration) => { - if trigger_start { - Some(AlarmDelta::Start(duration.as_seconds())) - } else { - Some(AlarmDelta::End(duration.as_seconds())) - } - } - _ => None, - }; - } - } - - None - } -} - -impl AlarmDelta { - pub fn to_timestamp(&self, start: i64, end: i64, default_tz: Tz) -> Option { - match self { - AlarmDelta::Start(delta) => Some(start + delta), - AlarmDelta::End(delta) => Some(end + delta), - AlarmDelta::FixedUtc(timestamp) => Some(*timestamp), - AlarmDelta::FixedFloating(timestamp) => default_tz - .from_local_datetime(&DateTime::from_timestamp(*timestamp, 0)?.naive_local()) - .single() - .map(|dt| dt.timestamp()), - } - } -} - -impl ArchivedAlarmDelta { - pub fn to_timestamp(&self, start: i64, end: i64, default_tz: Tz) -> Option { - match self { - ArchivedAlarmDelta::Start(delta) => Some(start + delta.to_native()), - ArchivedAlarmDelta::End(delta) => Some(end + delta.to_native()), - ArchivedAlarmDelta::FixedUtc(timestamp) => Some(timestamp.to_native()), - ArchivedAlarmDelta::FixedFloating(timestamp) => default_tz - .from_local_datetime( - &DateTime::from_timestamp(timestamp.to_native(), 0)?.naive_local(), - ) - .single() - .map(|dt| dt.timestamp()), - } - } -} diff --git a/crates/groupware/src/calendar/expand.rs b/crates/groupware/src/calendar/expand.rs new file mode 100644 index 00000000..68503815 --- /dev/null +++ b/crates/groupware/src/calendar/expand.rs @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use calcard::{common::timezone::Tz, icalendar::dates::CalendarEvent}; +use chrono::{DateTime, TimeZone}; +use dav_proto::schema::property::TimeRange; +use store::write::bitpack::BitpackIterator; +use utils::codec::leb128::Leb128Reader; + +use super::ArchivedCalendarEventData; + +impl ArchivedCalendarEventData { + pub fn expand(&self, default_tz: Tz, limit: TimeRange) -> Option>> { + let mut expansion = Vec::with_capacity(self.time_ranges.len()); + let base_offset = self.base_offset.to_native(); + + 'outer: for range in self.time_ranges.iter() { + let instances = range.instances.as_ref(); + let (offset_or_count, bytes_read) = instances.read_leb128::()?; + + let comp_id = range.id.to_native(); + let duration = range.duration.to_native() as i64; + let mut start_tz = Tz::from_id(range.start_tz.to_native())?; + let mut end_tz = Tz::from_id(range.end_tz.to_native())?; + + if start_tz.is_floating() && !default_tz.is_floating() { + start_tz = default_tz; + } + if end_tz.is_floating() && !default_tz.is_floating() { + end_tz = default_tz; + } + + if instances.len() > bytes_read { + // Recurring event + let unpacker = + BitpackIterator::from_bytes_and_offset(instances, bytes_read, offset_or_count); + for start_offset in unpacker { + let start_date_naive = start_offset as i64 + base_offset; + let end_date_naive = start_date_naive + duration; + let start = start_tz + .from_local_datetime( + &DateTime::from_timestamp(start_date_naive, 0)?.naive_local(), + ) + .single()? + .timestamp(); + let end = end_tz + .from_local_datetime( + &DateTime::from_timestamp(end_date_naive, 0)?.naive_local(), + ) + .single()? + .timestamp(); + + if ((start < limit.end) || (start <= limit.start)) + && (end > limit.start || end >= limit.end) + { + expansion.push(CalendarEvent { + comp_id, + start, + end, + }); + } else if start > limit.end { + continue 'outer; + } + } + } else { + // Single event + let start_date_naive = offset_or_count as i64 + base_offset; + let end_date_naive = start_date_naive + duration; + let start = start_tz + .from_local_datetime( + &DateTime::from_timestamp(start_date_naive, 0)?.naive_local(), + ) + .single()? + .timestamp(); + let end = end_tz + .from_local_datetime( + &DateTime::from_timestamp(end_date_naive, 0)?.naive_local(), + ) + .single()? + .timestamp(); + + if ((start < limit.end) || (start <= limit.start)) + && (end > limit.start || end >= limit.end) + { + expansion.push(CalendarEvent { + comp_id, + start, + end, + }); + } + } + } + + Some(expansion) + } +} diff --git a/crates/groupware/src/calendar/mod.rs b/crates/groupware/src/calendar/mod.rs index 08dfd741..14c66e70 100644 --- a/crates/groupware/src/calendar/mod.rs +++ b/crates/groupware/src/calendar/mod.rs @@ -4,7 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +pub mod alarm; pub mod dates; +pub mod expand; pub mod index; pub mod storage; @@ -88,13 +90,13 @@ pub struct CalendarEventData { pub duration: u32, } -#[derive( - rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, -)] +#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] #[rkyv(compare(PartialEq), derive(Debug))] pub struct Alarm { - pub comp_id: u16, - pub alarms: Box<[AlarmDelta]>, + pub id: u16, + pub parent_id: u16, + pub delta: AlarmDelta, + pub is_email_alert: bool, } #[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Clone, PartialEq, Eq)] diff --git a/crates/groupware/src/calendar/storage.rs b/crates/groupware/src/calendar/storage.rs index 84e12c33..5d169b4d 100644 --- a/crates/groupware/src/calendar/storage.rs +++ b/crates/groupware/src/calendar/storage.rs @@ -4,14 +4,20 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::DestroyArchive; +use crate::{DavResourceName, DestroyArchive}; +use calcard::common::timezone::Tz; use common::{Server, auth::AccessToken, storage::index::ObjectIndexBuilder}; use jmap_proto::types::collection::{Collection, VanishedCollection}; -use store::write::{Archive, BatchBuilder, now}; +use percent_encoding::NON_ALPHANUMERIC; +use store::{ + U16_LEN, U64_LEN, + write::{Archive, BatchBuilder, TaskQueueClass, ValueClass, key::KeySerializer, now}, +}; use trc::AddContext; use super::{ ArchivedCalendar, ArchivedCalendarEvent, Calendar, CalendarEvent, CalendarPreferences, + alarm::CalendarAlarm, }; impl CalendarEvent { @@ -47,6 +53,7 @@ impl CalendarEvent { access_token: &AccessToken, account_id: u32, document_id: u32, + next_alarm: Option, batch: &'x mut BatchBuilder, ) -> trc::Result<&'x mut BatchBuilder> { // Build event @@ -65,7 +72,13 @@ impl CalendarEvent { .with_changes(event) .with_tenant_id(access_token), ) - .map(|b| b.commit_point()) + .map(|batch| { + if let Some(next_alarm) = next_alarm { + next_alarm.write_task(batch); + } + + batch.commit_point() + }) } } @@ -236,8 +249,14 @@ impl DestroyArchive> { .caused_by(trc::location!())?; } else { // Delete event + batch.delete_document(document_id); + + // Remove next alarm if it exists + if let Some(next_alarm) = event.inner.data.next_alarm(now() as i64, Tz::Floating) { + next_alarm.delete_task(batch); + } + batch - .delete_document(document_id) .custom( ObjectIndexBuilder::<_, ()>::new() .with_tenant_id(access_token) @@ -256,3 +275,65 @@ impl DestroyArchive> { Ok(()) } } + +impl CalendarAlarm { + pub fn write_task(&self, batch: &mut BatchBuilder) { + batch.set( + ValueClass::TaskQueue(TaskQueueClass::SendAlarm { + due: self.alarm_time as u64, + event_id: self.event_id, + alarm_id: self.alarm_id, + }), + KeySerializer::new((U64_LEN * 2) + (U16_LEN * 2)) + .write(self.event_start as u64) + .write(self.event_end as u64) + .write(self.event_start_tz) + .write(self.event_end_tz) + .finalize(), + ); + } + + pub fn delete_task(&self, batch: &mut BatchBuilder) { + batch.clear(ValueClass::TaskQueue(TaskQueueClass::SendAlarm { + due: self.alarm_time as u64, + event_id: self.event_id, + alarm_id: self.alarm_id, + })); + } +} + +impl ArchivedCalendarEvent { + pub async fn webcal_uri( + &self, + server: &Server, + access_token: &AccessToken, + ) -> trc::Result { + for event_name in self.names.iter() { + if let Some(calendar_) = server + .get_archive( + access_token.primary_id, + Collection::Calendar, + event_name.parent_id.to_native(), + ) + .await + .caused_by(trc::location!())? + { + let calendar = calendar_ + .unarchive::() + .caused_by(trc::location!())?; + return Ok(format!( + "webcal://{}{}/{}/{}/{}", + server.core.network.server_name, + DavResourceName::Cal.base_path(), + percent_encoding::utf8_percent_encode(&access_token.name, NON_ALPHANUMERIC), + calendar.name, + event_name.name + )); + } + } + + Err(trc::StoreEvent::UnexpectedError + .into_err() + .details("Event is not linked to any calendar")) + } +} diff --git a/crates/http/src/management/stores.rs b/crates/http/src/management/stores.rs index 2cda8d11..1f4c5da4 100644 --- a/crates/http/src/management/stores.rs +++ b/crates/http/src/management/stores.rs @@ -20,7 +20,7 @@ use email::message::{ingest::EmailIngest, metadata::MessageData}; use hyper::Method; use jmap_proto::types::{collection::Collection, property::Property}; use serde_json::json; -use services::index::Indexer; +use services::task_manager::fts::FtsIndexTask; use store::{ Serialize, rand, write::{Archiver, BatchBuilder, ValueClass}, @@ -179,7 +179,7 @@ impl ManageStore for Server { Some("lock-purge-account") => vec![KV_LOCK_PURGE_ACCOUNT].into(), Some("lock-queue-message") => vec![KV_LOCK_QUEUE_MESSAGE].into(), Some("lock-queue-report") => vec![KV_LOCK_QUEUE_REPORT].into(), - Some("lock-email-task") => vec![KV_LOCK_EMAIL_TASK].into(), + Some("lock-email-task") => vec![KV_LOCK_TASK].into(), Some("lock-housekeeper") => vec![KV_LOCK_HOUSEKEEPER].into(), _ => None, }; @@ -228,7 +228,7 @@ impl ManageStore for Server { let jmap = self.clone(); tokio::spawn(async move { - if let Err(err) = jmap.reindex(account_id, tenant_id).await { + if let Err(err) = jmap.fts_reindex(account_id, tenant_id).await { trc::error!(err.details("Failed to reindex FTS")); } }); diff --git a/crates/migration/src/lib.rs b/crates/migration/src/lib.rs index 4fc6f2ca..fa79de91 100644 --- a/crates/migration/src/lib.rs +++ b/crates/migration/src/lib.rs @@ -357,3 +357,29 @@ impl Deserialize for Legac .map(|inner| Self { inner }) } } + +/* + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +pub struct CalendarEventData { + pub event: ICalendar, + pub time_ranges: Box<[ComponentTimeRange]>, + pub alarms: Box<[Alarm]>, + pub base_offset: i64, + pub base_time_utc: u32, + pub duration: u32, +} + +#[derive( + rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default, Clone, PartialEq, Eq, +)] +#[rkyv(compare(PartialEq), derive(Debug))] +pub struct Alarm { + pub comp_id: u16, + pub alarms: Box<[AlarmDelta]>, +} + + +*/ diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml index b79f64bc..6cc75cbb 100644 --- a/crates/services/Cargo.toml +++ b/crates/services/Cargo.toml @@ -11,10 +11,15 @@ utils = { path = "../utils" } trc = { path = "../trc" } email = { path = "../email" } smtp = { path = "../smtp" } +groupware = { path = "../groupware" } jmap_proto = { path = "../jmap-proto" } directory = { path = "../directory" } +smtp-proto = { version = "0.1.6", features = ["rkyv", "serde"] } tokio = { version = "1.45", features = ["rt"] } mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] } +mail-builder = { version = "0.4" } +calcard = { version = "0.1.2", features = ["rkyv"] } +chrono = { version = "0.4", features = ["unstable-locales"] } serde = { version = "1.0", features = ["derive"]} serde_json = "1.0" memory-stats = "1.2.0" diff --git a/crates/services/src/index/mod.rs b/crates/services/src/index/mod.rs deleted file mode 100644 index 8ea36492..00000000 --- a/crates/services/src/index/mod.rs +++ /dev/null @@ -1,511 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - */ - -use std::{sync::Arc, time::Instant}; - -use common::{Inner, KV_LOCK_EMAIL_TASK, Server, core::BuildServer}; -use directory::{Type, backend::internal::manage::ManageDirectory}; -use email::message::{bayes::EmailBayesTrain, index::IndexMessageText, metadata::MessageMetadata}; -use jmap_proto::types::{collection::Collection, property::Property}; -use mail_parser::MessageParser; -use store::{ - IterateParams, SerializeInfallible, U32_LEN, U64_LEN, ValueKey, - ahash::AHashMap, - fts::index::FtsDocument, - roaring::RoaringBitmap, - write::{ - BatchBuilder, BlobOp, TaskQueueClass, ValueClass, - key::{DeserializeBigEndian, KeySerializer}, - now, - }, -}; - -use std::future::Future; -use trc::{AddContext, TaskQueueEvent}; -use utils::{BLOB_HASH_LEN, BlobHash}; - -#[derive(Debug, Clone)] -pub struct EmailTask { - account_id: u32, - document_id: u32, - seq: u64, - hash: BlobHash, - action: EmailTaskAction, -} - -#[derive(Debug, Clone, Copy)] -pub enum EmailTaskAction { - Index, - BayesTrain { learn_spam: bool }, -} - -const FTS_LOCK_EXPIRY: u64 = 60 * 5; -const BAYES_LOCK_EXPIRY: u64 = 60 * 30; - -pub fn spawn_email_queue_task(inner: Arc) { - tokio::spawn(async move { - let rx = inner.ipc.index_tx.clone(); - let mut locked_seq_ids = AHashMap::new(); - loop { - // Index any queued messages - inner - .build_server() - .email_task_queued(&mut locked_seq_ids) - .await; - - // Wait for a signal to index more messages - rx.notified().await; - } - }); -} - -pub trait Indexer: Sync + Send { - fn email_task_queued( - &self, - locked_seq_ids: &mut AHashMap, - ) -> impl Future + Send; - fn try_lock_index(&self, event: &EmailTask) -> impl Future + Send; - fn remove_index_lock(&self, event: &EmailTask) -> impl Future + Send; - fn reindex( - &self, - account_id: Option, - tenant_id: Option, - ) -> impl Future> + Send; -} - -impl Indexer for Server { - async fn email_task_queued(&self, locked_seq_ids: &mut AHashMap) { - let from_key = ValueKey:: { - account_id: 0, - collection: 0, - document_id: 0, - class: ValueClass::TaskQueue(TaskQueueClass::IndexEmail { - seq: 0, - hash: BlobHash::default(), - }), - }; - let to_key = ValueKey:: { - account_id: u32::MAX, - collection: u8::MAX, - document_id: u32::MAX, - class: ValueClass::TaskQueue(TaskQueueClass::IndexEmail { - seq: u64::MAX, - hash: BlobHash::default(), - }), - }; - - // Retrieve entries pending to be indexed - let mut entries = Vec::new(); - let now = Instant::now(); - let _ = self - .core - .storage - .data - .iterate( - IterateParams::new(from_key, to_key).ascending().no_values(), - |key, _| { - let entry = EmailTask::deserialize(key)?; - if locked_seq_ids - .get(&entry.seq) - .is_none_or(|expires| now >= *expires) - { - entries.push(entry); - } - - Ok(true) - }, - ) - .await - .map_err(|err| { - trc::error!( - err.caused_by(trc::location!()) - .details("Failed to iterate over index emails") - ); - }); - - // Add entries to the index - let mut unlock_events = Vec::with_capacity(entries.len()); - for event in entries { - let op_start = Instant::now(); - // Lock index - if !self.try_lock_index(&event).await { - locked_seq_ids.insert( - event.seq, - Instant::now() + std::time::Duration::from_secs(event.lock_expiry() + 1), - ); - continue; - } - - if event.remove_lock() { - unlock_events.push(event.clone()); - } - - // Obtain raw message - let raw_message = if let Ok(Some(raw_message)) = self - .blob_store() - .get_blob(event.hash.as_slice(), 0..usize::MAX) - .await - { - raw_message - } else { - trc::event!( - TaskQueue(TaskQueueEvent::BlobNotFound), - AccountId = event.account_id, - DocumentId = event.document_id, - BlobId = event.hash.as_slice(), - ); - continue; - }; - - match event.action { - EmailTaskAction::Index => { - match self - .get_archive_by_property( - event.account_id, - Collection::Email, - event.document_id, - Property::BodyStructure, - ) - .await - { - Ok(Some(metadata_)) => { - match metadata_.unarchive::() { - Ok(metadata) - if metadata.blob_hash.0.as_slice() == event.hash.as_slice() => - { - // Index message - let document = FtsDocument::with_default_language( - self.core.jmap.default_language, - ) - .with_account_id(event.account_id) - .with_collection(Collection::Email) - .with_document_id(event.document_id) - .index_message(metadata, &raw_message); - if let Err(err) = self.core.storage.fts.index(document).await { - trc::error!( - err.account_id(event.account_id) - .document_id(event.document_id) - .details("Failed to index email in FTS index") - ); - - continue; - } - - trc::event!( - TaskQueue(TaskQueueEvent::Index), - AccountId = event.account_id, - Collection = Collection::Email, - DocumentId = event.document_id, - Elapsed = op_start.elapsed(), - ); - } - Err(err) => { - trc::error!( - err.account_id(event.account_id) - .document_id(event.document_id) - .details("Failed to unarchive email metadata") - ); - } - - _ => { - // The message was probably deleted or overwritten - trc::event!( - TaskQueue(TaskQueueEvent::MetadataNotFound), - Details = "Blob hash mismatch", - AccountId = event.account_id, - DocumentId = event.document_id, - ); - } - } - } - Err(err) => { - trc::error!( - err.account_id(event.account_id) - .document_id(event.document_id) - .caused_by(trc::location!()) - .details("Failed to retrieve email metadata") - ); - - continue; - } - _ => { - // The message was probably deleted or overwritten - trc::event!( - TaskQueue(TaskQueueEvent::MetadataNotFound), - AccountId = event.account_id, - DocumentId = event.document_id, - ); - } - } - } - EmailTaskAction::BayesTrain { learn_spam } => { - // Train bayes classifier for account - self.email_bayes_train( - event.account_id, - 0, - MessageParser::new().parse(&raw_message).unwrap_or_default(), - learn_spam, - ) - .await; - - trc::event!( - TaskQueue(TaskQueueEvent::BayesTrain), - AccountId = event.account_id, - Collection = Collection::Email, - DocumentId = event.document_id, - Elapsed = op_start.elapsed(), - ); - } - } - - // Remove entry from queue - if let Err(err) = self - .core - .storage - .data - .write( - BatchBuilder::new() - .with_account_id(event.account_id) - .with_collection(Collection::Email) - .update_document(event.document_id) - .clear(event.value_class()) - .build_all(), - ) - .await - { - trc::error!( - err.account_id(event.account_id) - .document_id(event.document_id) - .details("Failed to remove index email from queue.") - ); - } - } - - // Unlock entries - for event in unlock_events { - self.remove_index_lock(&event).await; - } - - // Delete expired locks - let now = Instant::now(); - locked_seq_ids.retain(|_, expires| *expires > now); - } - - async fn try_lock_index(&self, event: &EmailTask) -> bool { - match self - .in_memory_store() - .try_lock(KV_LOCK_EMAIL_TASK, &event.lock_key(), event.lock_expiry()) - .await - { - Ok(result) => { - if !result { - trc::event!( - TaskQueue(TaskQueueEvent::Locked), - AccountId = event.account_id, - DocumentId = event.document_id, - Expires = trc::Value::Timestamp(now() + event.lock_expiry()), - ); - } - result - } - Err(err) => { - trc::error!( - err.account_id(event.account_id) - .document_id(event.document_id) - .details("Failed to lock email task") - ); - - false - } - } - } - - async fn remove_index_lock(&self, event: &EmailTask) { - if let Err(err) = self - .in_memory_store() - .remove_lock(KV_LOCK_EMAIL_TASK, &event.lock_key()) - .await - { - trc::error!( - err.details("Failed to unlock email task") - .ctx(trc::Key::Key, event.seq) - .caused_by(trc::location!()) - ); - } - } - - async fn reindex(&self, account_id: Option, tenant_id: Option) -> trc::Result<()> { - let accounts = if let Some(account_id) = account_id { - RoaringBitmap::from_sorted_iter([account_id]).unwrap() - } else { - let mut accounts = RoaringBitmap::new(); - for principal in self - .core - .storage - .data - .list_principals( - None, - tenant_id, - &[Type::Individual, Type::Group], - false, - 0, - 0, - ) - .await - .caused_by(trc::location!())? - .items - { - accounts.insert(principal.id()); - } - accounts - }; - - // Validate linked blobs - let from_key = ValueKey { - account_id: 0, - collection: 0, - document_id: 0, - class: ValueClass::Blob(BlobOp::Link { - hash: BlobHash::default(), - }), - }; - let to_key = ValueKey { - account_id: u32::MAX, - collection: u8::MAX, - document_id: u32::MAX, - class: ValueClass::Blob(BlobOp::Link { - hash: BlobHash::new_max(), - }), - }; - let mut hashes: AHashMap> = AHashMap::new(); - self.core - .storage - .data - .iterate( - IterateParams::new(from_key, to_key).ascending().no_values(), - |key, _| { - let account_id = key.deserialize_be_u32(BLOB_HASH_LEN)?; - let collection = *key - .get(BLOB_HASH_LEN + U32_LEN) - .ok_or_else(|| trc::Error::corrupted_key(key, None, trc::location!()))?; - - if accounts.contains(account_id) && collection == Collection::Email as u8 { - let hash = - BlobHash::try_from_hash_slice(key.get(0..BLOB_HASH_LEN).ok_or_else( - || trc::Error::corrupted_key(key, None, trc::location!()), - )?) - .unwrap(); - let document_id = key.deserialize_be_u32(key.len() - U32_LEN)?; - - hashes - .entry(account_id) - .or_default() - .push((document_id, hash)); - } - - Ok(true) - }, - ) - .await - .caused_by(trc::location!())?; - - let mut seq = self.generate_snowflake_id(); - - for (account_id, hashes) in hashes { - let mut batch = BatchBuilder::new(); - batch - .with_account_id(account_id) - .with_collection(Collection::Email); - - for (document_id, hash) in hashes { - batch.update_document(document_id).set( - ValueClass::TaskQueue(TaskQueueClass::IndexEmail { hash, seq }), - 0u64.serialize(), - ); - seq += 1; - - if batch.len() >= 2000 { - self.core.storage.data.write(batch.build_all()).await?; - batch = BatchBuilder::new(); - batch - .with_account_id(account_id) - .with_collection(Collection::Email); - } - } - - if !batch.is_empty() { - self.core.storage.data.write(batch.build_all()).await?; - } - } - - // Request indexing - self.notify_task_queue(); - - Ok(()) - } -} - -impl EmailTask { - fn remove_lock(&self) -> bool { - matches!(self.action, EmailTaskAction::Index) - } - - fn lock_key(&self) -> Vec { - match self.action { - EmailTaskAction::Index => KeySerializer::new(U64_LEN + 1) - .write(0u8) - .write(self.seq) - .finalize(), - EmailTaskAction::BayesTrain { .. } => KeySerializer::new((U32_LEN * 2) + 1) - .write(1u8) - .write_leb128(self.account_id) - .write_leb128(self.document_id) - .finalize(), - } - } - - fn lock_expiry(&self) -> u64 { - match self.action { - EmailTaskAction::Index => FTS_LOCK_EXPIRY, - EmailTaskAction::BayesTrain { .. } => BAYES_LOCK_EXPIRY, - } - } - - fn value_class(&self) -> ValueClass { - ValueClass::TaskQueue(match self.action { - EmailTaskAction::Index => TaskQueueClass::IndexEmail { - hash: self.hash.clone(), - seq: self.seq, - }, - EmailTaskAction::BayesTrain { learn_spam } => TaskQueueClass::BayesTrain { - hash: self.hash.clone(), - seq: self.seq, - learn_spam, - }, - }) - } - - fn deserialize(key: &[u8]) -> trc::Result { - Ok(EmailTask { - seq: key.deserialize_be_u64(0)?, - account_id: key.deserialize_be_u32(U64_LEN)?, - document_id: key.deserialize_be_u32(U64_LEN + U32_LEN + 1)?, - action: match key.get(U64_LEN + U32_LEN) { - Some(0) => EmailTaskAction::Index, - Some(1) => EmailTaskAction::BayesTrain { learn_spam: true }, - Some(2) => EmailTaskAction::BayesTrain { learn_spam: false }, - _ => return Err(trc::Error::corrupted_key(key, None, trc::location!())), - }, - hash: key - .get( - U64_LEN + U32_LEN + U32_LEN + 1 - ..U64_LEN + U32_LEN + U32_LEN + BLOB_HASH_LEN + 1, - ) - .and_then(|bytes| BlobHash::try_from_hash_slice(bytes).ok()) - .ok_or_else(|| trc::Error::corrupted_key(key, None, trc::location!()))?, - }) - } -} diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs index 761f8fc7..284901ee 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -10,14 +10,14 @@ use common::{ manager::boot::{BootManager, IpcReceivers}, }; use housekeeper::spawn_housekeeper; -use index::spawn_email_queue_task; use state_manager::manager::spawn_state_manager; use std::sync::Arc; +use task_manager::spawn_task_manager; pub mod broadcast; pub mod housekeeper; -pub mod index; pub mod state_manager; +pub mod task_manager; pub trait StartServices: Sync + Send { fn start_services(&mut self) -> impl Future + Send; @@ -62,7 +62,7 @@ impl SpawnServices for IpcReceivers { spawn_broadcast_publisher(inner.clone(), event_rx); } - // Spawn index task - spawn_email_queue_task(inner); + // Spawn task manager + spawn_task_manager(inner); } } diff --git a/crates/services/src/task_manager/alarm.rs b/crates/services/src/task_manager/alarm.rs new file mode 100644 index 00000000..f9fbe6b9 --- /dev/null +++ b/crates/services/src/task_manager/alarm.rs @@ -0,0 +1,516 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::Task; +use calcard::{ + common::timezone::Tz, + icalendar::{ArchivedICalendarParameter, ArchivedICalendarProperty}, +}; +use chrono::{DateTime, Locale}; +use common::{ + DEFAULT_LOGO, Server, + config::groupware::CalendarTemplateVariable, + i18n, + listener::{ServerInstance, stream::NullIo}, +}; +use directory::Permission; +use groupware::calendar::{CalendarEvent, alarm::CalendarAlarm}; +use jmap_proto::types::collection::Collection; +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::{str::FromStr, sync::Arc}; +use store::write::{BatchBuilder, now}; +use trc::{AddContext, TaskQueueEvent}; +use utils::{sanitize_email, template::Variables}; + +pub trait SendAlarmTask: Sync + Send { + fn send_alarm( + &self, + task: &Task, + alarm: &CalendarAlarm, + server_instance: Arc, + ) -> impl Future + Send; +} + +impl SendAlarmTask for Server { + async fn send_alarm( + &self, + task: &Task, + alarm: &CalendarAlarm, + server_instance: Arc, + ) -> bool { + match send_alarm(self, task, alarm, server_instance).await { + Ok(result) => result, + Err(err) => { + trc::error!( + err.account_id(task.account_id) + .document_id(task.document_id) + .caused_by(trc::location!()) + .details("Failed to process alarm") + ); + false + } + } + } +} + +async fn send_alarm( + server: &Server, + task: &Task, + alarm: &CalendarAlarm, + server_instance: Arc, +) -> trc::Result { + // Obtain access token + let access_token = server + .get_access_token(task.account_id) + .await + .caused_by(trc::location!())?; + + if !access_token.has_permission(Permission::CalendarAlarms) { + trc::event!( + Calendar(trc::CalendarEvent::AlarmSkipped), + Reason = "Account does not have permission to send calendar alarms", + AccountId = task.account_id, + DocumentId = task.document_id, + ); + return Ok(true); + } else if access_token.emails.is_empty() { + trc::event!( + Calendar(trc::CalendarEvent::AlarmFailed), + Reason = "Account does not have any email addresses", + AccountId = task.account_id, + DocumentId = task.document_id, + ); + return Ok(true); + } + + // Fetch event + let Some(event_) = server + .get_archive(task.account_id, Collection::CalendarEvent, task.document_id) + .await + .caused_by(trc::location!())? + else { + trc::event!( + TaskQueue(TaskQueueEvent::MetadataNotFound), + Details = "Calendar Event metadata not found", + AccountId = task.account_id, + DocumentId = task.document_id, + ); + + return Ok(true); + }; + + // Unarchive event + let event = event_ + .unarchive::() + .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 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); + + // Obtain logo image + let logo = match server.logo_resource(account_main_domain).await { + Ok(logo) => logo, + Err(err) => { + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to fetch logo image") + ); + None + } + }; + let (logo_content_type, logo_contents) = if let Some(logo) = &logo { + (logo.content_type.as_ref(), logo.contents.as_slice()) + } else { + ("image/svg+xml", DEFAULT_LOGO.as_bytes()) + }; + + // Build message + 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}",) + }; + 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("Auto-Submitted", HeaderType::Text("auto-generated".into())) + .header( + "Reply-To", + HeaderType::Text(account_main_email.as_str().into()), + ) + .subject(subject) + .body(MimePart::new( + ContentType::new("multipart/mixed"), + BodyPart::Multipart(vec![ + MimePart::new( + ContentType::new("multipart/alternative"), + BodyPart::Multipart(vec![ + MimePart::new( + ContentType::new("text/plain"), + BodyPart::Text(txt_body.into()), + ), + MimePart::new( + ContentType::new("text/html"), + BodyPart::Text(html_body.into()), + ), + ]), + ), + MimePart::new( + ContentType::new(logo_content_type), + BodyPart::Binary(logo_contents.into()), + ) + .inline() + .cid(logo_cid), + ]), + )) + .write_to_vec() + .unwrap_or_default(); + + // Send message + let server_ = server.clone(); + let mail_from = account_main_email.clone(); + let result = tokio::spawn(async move { + let mut session = Session::::local( + server_, + server_instance, + SessionData::local(access_token, None, vec![], vec![], 0), + ); + + // MAIL FROM + let _ = session + .handle_mail_from(MailFrom { + address: mail_from, + ..Default::default() + }) + .await; + if let Some(error) = session.has_failed() { + return Err(format!("Server rejected MAIL-FROM: {}", error.trim())); + } + + // RCPT TO + let _ = session + .handle_rcpt_to(RcptTo { + address: rcpt_to, + ..Default::default() + }) + .await; + if let Some(error) = session.has_failed() { + return Err(format!("Server rejected RCPT-TO: {}", error.trim())); + } + + // DATA + session.data.message = message; + let response = session.queue_message().await; + if let smtp::core::State::Accepted(queue_id) = session.state { + Ok(queue_id) + } else { + Err(format!( + "Server rejected DATA: {}", + std::str::from_utf8(&response).unwrap().trim() + )) + } + }) + .await; + + match result { + Ok(Ok(queue_id)) => { + trc::event!( + Calendar(trc::CalendarEvent::AlarmSent), + AccountId = task.account_id, + DocumentId = task.document_id, + QueueId = queue_id, + ); + } + Ok(Err(err)) => { + trc::event!( + Calendar(trc::CalendarEvent::AlarmFailed), + AccountId = task.account_id, + DocumentId = task.document_id, + Reason = err, + ); + } + Err(_) => { + trc::event!( + Server(trc::ServerEvent::ThreadError), + Details = "Join Error", + AccountId = task.account_id, + DocumentId = task.document_id, + CausedBy = trc::location!(), + ); + return Ok(false); + } + } + + // Find next alarm time and write to task queue + let now = now() as i64; + if let Some(next_alarm) = + event + .data + .next_alarm(now, Default::default()) + .and_then(|next_alarm| { + // Verify minimum interval + let max_next_alarm = now + server.core.groupware.alarms_minimum_interval; + if next_alarm.alarm_time < max_next_alarm { + trc::event!( + Calendar(trc::CalendarEvent::AlarmSkipped), + Reason = "Next alarm skipped due to minimum interval", + Details = next_alarm.alarm_time - now, + AccountId = task.account_id, + DocumentId = task.document_id, + ); + event.data.next_alarm(max_next_alarm, Default::default()) + } else { + Some(next_alarm) + } + }) + { + let mut batch = BatchBuilder::new(); + batch + .with_account_id(task.account_id) + .with_collection(Collection::CalendarEvent) + .update_document(task.document_id); + next_alarm.write_task(&mut batch); + server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + } + + Ok(true) +} diff --git a/crates/services/src/task_manager/bayes.rs b/crates/services/src/task_manager/bayes.rs new file mode 100644 index 00000000..a626a3c7 --- /dev/null +++ b/crates/services/src/task_manager/bayes.rs @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Instant; + +use common::Server; +use email::message::bayes::EmailBayesTrain; +use jmap_proto::types::collection::Collection; +use mail_parser::MessageParser; +use trc::{SpamEvent, TaskQueueEvent}; +use utils::BlobHash; + +use super::Task; + +pub trait BayesTrainTask: Sync + Send { + fn bayes_train( + &self, + task: &Task, + hash: &BlobHash, + learn_spam: bool, + ) -> impl Future + Send; +} + +impl BayesTrainTask for Server { + async fn bayes_train(&self, task: &Task, hash: &BlobHash, learn_spam: bool) -> bool { + let op_start = Instant::now(); + // Obtain raw message + if let Ok(Some(raw_message)) = self + .blob_store() + .get_blob(hash.as_slice(), 0..usize::MAX) + .await + { + // Train bayes classifier for account + self.email_bayes_train( + task.account_id, + 0, + MessageParser::new().parse(&raw_message).unwrap_or_default(), + learn_spam, + ) + .await; + + trc::event!( + Spam(SpamEvent::TrainAccount), + AccountId = task.account_id, + Collection = Collection::Email, + DocumentId = task.document_id, + Details = if learn_spam { "spam" } else { "ham" }, + Elapsed = op_start.elapsed(), + ); + true + } else { + trc::event!( + TaskQueue(TaskQueueEvent::BlobNotFound), + AccountId = task.account_id, + DocumentId = task.document_id, + BlobId = hash.as_slice(), + ); + false + } + } +} diff --git a/crates/services/src/task_manager/fts.rs b/crates/services/src/task_manager/fts.rs new file mode 100644 index 00000000..ba85fe74 --- /dev/null +++ b/crates/services/src/task_manager/fts.rs @@ -0,0 +1,247 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Instant; + +use common::Server; +use directory::{Type, backend::internal::manage::ManageDirectory}; +use email::message::{index::IndexMessageText, metadata::MessageMetadata}; +use jmap_proto::types::{collection::Collection, property::Property}; +use store::{ + IterateParams, SerializeInfallible, U32_LEN, ValueKey, + ahash::AHashMap, + fts::index::FtsDocument, + roaring::RoaringBitmap, + write::{BatchBuilder, BlobOp, TaskQueueClass, ValueClass, key::DeserializeBigEndian, now}, +}; +use trc::{AddContext, MessageIngestEvent, TaskQueueEvent}; +use utils::{BLOB_HASH_LEN, BlobHash}; + +use super::Task; + +pub trait FtsIndexTask: Sync + Send { + fn fts_index(&self, task: &Task, hash: &BlobHash) -> impl Future + Send; + fn fts_reindex( + &self, + account_id: Option, + tenant_id: Option, + ) -> impl Future> + Send; +} + +impl FtsIndexTask for Server { + async fn fts_index(&self, task: &Task, hash: &BlobHash) -> bool { + // Obtain raw message + let op_start = Instant::now(); + let raw_message = if let Ok(Some(raw_message)) = self + .blob_store() + .get_blob(hash.as_slice(), 0..usize::MAX) + .await + { + raw_message + } else { + trc::event!( + TaskQueue(TaskQueueEvent::BlobNotFound), + AccountId = task.account_id, + DocumentId = task.document_id, + BlobId = hash.as_slice(), + ); + return false; + }; + + match self + .get_archive_by_property( + task.account_id, + Collection::Email, + task.document_id, + Property::BodyStructure, + ) + .await + { + Ok(Some(metadata_)) => { + match metadata_.unarchive::() { + Ok(metadata) if metadata.blob_hash.0.as_slice() == hash.as_slice() => { + // Index message + let document = + FtsDocument::with_default_language(self.core.jmap.default_language) + .with_account_id(task.account_id) + .with_collection(Collection::Email) + .with_document_id(task.document_id) + .index_message(metadata, &raw_message); + if let Err(err) = self.core.storage.fts.index(document).await { + trc::error!( + err.account_id(task.account_id) + .document_id(task.document_id) + .details("Failed to index email in FTS index") + ); + + return false; + } + + trc::event!( + MessageIngest(MessageIngestEvent::FtsIndex), + AccountId = task.account_id, + Collection = Collection::Email, + DocumentId = task.document_id, + Elapsed = op_start.elapsed(), + ); + } + Err(err) => { + trc::error!( + err.account_id(task.account_id) + .document_id(task.document_id) + .details("Failed to unarchive email metadata") + ); + } + + _ => { + // The message was probably deleted or overwritten + trc::event!( + TaskQueue(TaskQueueEvent::MetadataNotFound), + Details = "E-mail blob hash mismatch", + AccountId = task.account_id, + DocumentId = task.document_id, + ); + } + } + + true + } + Err(err) => { + trc::error!( + err.account_id(task.account_id) + .document_id(task.document_id) + .caused_by(trc::location!()) + .details("Failed to retrieve email metadata") + ); + + false + } + _ => { + // The message was probably deleted or overwritten + trc::event!( + TaskQueue(TaskQueueEvent::MetadataNotFound), + Details = "E-mail metadata not found", + AccountId = task.account_id, + DocumentId = task.document_id, + ); + true + } + } + } + + async fn fts_reindex( + &self, + account_id: Option, + tenant_id: Option, + ) -> trc::Result<()> { + let accounts = if let Some(account_id) = account_id { + RoaringBitmap::from_sorted_iter([account_id]).unwrap() + } else { + let mut accounts = RoaringBitmap::new(); + for principal in self + .core + .storage + .data + .list_principals( + None, + tenant_id, + &[Type::Individual, Type::Group], + false, + 0, + 0, + ) + .await + .caused_by(trc::location!())? + .items + { + accounts.insert(principal.id()); + } + accounts + }; + + // Validate linked blobs + let from_key = ValueKey { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::Blob(BlobOp::Link { + hash: BlobHash::default(), + }), + }; + let to_key = ValueKey { + account_id: u32::MAX, + collection: u8::MAX, + document_id: u32::MAX, + class: ValueClass::Blob(BlobOp::Link { + hash: BlobHash::new_max(), + }), + }; + let mut hashes: AHashMap> = AHashMap::new(); + self.core + .storage + .data + .iterate( + IterateParams::new(from_key, to_key).ascending().no_values(), + |key, _| { + let account_id = key.deserialize_be_u32(BLOB_HASH_LEN)?; + let collection = *key + .get(BLOB_HASH_LEN + U32_LEN) + .ok_or_else(|| trc::Error::corrupted_key(key, None, trc::location!()))?; + + if accounts.contains(account_id) && collection == Collection::Email as u8 { + let hash = + BlobHash::try_from_hash_slice(key.get(0..BLOB_HASH_LEN).ok_or_else( + || trc::Error::corrupted_key(key, None, trc::location!()), + )?) + .unwrap(); + let document_id = key.deserialize_be_u32(key.len() - U32_LEN)?; + + hashes + .entry(account_id) + .or_default() + .push((document_id, hash)); + } + + Ok(true) + }, + ) + .await + .caused_by(trc::location!())?; + + let due = now(); + + for (account_id, hashes) in hashes { + let mut batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::Email); + + for (document_id, hash) in hashes { + batch.update_document(document_id).set( + ValueClass::TaskQueue(TaskQueueClass::IndexEmail { hash, due }), + 0u64.serialize(), + ); + + if batch.len() >= 2000 { + self.core.storage.data.write(batch.build_all()).await?; + batch = BatchBuilder::new(); + batch + .with_account_id(account_id) + .with_collection(Collection::Email); + } + } + + if !batch.is_empty() { + self.core.storage.data.write(batch.build_all()).await?; + } + } + + // Request indexing + self.notify_task_queue(); + + Ok(()) + } +} diff --git a/crates/services/src/task_manager/mod.rs b/crates/services/src/task_manager/mod.rs new file mode 100644 index 00000000..51104c06 --- /dev/null +++ b/crates/services/src/task_manager/mod.rs @@ -0,0 +1,419 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use alarm::SendAlarmTask; +use bayes::BayesTrainTask; +use common::config::server::ServerProtocol; +use common::listener::limiter::ConcurrencyLimiter; +use common::listener::{ServerInstance, TcpAcceptor}; +use common::{IPC_CHANNEL_BUFFER, LONG_1Y_SLUMBER}; +use common::{Inner, KV_LOCK_TASK, Server, core::BuildServer}; +use fts::FtsIndexTask; +use groupware::calendar::alarm::CalendarAlarm; +use jmap_proto::types::collection::Collection; +use std::collections::hash_map::Entry; +use std::future::Future; +use std::time::Duration; +use std::{sync::Arc, time::Instant}; +use store::rand; +use store::rand::seq::SliceRandom; +use store::{ + IterateParams, U16_LEN, U32_LEN, U64_LEN, ValueKey, + ahash::AHashMap, + write::{ + BatchBuilder, TaskQueueClass, ValueClass, + key::{DeserializeBigEndian, KeySerializer}, + now, + }, +}; +use tokio::sync::{mpsc, watch}; +use trc::TaskQueueEvent; +use utils::snowflake::SnowflakeIdGenerator; +use utils::{BLOB_HASH_LEN, BlobHash}; + +pub mod alarm; +pub mod bayes; +pub mod fts; + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct Task { + account_id: u32, + document_id: u32, + due: u64, + action: TaskAction, +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub enum TaskAction { + Index { hash: BlobHash }, + BayesTrain { hash: BlobHash, learn_spam: bool }, + SendAlarm { alarm: CalendarAlarm }, +} + +const FTS_LOCK_EXPIRY: u64 = 60 * 5; +const BAYES_LOCK_EXPIRY: u64 = 60 * 30; +const ALARM_EXPIRY: u64 = 60 * 2; + +pub(crate) struct TaskManagerIpc { + tx_fts: mpsc::Sender, + tx_bayes: mpsc::Sender, + tx_alarm: mpsc::Sender, + locked: AHashMap, Instant>, +} + +pub fn spawn_task_manager(inner: Arc) { + // Create three mpsc channels for the different task types + let (tx_index_1, rx_index_1) = mpsc::channel::(IPC_CHANNEL_BUFFER); + let (tx_index_2, rx_index_2) = mpsc::channel::(IPC_CHANNEL_BUFFER); + let (tx_index_3, rx_index_3) = mpsc::channel::(IPC_CHANNEL_BUFFER); + + // Create dummy server instance for alarms + let server_instance = Arc::new(ServerInstance { + id: "_local".to_string(), + protocol: ServerProtocol::Smtp, + acceptor: TcpAcceptor::Plain, + limiter: ConcurrencyLimiter::new(100), + shutdown_rx: watch::channel(false).1, + proxy_networks: vec![], + span_id_gen: Arc::new(SnowflakeIdGenerator::new()), + }); + + for mut rx_index in [rx_index_1, rx_index_2, rx_index_3] { + let inner = inner.clone(); + let server_instance = server_instance.clone(); + + tokio::spawn(async move { + while let Some(task) = rx_index.recv().await { + let server = inner.build_server(); + // Lock task + if server.try_lock_task(&task).await { + let success = match &task.action { + TaskAction::Index { hash } => server.fts_index(&task, hash).await, + TaskAction::BayesTrain { hash, learn_spam } => { + server.bayes_train(&task, hash, *learn_spam).await + } + TaskAction::SendAlarm { alarm } => { + if server.core.groupware.alarms_enabled { + server + .send_alarm(&task, alarm, server_instance.clone()) + .await + } else { + true + } + } + }; + + // Remove entry from queue + if success { + if let Err(err) = server + .core + .storage + .data + .write( + BatchBuilder::new() + .with_account_id(task.account_id) + .with_collection(Collection::Email) + .update_document(task.document_id) + .clear(task.value_class()) + .build_all(), + ) + .await + { + trc::error!( + err.account_id(task.account_id) + .document_id(task.document_id) + .details("Failed to remove task from queue.") + ); + } + + if task.remove_lock() { + server.remove_index_lock(&task).await; + } + } + } + } + }); + } + + tokio::spawn(async move { + let mut ipc = TaskManagerIpc { + tx_fts: tx_index_1, + tx_bayes: tx_index_2, + tx_alarm: tx_index_3, + locked: Default::default(), + }; + let rx = inner.ipc.task_tx.clone(); + loop { + // Index any queued tasks + let sleep_for = inner.build_server().process_tasks(&mut ipc).await; + + // Wait for a signal or sleep until the next task is due + let _ = tokio::time::timeout(sleep_for, rx.notified()).await; + } + }); +} + +pub(crate) trait TaskQueueManager: Sync + Send { + fn process_tasks(&self, ipc: &mut TaskManagerIpc) -> impl Future + Send; + fn try_lock_task(&self, event: &Task) -> impl Future + Send; + fn remove_index_lock(&self, event: &Task) -> impl Future + Send; +} + +impl TaskQueueManager for Server { + async fn process_tasks(&self, ipc: &mut TaskManagerIpc) -> Duration { + let from_key = ValueKey:: { + account_id: 0, + collection: 0, + document_id: 0, + class: ValueClass::TaskQueue(TaskQueueClass::IndexEmail { + due: 0, + hash: BlobHash::default(), + }), + }; + let to_key = ValueKey:: { + account_id: u32::MAX, + collection: u8::MAX, + document_id: u32::MAX, + class: ValueClass::TaskQueue(TaskQueueClass::IndexEmail { + due: u64::MAX, + hash: BlobHash::default(), + }), + }; + + // Retrieve tasks pending to be processed + let mut tasks = Vec::new(); + let now_timestamp = now(); + let now = Instant::now(); + let mut next_event = None; + let _ = self + .core + .storage + .data + .iterate( + IterateParams::new(from_key, to_key).ascending(), + |key, value| { + let task = Task::deserialize(key, value)?; + if task.due <= now_timestamp { + match ipc.locked.entry(key.to_vec()) { + Entry::Occupied(mut entry) => { + let expires = entry.get_mut(); + if *expires <= now { + *expires = Instant::now() + + std::time::Duration::from_secs(task.lock_expiry() + 1); + tasks.push(task); + } + } + Entry::Vacant(entry) => { + entry.insert( + Instant::now() + + std::time::Duration::from_secs(task.lock_expiry() + 1), + ); + tasks.push(task); + } + } + + Ok(true) + } else { + next_event = Some(task.due); + Ok(false) + } + }, + ) + .await + .map_err(|err| { + trc::error!( + err.caused_by(trc::location!()) + .details("Failed to iterate over index emails") + ); + }); + + if !tasks.is_empty() || !ipc.locked.is_empty() { + trc::event!( + TaskQueue(TaskQueueEvent::TaskAcquired), + Total = tasks.len(), + Details = ipc.locked.len(), + ); + } + + // Shuffle tasks + if tasks.len() > 1 { + tasks.shuffle(&mut rand::rng()); + } + + for event in tasks { + let tx = match &event.action { + TaskAction::Index { .. } => &ipc.tx_fts, + TaskAction::BayesTrain { .. } => &ipc.tx_bayes, + TaskAction::SendAlarm { .. } => &ipc.tx_alarm, + }; + if tx.send(event).await.is_err() { + trc::event!( + Server(trc::ServerEvent::ThreadError), + Details = "Error sending task.", + CausedBy = trc::location!() + ); + } + } + + // Delete expired locks + let now = Instant::now(); + ipc.locked.retain(|_, expires| *expires > now); + next_event.map_or(LONG_1Y_SLUMBER, |timestamp| { + Duration::from_secs(timestamp.saturating_sub(store::write::now())) + }) + } + + async fn try_lock_task(&self, event: &Task) -> bool { + match self + .in_memory_store() + .try_lock(KV_LOCK_TASK, &event.lock_key(), event.lock_expiry()) + .await + { + Ok(result) => { + if !result { + trc::event!( + TaskQueue(TaskQueueEvent::TaskLocked), + AccountId = event.account_id, + DocumentId = event.document_id, + Expires = trc::Value::Timestamp(now() + event.lock_expiry()), + ); + } + result + } + Err(err) => { + trc::error!( + err.account_id(event.account_id) + .document_id(event.document_id) + .details("Failed to lock task") + ); + + false + } + } + } + + async fn remove_index_lock(&self, event: &Task) { + let key = event.lock_key(); + if let Err(err) = self.in_memory_store().remove_lock(KV_LOCK_TASK, &key).await { + trc::error!( + err.details("Failed to unlock task") + .ctx(trc::Key::Key, key) + .caused_by(trc::location!()) + ); + } + } +} + +impl Task { + fn remove_lock(&self) -> bool { + // Bayes locks are not removed to avoid constant retraining + matches!( + self.action, + TaskAction::Index { .. } | TaskAction::SendAlarm { .. } + ) + } + + fn lock_key(&self) -> Vec { + match &self.action { + TaskAction::Index { .. } => KeySerializer::new((U32_LEN * 2) + U64_LEN + 1) + .write(0u8) + .write(self.due) + .write_leb128(self.account_id) + .write_leb128(self.document_id) + .finalize(), + TaskAction::BayesTrain { .. } => KeySerializer::new((U32_LEN * 2) + 1) + .write(1u8) + .write_leb128(self.account_id) + .write_leb128(self.document_id) + .finalize(), + TaskAction::SendAlarm { .. } => KeySerializer::new((U32_LEN * 2) + U64_LEN + 1) + .write(2u8) + .write(self.due) + .write_leb128(self.account_id) + .write_leb128(self.document_id) + .finalize(), + } + } + + fn lock_expiry(&self) -> u64 { + match self.action { + TaskAction::Index { .. } => FTS_LOCK_EXPIRY, + TaskAction::BayesTrain { .. } => BAYES_LOCK_EXPIRY, + TaskAction::SendAlarm { .. } => ALARM_EXPIRY, + } + } + + fn value_class(&self) -> ValueClass { + ValueClass::TaskQueue(match &self.action { + TaskAction::Index { hash } => TaskQueueClass::IndexEmail { + hash: hash.clone(), + due: self.due, + }, + TaskAction::BayesTrain { hash, learn_spam } => TaskQueueClass::BayesTrain { + hash: hash.clone(), + due: self.due, + learn_spam: *learn_spam, + }, + TaskAction::SendAlarm { alarm } => TaskQueueClass::SendAlarm { + event_id: alarm.event_id, + alarm_id: alarm.alarm_id, + due: self.due, + }, + }) + } + + fn deserialize(key: &[u8], value: &[u8]) -> trc::Result { + Ok(Task { + due: key.deserialize_be_u64(0)?, + account_id: key.deserialize_be_u32(U64_LEN)?, + document_id: key.deserialize_be_u32(U64_LEN + U32_LEN + 1)?, + action: match key.get(U64_LEN + U32_LEN) { + Some(0) => TaskAction::Index { + hash: key + .get( + U64_LEN + U32_LEN + U32_LEN + 1 + ..U64_LEN + U32_LEN + U32_LEN + BLOB_HASH_LEN + 1, + ) + .and_then(|bytes| BlobHash::try_from_hash_slice(bytes).ok()) + .ok_or_else(|| trc::Error::corrupted_key(key, None, trc::location!()))?, + }, + Some(1) => TaskAction::BayesTrain { + learn_spam: true, + hash: key + .get( + U64_LEN + U32_LEN + U32_LEN + 1 + ..U64_LEN + U32_LEN + U32_LEN + BLOB_HASH_LEN + 1, + ) + .and_then(|bytes| BlobHash::try_from_hash_slice(bytes).ok()) + .ok_or_else(|| trc::Error::corrupted_key(key, None, trc::location!()))?, + }, + Some(2) => TaskAction::BayesTrain { + learn_spam: false, + hash: key + .get( + U64_LEN + U32_LEN + U32_LEN + 1 + ..U64_LEN + U32_LEN + U32_LEN + BLOB_HASH_LEN + 1, + ) + .and_then(|bytes| BlobHash::try_from_hash_slice(bytes).ok()) + .ok_or_else(|| trc::Error::corrupted_key(key, None, trc::location!()))?, + }, + Some(3) => TaskAction::SendAlarm { + alarm: CalendarAlarm { + event_id: key.deserialize_be_u16(U64_LEN + U32_LEN + U32_LEN + 1)?, + alarm_id: key + .deserialize_be_u16(U64_LEN + U32_LEN + U32_LEN + U16_LEN + 1)?, + event_start: value.deserialize_be_u64(0)? as i64, + event_end: value.deserialize_be_u64(U64_LEN)? as i64, + event_start_tz: value.deserialize_be_u16(U64_LEN * 2)?, + event_end_tz: value.deserialize_be_u16((U64_LEN * 2) + U16_LEN)?, + alarm_time: 0, + }, + }, + _ => return Err(trc::Error::corrupted_key(key, None, trc::location!())), + }, + }) + } +} diff --git a/crates/store/src/write/key.rs b/crates/store/src/write/key.rs index 34f9e960..4360eb0b 100644 --- a/crates/store/src/write/key.rs +++ b/crates/store/src/write/key.rs @@ -281,22 +281,33 @@ impl ValueClass { .write(collection) .write(document_id), ValueClass::TaskQueue(task) => match task { - TaskQueueClass::IndexEmail { seq, hash } => serializer - .write(*seq) + TaskQueueClass::IndexEmail { due, hash } => serializer + .write(*due) .write(account_id) .write(0u8) .write(document_id) .write::<&[u8]>(hash.as_ref()), TaskQueueClass::BayesTrain { - seq, + due, hash, learn_spam, } => serializer - .write(*seq) + .write(*due) .write(account_id) .write(if *learn_spam { 1u8 } else { 2u8 }) .write(document_id) .write::<&[u8]>(hash.as_ref()), + TaskQueueClass::SendAlarm { + due, + event_id, + alarm_id, + } => serializer + .write(*due) + .write(account_id) + .write(3u8) + .write(document_id) + .write(*event_id) + .write(*alarm_id), }, ValueClass::Blob(op) => match op { BlobOp::Reserve { hash, until } => serializer @@ -565,7 +576,12 @@ impl ValueClass { BLOB_HASH_LEN + U32_LEN * 2 + 2 } }, - ValueClass::TaskQueue { .. } => BLOB_HASH_LEN + U64_LEN * 2, + ValueClass::TaskQueue(e) => match e { + TaskQueueClass::IndexEmail { .. } | TaskQueueClass::BayesTrain { .. } => { + (BLOB_HASH_LEN + U64_LEN * 2) + 1 + } + TaskQueueClass::SendAlarm { .. } => U64_LEN + (U32_LEN * 3) + 1, + }, ValueClass::Queue(q) => match q { QueueClass::Message(_) => U64_LEN, QueueClass::MessageEvent(_) => U64_LEN * 2, diff --git a/crates/store/src/write/mod.rs b/crates/store/src/write/mod.rs index fe909d01..0a905995 100644 --- a/crates/store/src/write/mod.rs +++ b/crates/store/src/write/mod.rs @@ -196,14 +196,19 @@ pub enum ValueClass { #[derive(Debug, PartialEq, Clone, Eq, Hash)] pub enum TaskQueueClass { IndexEmail { - seq: u64, + due: u64, hash: BlobHash, }, BayesTrain { - seq: u64, + due: u64, hash: BlobHash, learn_spam: bool, }, + SendAlarm { + due: u64, + event_id: u16, + alarm_id: u16, + }, } #[derive(Debug, PartialEq, Clone, Eq, Hash)] diff --git a/crates/trc/src/event/description.rs b/crates/trc/src/event/description.rs index 8df5498d..24bf9e8a 100644 --- a/crates/trc/src/event/description.rs +++ b/crates/trc/src/event/description.rs @@ -191,21 +191,19 @@ impl HousekeeperEvent { impl TaskQueueEvent { pub fn description(&self) -> &'static str { match self { - TaskQueueEvent::Index => "Full-text search indexing completed", - TaskQueueEvent::Locked => "Task is locked by another process", + TaskQueueEvent::TaskAcquired => "Task acquired from queue", + TaskQueueEvent::TaskLocked => "Task is locked by another process", TaskQueueEvent::BlobNotFound => "Blob not found for task", TaskQueueEvent::MetadataNotFound => "Metadata not found for task", - TaskQueueEvent::BayesTrain => "Bayesian training completed", } } pub fn explain(&self) -> &'static str { match self { - TaskQueueEvent::Index => "The full-text search index has been updated", - TaskQueueEvent::Locked => "The task id is locked by another process", + TaskQueueEvent::TaskAcquired => "A task has been acquired from the queue", + TaskQueueEvent::TaskLocked => "The task id is locked by another process", TaskQueueEvent::BlobNotFound => "The requested blob was not found for task", TaskQueueEvent::MetadataNotFound => "The metadata was not found for task", - TaskQueueEvent::BayesTrain => "Bayesian training has been completed", } } } @@ -1020,6 +1018,7 @@ impl SpamEvent { SpamEvent::ClassifyError => "Not enough training data for spam filter", SpamEvent::Dnsbl => "DNSBL query", SpamEvent::DnsblError => "Error querying DNSBL", + SpamEvent::TrainAccount => "Training spam filter for account", } } @@ -1034,6 +1033,7 @@ impl SpamEvent { SpamEvent::Pyzor => "Pyzor query successful", SpamEvent::Dnsbl => "The DNSBL query was successful", SpamEvent::DnsblError => "An error occurred while querying the DNSBL", + SpamEvent::TrainAccount => "The spam filter has been trained for the account", } } } @@ -1613,6 +1613,7 @@ impl MessageIngestEvent { MessageIngestEvent::JmapAppend => "Message appended via JMAP", MessageIngestEvent::Duplicate => "Skipping duplicate message", MessageIngestEvent::Error => "Message ingestion error", + MessageIngestEvent::FtsIndex => "Full-text search index updated", } } @@ -1624,6 +1625,7 @@ impl MessageIngestEvent { MessageIngestEvent::JmapAppend => "The message has been appended via JMAP", MessageIngestEvent::Duplicate => "The message is a duplicate and has been skipped", MessageIngestEvent::Error => "An error occurred while ingesting the message", + MessageIngestEvent::FtsIndex => "The full-text search index has been updated", } } } @@ -1889,6 +1891,10 @@ impl CalendarEvent { pub fn description(&self) -> &'static str { match self { CalendarEvent::RuleExpansionError => "Calendar rule expansion error", + CalendarEvent::AlarmSent => "Calendar alarm sent", + CalendarEvent::AlarmSkipped => "Calendar alarm skipped", + CalendarEvent::AlarmRecipientOverride => "Calendar alarm recipient overriden", + CalendarEvent::AlarmFailed => "Calendar alarm could not be sent", } } @@ -1897,6 +1903,10 @@ impl CalendarEvent { CalendarEvent::RuleExpansionError => { "An error occurred while expanding calendar recurrences" } + CalendarEvent::AlarmSent => "A calendar alarm has been sent to the recipient", + CalendarEvent::AlarmSkipped => "A calendar alarm was skipped", + CalendarEvent::AlarmRecipientOverride => "A calendar alarm recipient was overridden", + CalendarEvent::AlarmFailed => "A calendar alarm could not be sent to the recipient", } } } diff --git a/crates/trc/src/event/level.rs b/crates/trc/src/event/level.rs index b250b10a..b04076f4 100644 --- a/crates/trc/src/event/level.rs +++ b/crates/trc/src/event/level.rs @@ -345,6 +345,7 @@ impl EventType { | SpamEvent::DnsblError | SpamEvent::Pyzor | SpamEvent::Train + | SpamEvent::TrainAccount | SpamEvent::Classify | SpamEvent::ClassifyError | SpamEvent::TrainBalance @@ -376,10 +377,9 @@ impl EventType { HousekeeperEvent::Run | HousekeeperEvent::Schedule => Level::Debug, }, EventType::TaskQueue(event) => match event { - TaskQueueEvent::Index => Level::Info, TaskQueueEvent::BlobNotFound - | TaskQueueEvent::Locked - | TaskQueueEvent::BayesTrain + | TaskQueueEvent::TaskAcquired + | TaskQueueEvent::TaskLocked | TaskQueueEvent::MetadataNotFound => Level::Debug, }, EventType::Dmarc(_) => Level::Debug, @@ -526,7 +526,8 @@ impl EventType { | MessageIngestEvent::Spam | MessageIngestEvent::ImapAppend | MessageIngestEvent::JmapAppend - | MessageIngestEvent::Duplicate => Level::Info, + | MessageIngestEvent::Duplicate + | MessageIngestEvent::FtsIndex => Level::Info, MessageIngestEvent::Error => Level::Error, }, EventType::Security(_) => Level::Info, @@ -535,7 +536,13 @@ impl EventType { AiEvent::ApiError => Level::Warn, }, EventType::WebDav(_) => Level::Debug, - EventType::Calendar(CalendarEvent::RuleExpansionError) => Level::Debug, + EventType::Calendar(event) => match event { + CalendarEvent::AlarmSent => Level::Info, + CalendarEvent::AlarmFailed => Level::Warn, + CalendarEvent::RuleExpansionError + | CalendarEvent::AlarmSkipped + | CalendarEvent::AlarmRecipientOverride => Level::Debug, + }, } } } diff --git a/crates/trc/src/ipc/metrics.rs b/crates/trc/src/ipc/metrics.rs index 698fa5c3..4eb7dd64 100644 --- a/crates/trc/src/ipc/metrics.rs +++ b/crates/trc/src/ipc/metrics.rs @@ -178,7 +178,7 @@ impl Collector { EventType::Queue(QueueEvent::QueueAutogenerated | QueueEvent::QueueDsn) => { QUEUE_COUNT.increment(); } - EventType::TaskQueue(TaskQueueEvent::Index) => { + EventType::MessageIngest(MessageIngestEvent::FtsIndex) => { MESSAGE_INDEX_TIME.observe(elapsed); } EventType::Store(StoreEvent::BlobWrite) => { @@ -592,9 +592,7 @@ impl EventType { ) => true, EventType::Housekeeper(_) => false, EventType::TaskQueue( - TaskQueueEvent::Index - | TaskQueueEvent::BlobNotFound - | TaskQueueEvent::MetadataNotFound, + TaskQueueEvent::BlobNotFound | TaskQueueEvent::MetadataNotFound, ) => true, EventType::Milter( MilterEvent::ActionAccept @@ -673,6 +671,7 @@ impl EventType { | TelemetryEvent::PrometheusExporterError | TelemetryEvent::JournalError, ) => true, + EventType::Calendar(CalendarEvent::AlarmSent | CalendarEvent::AlarmFailed) => true, _ => false, } } diff --git a/crates/trc/src/lib.rs b/crates/trc/src/lib.rs index 437bbd43..c6bf8347 100644 --- a/crates/trc/src/lib.rs +++ b/crates/trc/src/lib.rs @@ -236,9 +236,8 @@ pub enum HousekeeperEvent { #[event_type] pub enum TaskQueueEvent { - Index, - BayesTrain, - Locked, + TaskAcquired, + TaskLocked, BlobNotFound, MetadataNotFound, } @@ -608,6 +607,7 @@ pub enum SpamEvent { TrainError, Classify, ClassifyError, + TrainAccount, } #[event_type] @@ -870,6 +870,7 @@ pub enum MessageIngestEvent { JmapAppend, Duplicate, Error, + FtsIndex, } #[event_type] @@ -983,6 +984,10 @@ pub enum WebDavEvent { #[event_type] pub enum CalendarEvent { RuleExpansionError, + AlarmSent, + AlarmSkipped, + AlarmRecipientOverride, + AlarmFailed, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/crates/trc/src/serializers/binary.rs b/crates/trc/src/serializers/binary.rs index ec39e3c1..a697ed03 100644 --- a/crates/trc/src/serializers/binary.rs +++ b/crates/trc/src/serializers/binary.rs @@ -446,9 +446,9 @@ impl EventType { EventType::Eval(EvalEvent::Result) => 139, EventType::Eval(EvalEvent::StoreNotFound) => 140, EventType::TaskQueue(TaskQueueEvent::BlobNotFound) => 141, - EventType::TaskQueue(TaskQueueEvent::Index) => 142, - EventType::TaskQueue(TaskQueueEvent::BayesTrain) => 143, - EventType::TaskQueue(TaskQueueEvent::Locked) => 144, + EventType::MessageIngest(MessageIngestEvent::FtsIndex) => 142, + EventType::Spam(SpamEvent::TrainAccount) => 143, + EventType::TaskQueue(TaskQueueEvent::TaskLocked) => 144, EventType::TaskQueue(TaskQueueEvent::MetadataNotFound) => 145, EventType::Housekeeper(HousekeeperEvent::Run) => 146, EventType::Housekeeper(HousekeeperEvent::Schedule) => 149, @@ -887,6 +887,11 @@ impl EventType { EventType::Store(StoreEvent::CacheHit) => 51, EventType::Store(StoreEvent::CacheStale) => 52, EventType::Store(StoreEvent::CacheUpdate) => 577, + EventType::TaskQueue(TaskQueueEvent::TaskAcquired) => 578, + EventType::Calendar(CalendarEvent::AlarmSent) => 579, + EventType::Calendar(CalendarEvent::AlarmSkipped) => 580, + EventType::Calendar(CalendarEvent::AlarmRecipientOverride) => 581, + EventType::Calendar(CalendarEvent::AlarmFailed) => 582, } } @@ -1029,9 +1034,9 @@ impl EventType { 139 => Some(EventType::Eval(EvalEvent::Result)), 140 => Some(EventType::Eval(EvalEvent::StoreNotFound)), 141 => Some(EventType::TaskQueue(TaskQueueEvent::BlobNotFound)), - 142 => Some(EventType::TaskQueue(TaskQueueEvent::Index)), - 143 => Some(EventType::TaskQueue(TaskQueueEvent::BayesTrain)), - 144 => Some(EventType::TaskQueue(TaskQueueEvent::Locked)), + 142 => Some(EventType::MessageIngest(MessageIngestEvent::FtsIndex)), + 143 => Some(EventType::Spam(SpamEvent::TrainAccount)), + 144 => Some(EventType::TaskQueue(TaskQueueEvent::TaskLocked)), 145 => Some(EventType::TaskQueue(TaskQueueEvent::MetadataNotFound)), 146 => Some(EventType::Housekeeper(HousekeeperEvent::Run)), 149 => Some(EventType::Housekeeper(HousekeeperEvent::Schedule)), @@ -1510,6 +1515,11 @@ impl EventType { 51 => Some(EventType::Store(StoreEvent::CacheHit)), 52 => Some(EventType::Store(StoreEvent::CacheStale)), 577 => Some(EventType::Store(StoreEvent::CacheUpdate)), + 578 => Some(EventType::TaskQueue(TaskQueueEvent::TaskAcquired)), + 579 => Some(EventType::Calendar(CalendarEvent::AlarmSent)), + 580 => Some(EventType::Calendar(CalendarEvent::AlarmSkipped)), + 581 => Some(EventType::Calendar(CalendarEvent::AlarmRecipientOverride)), + 582 => Some(EventType::Calendar(CalendarEvent::AlarmFailed)), _ => None, } } diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 2b73df14..19f4543a 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -14,6 +14,7 @@ pub mod glob; pub mod json; pub mod map; pub mod snowflake; +pub mod template; pub mod topological; pub mod url_params; diff --git a/crates/utils/src/template.rs b/crates/utils/src/template.rs new file mode 100644 index 00000000..d5cd158e --- /dev/null +++ b/crates/utils/src/template.rs @@ -0,0 +1,601 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use ahash::AHashMap; +use std::{hash::Hash, str::FromStr}; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Template { + pub items: Vec>, + pub size: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateItem { + Static(String), + Variable(T), + If { variable: T, block_end: usize }, + ForEach { variable: T, block_end: usize }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Variable> { + Single(V), + Block(Vec>), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Variables> { + pub items: AHashMap>, +} + +impl Template { + pub fn parse(mut template: &str) -> Result { + let mut items = Vec::new(); + let mut block_stack = vec![]; + let mut size = 0; + + loop { + if let Some((start, end)) = template.split_once("{{") { + if !start.is_empty() { + items.push(TemplateItem::Static(start.to_string())); + size += start.len(); + } + let (var, rest) = end.split_once("}}").ok_or("Unmatched {{")?; + template = rest; + let var = var.trim(); + if let Some(var_name) = var.strip_prefix("#").map(|v| v.trim()) { + let (is_each, var_name) = if let Some(each) = var_name.strip_prefix("each ") { + (true, each) + } else if let Some(if_cond) = var_name.strip_prefix("if ") { + (false, if_cond) + } else { + return Err(format!("Invalid block start: {}", var_name)); + }; + let var = T::from_str(var_name) + .map_err(|_| format!("Invalid variable: {}", var_name))?; + + block_stack.push((var_name, items.len())); + + if is_each { + items.push(TemplateItem::ForEach { + variable: var, + block_end: 0, + }); + } else { + items.push(TemplateItem::If { + variable: var, + block_end: 0, + }); + } + } else if let Some(var_name) = var.strip_prefix("/").map(|v| v.trim()) { + let (is_each, var_name) = if let Some(each) = var_name.strip_prefix("each ") { + (true, each) + } else if let Some(if_cond) = var_name.strip_prefix("if ") { + (false, if_cond) + } else { + return Err(format!("Invalid block end: {}", var_name)); + }; + + if let Some((expected_name, if_pos)) = block_stack.pop() { + if expected_name != var_name { + return Err(format!( + "Block end does not match start: expected {}, got {}", + expected_name, var_name + )); + } + let block_end_idx = items.len(); + match &mut items[if_pos] { + TemplateItem::If { block_end, .. } if !is_each => { + *block_end = block_end_idx; + } + TemplateItem::ForEach { block_end, .. } if is_each => { + *block_end = block_end_idx; + } + _ => { + return Err(format!( + "Block end does not match start type for {}", + var_name + )); + } + } + } + } else { + let var = T::from_str(var).map_err(|_| format!("Invalid variable: {}", var))?; + items.push(TemplateItem::Variable(var)); + } + } else { + if !template.is_empty() { + items.push(TemplateItem::Static(template.to_string())); + size += template.len(); + } + break; + } + } + + if block_stack.is_empty() { + Ok(Template { items, size }) + } else { + Err(format!("Unmatched {{: {}", block_stack.last().unwrap().0)) + } + } + + pub fn eval(&self, variables: &Variables) -> String + where + V: AsRef, + { + let mut result = String::with_capacity(self.size); + let mut items = self.items.iter().enumerate(); + let mut base_offset = 0; + + while let Some((idx, item)) = items.next() { + 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) { + html_escape(&mut result, variable.as_ref()) + } + } + TemplateItem::If { + variable, + block_end, + } => { + if !variables.items.contains_key(variable) { + items = self.items[*block_end..].iter().enumerate(); + base_offset = *block_end; + } + } + TemplateItem::ForEach { + variable, + block_end, + } => { + 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 { + match sub_item { + TemplateItem::Static(s) => result.push_str(s), + TemplateItem::Variable(var) => { + if let Some(variable) = entry.get(var) { + html_escape(&mut result, variable.as_ref()) + } + } + _ => {} + } + } + } + } + items = self.items[*block_end..].iter().enumerate(); + base_offset = *block_end; + } + } + } + + result + } +} + +fn html_escape(result: &mut String, input: &str) { + for c in input.chars() { + match c { + '&' => result.push_str("&"), + '<' => result.push_str("<"), + '>' => result.push_str(">"), + '"' => result.push_str("""), + '\'' => result.push_str("'"), + _ => result.push(c), + } + } +} + +impl> Variables { + pub fn new() -> Self { + Self { + items: AHashMap::new(), + } + } + + pub fn insert_single(&mut self, key: T, value: V) { + self.items.insert(key, Variable::Single(value)); + } + + pub fn insert_block(&mut self, key: T, value: V1) + where + V1: IntoIterator, + V2: IntoIterator, + { + self.items.insert( + key, + Variable::Block(value.into_iter().map(AHashMap::from_iter).collect()), + ); + } +} + +impl> Default for Variables { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_variable_substitution() { + let template = Template::parse("Hello {{name}}!").unwrap(); + let mut vars = Variables::::new(); + vars.insert_single("name".to_string(), "World".to_string()); + + let result = template.eval(&vars); + assert_eq!(result, "Hello World!"); + } + + #[test] + fn test_multiple_variables() { + let template = Template::parse("{{greeting}} {{name}}, today is {{day}}").unwrap(); + let mut vars = Variables::::new(); + vars.insert_single("greeting".to_string(), "Hello".to_string()); + vars.insert_single("name".to_string(), "Alice".to_string()); + vars.insert_single("day".to_string(), "Monday".to_string()); + + let result = template.eval(&vars); + assert_eq!(result, "Hello Alice, today is Monday"); + } + + #[test] + fn test_missing_variable() { + let template = Template::parse("Hello {{name}}!").unwrap(); + let vars = Variables::::new(); + + let result = template.eval(&vars); + assert_eq!(result, "Hello !"); + } + + #[test] + fn test_static_text_only() { + let template = Template::parse("This is just static text").unwrap(); + let vars = Variables::::new(); + + let result = template.eval(&vars); + assert_eq!(result, "This is just static text"); + } + + #[test] + fn test_empty_template() { + let template = Template::parse("").unwrap(); + let vars = Variables::::new(); + + let result = template.eval(&vars); + assert_eq!(result, ""); + } + + #[test] + fn test_if_block_with_existing_variable() { + let template = + Template::parse("{{#if show_message}}Hello World!{{/if show_message}}").unwrap(); + let mut vars = Variables::::new(); + vars.insert_single("show_message".to_string(), "true".to_string()); + + let result = template.eval(&vars); + assert_eq!(result, "Hello World!"); + } + + #[test] + fn test_if_block_with_missing_variable() { + let template = + Template::parse("{{#if show_message}}Hello World!{{/if show_message}}").unwrap(); + let vars = Variables::::new(); + + let result = template.eval(&vars); + assert_eq!(result, ""); + } + + #[test] + fn test_if_block_with_content_and_variables() { + let template = Template::parse( + "{{#if notifications}}You have notifications: {{count}}{{/if notifications}}", + ) + .unwrap(); + let mut vars = Variables::::new(); + vars.insert_single("notifications".to_string(), "true".to_string()); + vars.insert_single("count".to_string(), "5".to_string()); + + let result = template.eval(&vars); + assert_eq!(result, "You have notifications: 5"); + } + + #[test] + fn test_foreach_block_basic() { + let template = Template::parse("{{#each items}}{{name}} {{/each items}}").unwrap(); + let mut vars = Variables::::new(); + + let items = vec![ + vec![("name".to_string(), "Item1".to_string())], + vec![("name".to_string(), "Item2".to_string())], + vec![("name".to_string(), "Item3".to_string())], + ]; + vars.insert_block("items".to_string(), items); + + let result = template.eval(&vars); + assert_eq!(result, "Item1 Item2 Item3 "); + } + + #[test] + fn test_foreach_block_multiple_variables() { + let template = Template::parse( + "{{#each notifications}}* {{name}} at {{time}}\n{{/each notifications}}", + ) + .unwrap(); + let mut vars = Variables::::new(); + + let notifications = vec![ + vec![ + ("name".to_string(), "Meeting".to_string()), + ("time".to_string(), "10:00".to_string()), + ], + vec![ + ("name".to_string(), "Call".to_string()), + ("time".to_string(), "14:30".to_string()), + ], + ]; + vars.insert_block("notifications".to_string(), notifications); + + let result = template.eval(&vars); + assert_eq!(result, "* Meeting at 10:00\n* Call at 14:30\n"); + } + + #[test] + fn test_foreach_block_empty() { + let template = Template::parse("{{#each items}}{{name}}{{/each items}}").unwrap(); + let mut vars = Variables::::new(); + vars.insert_block("items".to_string(), Vec::>::new()); + + let result = template.eval(&vars); + assert_eq!(result, ""); + } + + #[test] + fn test_foreach_block_missing_variable() { + let template = Template::parse("{{#each items}}{{name}}{{/each items}}").unwrap(); + let vars = Variables::::new(); + + let result = template.eval(&vars); + assert_eq!(result, ""); + } + + #[test] + fn test_complex_template_example() { + let template_str = r#"Hello {{name}}, + +{{#if notifications}}You have the following notifications: +{{#each notifications}}* {{name}} at {{time}} +{{/each notifications}}{{/if notifications}} +Best regards"#; + + let template = Template::parse(template_str).unwrap(); + let mut vars = Variables::::new(); + vars.insert_single("name".to_string(), "Alice".to_string()); + vars.insert_single("notifications".to_string(), "true".to_string()); + + let notifications = vec![ + vec![ + ("name".to_string(), "Team Meeting".to_string()), + ("time".to_string(), "09:00".to_string()), + ], + vec![ + ("name".to_string(), "Doctor Appointment".to_string()), + ("time".to_string(), "15:30".to_string()), + ], + ]; + vars.insert_block("notifications".to_string(), notifications); + + let result = template.eval(&vars); + let expected = r#"Hello Alice, + +You have the following notifications: +* Team Meeting at 09:00 +* Doctor Appointment at 15:30 + +Best regards"#; + + assert_eq!(result, expected); + } + + #[test] + fn test_complex_template_no_notifications() { + let template_str = r#"Hello {{name}}, + +{{#if notifications}} +You have the following notifications: +{{#each notifications}} +* {{name}} at {{time}} +{{/each notifications}}{{/if notifications}} +Best regards"#; + + let template = Template::parse(template_str).unwrap(); + let mut vars = Variables::::new(); + vars.insert_single("name".to_string(), "Bob".to_string()); + + let result = template.eval(&vars); + let expected = r#"Hello Bob, + + +Best regards"#; + + assert_eq!(result, expected); + } + + #[test] + fn test_whitespace_handling() { + let template = Template::parse("{{ name }}").unwrap(); + let mut vars = Variables::::new(); + vars.insert_single("name".to_string(), "Test".to_string()); + + let result = template.eval(&vars); + assert_eq!(result, "Test"); + } + + #[test] + fn test_whitespace_in_blocks() { + let template = Template::parse("{{# if condition }}Content{{/ if condition }}").unwrap(); + let mut vars = Variables::::new(); + vars.insert_single("condition".to_string(), "true".to_string()); + + let result = template.eval(&vars); + assert_eq!(result, "Content"); + } + + // Error handling tests + #[test] + fn test_unmatched_opening_brace() { + let result = Template::::parse("Hello {{name"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unmatched {{")); + } + + #[test] + fn test_invalid_block_start() { + let result = Template::::parse("{{#invalid block}}{{/invalid block}}"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid block start")); + } + + #[test] + fn test_invalid_block_end() { + let result = Template::::parse("{{#if test}}{{\\/invalid block}}"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unmatched")); + } + + #[test] + fn test_mismatched_block_names() { + let result = Template::::parse("{{#if test}}{{/if different}}"); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .contains("Block end does not match start") + ); + } + + #[test] + fn test_mismatched_block_types() { + let result = Template::::parse("{{#if test}}{{/each test}}"); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .contains("Block end does not match start") + ); + } + + #[test] + fn test_consecutive_braces() { + let template = Template::parse("{{}}").unwrap(); + let vars = Variables::::new(); + + let result = template.eval(&vars); + assert_eq!(result, ""); + } + + #[test] + fn test_foreach_with_missing_inner_variables() { + let template = + Template::parse("{{#each items}}{{name}}: {{missing}}{{/each items}}").unwrap(); + let mut vars = Variables::::new(); + + let items = vec![ + vec![("name".to_string(), "Item1".to_string())], + vec![("name".to_string(), "Item2".to_string())], + ]; + vars.insert_block("items".to_string(), items); + + let result = template.eval(&vars); + assert_eq!(result, "Item1: Item2: "); + } + + /*#[test] + fn test_full() { + // Load static html in memory from resources/email-templates/calendar-alarm.html + let template_str = include_str!("../../../resources/email-templates/calendar-alarm.html"); + let template: Template = Template::parse(template_str).unwrap(); + + let mut vars = Variables::::new(); + vars.insert_single( + CalendarTemplateVariable::PageTitle, + "Test Event".to_string(), + ); + vars.insert_single(CalendarTemplateVariable::Header, "Event Header".to_string()); + vars.insert_single(CalendarTemplateVariable::Footer, "Event Footer".to_string()); + vars.insert_single( + CalendarTemplateVariable::EventTitle, + "Meeting with Team".to_string(), + ); + vars.insert_single( + CalendarTemplateVariable::EventDescription, + "Discuss project updates".to_string(), + ); + vars.insert_single( + CalendarTemplateVariable::EventDetails, + "Details about the event".to_string(), + ); + vars.insert_single( + CalendarTemplateVariable::ActionUrl, + "http://example.com/action".to_string(), + ); + vars.insert_single( + CalendarTemplateVariable::ActionName, + "Join Meeting".to_string(), + ); + vars.insert_single( + CalendarTemplateVariable::AttendeesTitle, + "Attendees".to_string(), + ); + vars.insert_block( + CalendarTemplateVariable::EventDetails, + vec![ + vec![ + (CalendarTemplateVariable::Key, "Location".to_string()), + ( + CalendarTemplateVariable::Value, + "Conference Room A".to_string(), + ), + ], + vec![ + (CalendarTemplateVariable::Key, "Time".to_string()), + ( + CalendarTemplateVariable::Value, + "10:00 AM - 11:00 AM".to_string(), + ), + ], + ], + ); + vars.insert_block( + CalendarTemplateVariable::Attendees, + vec![ + vec![ + (CalendarTemplateVariable::Key, "Alice".to_string()), + ( + CalendarTemplateVariable::Value, + "alice@domain.org".to_string(), + ), + ], + vec![ + (CalendarTemplateVariable::Key, "Bob".to_string()), + ( + CalendarTemplateVariable::Value, + "bob@domain.org".to_string(), + ), + ], + ], + ); + let result = template.eval(&vars); + // Write result to test.html + std::fs::write("test.html", result).expect("Unable to write file"); + }*/ +} diff --git a/resources/email-templates/calendar-alarm.html b/resources/email-templates/calendar-alarm.html new file mode 100644 index 00000000..016e972c --- /dev/null +++ b/resources/email-templates/calendar-alarm.html @@ -0,0 +1,246 @@ + + + + + {{page_title}} + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
Logo
+
+
+
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + {{#if event_description}} + + + + {{/if event_description}} + {{#each event_details}} + + + + {{/each event_details}} + {{#if attendees}} + + + + + + + {{/if attendees}} + + + + +
+
+ {{header}}
+
+
+ {{event_title}}
+
+
+ {{event_description}}
+
+
+ {{key}}: + {{value}} +
+
+
+ {{attendees_title}}:
+
+
+
+ {{#each attendees}}• {{key}} <{{value}}>
{{/each attendees}}
+
+
+ + + + +
{{action_name}}
+
+
+
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+

+
+
+ {{footer}}
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/resources/email-templates/calendar-alarm.mjml b/resources/email-templates/calendar-alarm.mjml new file mode 100644 index 00000000..119b7001 --- /dev/null +++ b/resources/email-templates/calendar-alarm.mjml @@ -0,0 +1,72 @@ + + + {{title}} + + + + + + + .event-detail { + font-weight: bold; + color: #2c5aa0; + } + .guest-list { + background-color: #f8f9fa; + padding: 10px; + border-radius: 4px; + margin-top: 5px; + } + + + + + + + + + + + + + {{upcoming_event}} + + + + {{event_title}} + + + + {{event_description}} + + + + {{field_name}}: {{field_value}} + + + + {{attendees}}: + + + +
+ • {{guest_name}} ({{guest_email}})
+
+
+ + + {{open_button}} + +
+
+ + + + + + {{footer}} + + + +
+
\ No newline at end of file diff --git a/resources/locales/i18n.yml b/resources/locales/i18n.yml new file mode 100644 index 00000000..f8c8bffc --- /dev/null +++ b/resources/locales/i18n.yml @@ -0,0 +1,549 @@ +# SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + +calendar.alarm_subject_prefix: + en: Notification + es: Notificación + fr: Notification + 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 + es: Tienes un evento próximo + fr: Vous avez un événement à venir + 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. + es: Recibe este correo porque ha habilitado las notificaciones de calendario. Para dejar de recibir estos correos, inicie sesión en el portal de autoservicio y desactive las notificaciones de eventos. + fr: Vous recevez cet e-mail car vous avez activé les notifications de calendrier. Pour arrêter de recevoir ces e-mails, connectez-vous au portail libre-service et désactivez les notifications d'événements. + 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 + es: Ver Evento + fr: Voir l'Événement + 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 + es: Organizador + fr: Organisateur + 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 + es: Invitados + fr: Invités + 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 + es: Inicio + fr: Début + 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 + es: Fin + fr: Fin + 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 + es: Ubicación + fr: Lieu + 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" + + # Danish: "søn 25. maj 2025 09.00" (weekday date month year time with periods) + da: "%a %-d. %b %Y %H.%M" + + # Norwegian: "søn 25. mai 2025 09:00" (weekday date month year time) + no: "%a %-d. %b %Y %H:%M" + + # Finnish: "su 25. toukokuuta 2025 klo 9.00" (weekday date month year clock time) + fi: "%a %-d. %b %Y klo %-H.%M" + + # Polish: "ndz 25 maj 2025 9:00" (weekday date month year time) + pl: "%a %-d %b %Y %-H:%M" + + # Czech: "ne 25. 5. 2025 9:00" (weekday date month year time) + cs: "%a %-d. %-m. %Y %-H:%M" + + # Slovak: "ne 25. 5. 2025 9:00" (weekday date month year time) + sk: "%a %-d. %-m. %Y %-H:%M" + + # Hungarian: "v 2025. 05. 25. 9:00" (weekday year month date time) + hu: "%a %Y. %m. %d. %-H:%M" + + # Romanian: "dum 25 mai 2025 9:00" (weekday date month year time) + ro: "%a %-d %b %Y %-H:%M" + + # Bulgarian: "нед 25 май 2025 9:00" (weekday date month year time) + bg: "%a %-d %b %Y %-H:%M" + + # Croatian: "ned 25. svi 2025 9:00" (weekday date month year time) + hr: "%a %-d. %b %Y %-H:%M" + + # Slovenian: "ned 25. maj 2025 9:00" (weekday date month year time) + sl: "%a %-d. %b %Y %-H:%M" + + # Estonian: "P 25. mai 2025 9:00" (weekday date month year time) + et: "%a %-d. %b %Y %-H:%M" + + # Latvian: "sv 25. maijs 2025 9:00" (weekday date month year time) + lv: "%a %-d. %b %Y %-H:%M" + + # Lithuanian: "sk 2025 m. gegužės 25 d. 9:00" (weekday year month date time) + lt: "%a %Y m. %b %-d d. %-H:%M" + + # Greek: "Κυρ 25 Μάι 2025 9:00 πμ" (weekday date month year time AM/PM) + el: "%a %-d %b %Y %-I:%M %p" + + # Turkish: "Paz 25 May 2025 09:00" (weekday date month year time) + tr: "%a %-d %b %Y %H:%M" + + # Hebrew: "א׳ 25 מאי 2025 9:00" (weekday date month year time) + he: "%a %-d %b %Y %-H:%M" + + # Thai: "อา. 25 พ.ค. 2568 9:00 น." (weekday date month Buddhist year time) + th: "%a %-d %b %Y %-H:%M น." + + # Vietnamese: "CN 25 thg 5 2025 9:00" (weekday date month year time) + vi: "%a %-d thg %-m %Y %-H:%M" + + # Indonesian: "Min 25 Mei 2025 09.00" (weekday date month year time with periods) + id: "%a %-d %b %Y %H.%M" + + # Malaysian: "Ahd 25 Mei 2025 9:00 PG" (weekday date month year time AM/PM) + ms: "%a %-d %b %Y %-I:%M %p" + + # 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" + + # Ukrainian: "нд 25 тра 2025 9:00" (weekday date month year time) + uk: "%a %-d %b %Y %-H:%M" + + # Belarusian: "нд 25 мая 2025 9:00" (weekday date month year time) + be: "%a %-d %b %Y %-H:%M" + + # Macedonian: "нед 25 мај 2025 9:00" (weekday date month year time) + mk: "%a %-d %b %Y %-H:%M" + + # Albanian: "Die 25 Maj 2025 9:00" (weekday date month year time) + sq: "%a %-d %b %Y %-H:%M" + + # Maltese: "Ħad 25 Mej 2025 9:00" (weekday date month year time) + mt: "%a %-d %b %Y %-H:%M" + + # Welsh: "Sul 25 Mai 2025 9:00" (weekday date month year time) + cy: "%a %-d %b %Y %-H:%M" + + # Irish: "Domh 25 Beal 2025 9:00" (weekday date month year time) + ga: "%a %-d %b %Y %-H:%M" + + # Icelandic: "sun 25. maí 2025 09:00" (weekday date month year time) + is: "%a %-d. %b %Y %H:%M" diff --git a/tests/src/jmap/enterprise.rs b/tests/src/jmap/enterprise.rs index 2f889c0a..8d0359bd 100644 --- a/tests/src/jmap/enterprise.rs +++ b/tests/src/jmap/enterprise.rs @@ -109,6 +109,8 @@ pub async fn test(params: &mut JMAPTest) { logo_url: None, ai_apis: Default::default(), spam_filter_llm: None, + template_calendar_alarm: None, + template_calendar_invite: None, } .into(); config.assert_no_errors(); @@ -177,6 +179,8 @@ impl EnterpriseCore for Core { logo_url: None, ai_apis: Default::default(), spam_filter_llm: None, + template_calendar_alarm: None, + template_calendar_invite: None, } .into(); self diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs index ec4ec3cd..5c04ac4a 100644 --- a/tests/src/jmap/mod.rs +++ b/tests/src/jmap/mod.rs @@ -159,7 +159,7 @@ pub async fn wait_for_index(server: &Server) { collection: 0, document_id: 0, class: ValueClass::TaskQueue(TaskQueueClass::IndexEmail { - seq: 0, + due: 0, hash: BlobHash::default(), }), }, @@ -168,7 +168,7 @@ pub async fn wait_for_index(server: &Server) { collection: u8::MAX, document_id: u32::MAX, class: ValueClass::TaskQueue(TaskQueueClass::IndexEmail { - seq: u64::MAX, + due: u64::MAX, hash: BlobHash::default(), }), }, diff --git a/tests/src/webdav/cal_alarm.rs b/tests/src/webdav/cal_alarm.rs new file mode 100644 index 00000000..6bcea819 --- /dev/null +++ b/tests/src/webdav/cal_alarm.rs @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::WebDavTest; +use email::{cache::MessageCacheFetch, message::metadata::MessageMetadata}; +use hyper::StatusCode; +use jmap_proto::types::{collection::Collection, property::Property}; +use mail_parser::DateTime; +use store::write::now; + +pub async fn test(test: &WebDavTest) { + println!("Running calendar e-mail alarms tests..."); + let client = test.client("john"); + client + .request_with_headers( + "PUT", + "/dav/cal/john/default/its-alarming-how-charming-i-feel.ics", + [("content-type", "text/calendar; charset=utf-8")], + TEST_ALARM_1.replace( + "$START", + &DateTime::from_timestamp(now() as i64 + 5) + .to_rfc3339() + .replace(['-', ':'], ""), + ), + ) + .await + .with_status(StatusCode::CREATED); + + tokio::time::sleep(std::time::Duration::from_secs(6)).await; + + // Check that the alarm was sent + let messages = test + .server + .get_cached_messages(client.account_id) + .await + .unwrap(); + assert_eq!(messages.emails.items.len(), 2); + + for (idx, message) in messages.emails.items.iter().enumerate() { + let metadata_ = test + .server + .get_archive_by_property( + client.account_id, + Collection::Email, + message.document_id, + Property::BodyStructure, + ) + .await + .unwrap() + .unwrap(); + let contents = String::from_utf8( + test.server + .blob_store() + .get_blob( + metadata_ + .unarchive::() + .unwrap() + .blob_hash + .0 + .as_slice(), + 0..usize::MAX, + ) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + /*std::fs::write( + format!("message_{}.eml", message.document_id), + contents.as_bytes(), + ) + .unwrap();*/ + if idx == 0 { + // First alarm does not have a summary or description + assert!( + contents.contains("See the pretty girl in that mirror there"), + "failed for {contents}" + ); + assert!( + contents.contains("What mirror where?!"), + "failed for {contents}" + ); + } else { + assert!( + contents.contains("I feel pretty and witty and gay"), + "failed for {contents}" + ); + assert!( + contents.contains("It's alarming how charming I feel."), + "failed for {contents}" + ); + } + assert!( + contents.contains(concat!( + "/dav/cal/john/default/", + "its-alarming-how-charming-i-feel.ics" + )), + "failed for {contents}" + ); + } +} + +const TEST_ALARM_1: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID: 2371c2d9-a136-43b0-bba3-f6ab249ad46e +SUMMARY:See the pretty girl in that mirror there +DESCRIPTION:What mirror where?! +DTSTART:$START +DTEND;TZID=America/New_York:21250221T180000 +LOCATION:West Side +BEGIN:VALARM +TRIGGER:-P2S +ACTION:EMAIL +ATTENDEE:mailto:john_doe@unknown.com +SUMMARY:I feel pretty and witty and gay +DESCRIPTION:I feel charming, Oh, so charming, It's alarming how charming I feel. +END:VALARM +BEGIN:VALARM +TRIGGER:-P4S +ACTION:EMAIL +END:VALARM +END:VEVENT +END:VCALENDAR +"#; diff --git a/tests/src/webdav/cal_query.rs b/tests/src/webdav/cal_query.rs index 775e0122..9936b23a 100644 --- a/tests/src/webdav/cal_query.rs +++ b/tests/src/webdav/cal_query.rs @@ -13,7 +13,7 @@ use calcard::{ use dav_proto::schema::property::TimeRange; use groupware::{ DavResourceName, - calendar::{CalendarEventData, dates::ExpandAlarm}, + calendar::{CalendarEventData, alarm::ExpandAlarm}, }; use hyper::StatusCode; use store::write::serialize::rkyv_unarchive; @@ -228,8 +228,8 @@ fn roundtrip_expansion(ics: &str, ignore_errors: bool) { for alarm in ical.alarms_for_id(e.comp_id) { if let Some(alarm_time) = alarm - .expand_alarm() - .and_then(|delta| delta.to_timestamp(start, end, Tz::UTC)) + .expand_alarm(0, 0) + .and_then(|alarm| alarm.delta.to_timestamp(start, end, Tz::UTC)) { if alarm_time < min { min = alarm_time; @@ -256,7 +256,7 @@ fn roundtrip_expansion(ics: &str, ignore_errors: bool) { .collect::>(); // Verify min/max UTC timestamps - let event_data = CalendarEventData::new(ical, Tz::UTC, 100); + let event_data = CalendarEventData::new(ical, Tz::UTC, 100, &mut None); let from_time = event_data.base_time_utc as i64 + event_data.base_offset; let to_time = from_time + event_data.duration as i64; diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs index ff7eaf1f..ff335570 100644 --- a/tests/src/webdav/mod.rs +++ b/tests/src/webdav/mod.rs @@ -47,6 +47,7 @@ use utils::config::Config; pub mod acl; pub mod basic; +pub mod cal_alarm; pub mod cal_query; pub mod card_query; pub mod copy_move; @@ -82,6 +83,7 @@ pub async fn webdav_tests() { acl::test(&handle).await; card_query::test(&handle).await; cal_query::test(&handle).await; + cal_alarm::test(&handle).await; // Print elapsed time let elapsed = start_time.elapsed(); @@ -1115,6 +1117,9 @@ account = "1000/1m" authentication = "100/2s" anonymous = "100/1m" +[calendar.alarms] +minimum-interval = "1s" + [store."auth"] type = "sqlite" path = "{TMP}/auth.db"