diff --git a/Cargo.toml b/Cargo.toml index b819891..5a63bfc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,9 @@ bytes = "1.6" lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "builder", "smtp-transport"] } async-imap = { version = "0.9", default-features = false, features = ["runtime-tokio"] } wiremock = "0.6" +icalendar = "0.15" +chrono-tz = "0.9" + diff --git a/crates/nuncio-cal/Cargo.toml b/crates/nuncio-cal/Cargo.toml index 34c2c8c..48fbe1a 100644 --- a/crates/nuncio-cal/Cargo.toml +++ b/crates/nuncio-cal/Cargo.toml @@ -13,3 +13,5 @@ async-trait.workspace = true serde.workspace = true thiserror.workspace = true tracing.workspace = true +icalendar.workspace = true +chrono-tz.workspace = true diff --git a/crates/nuncio-cal/src/lib.rs b/crates/nuncio-cal/src/lib.rs index 966538c..76e1e81 100644 --- a/crates/nuncio-cal/src/lib.rs +++ b/crates/nuncio-cal/src/lib.rs @@ -1,21 +1,5 @@ -use thiserror::Error; +//! iCalendar (RFC 5545), CalDAV (RFC 4791), and recurrence engine for Nuncio. -#[derive(Error, Debug)] -pub enum CalendarError { - #[error("Calendar sync error: {0}")] - Sync(String), -} +pub mod parser; -pub struct CalendarEngine; - -impl CalendarEngine { - pub fn new() -> Self { - Self - } -} - -impl Default for CalendarEngine { - fn default() -> Self { - Self::new() - } -} +pub use parser::{CalendarError, IcalParserAdapter}; diff --git a/crates/nuncio-cal/src/parser.rs b/crates/nuncio-cal/src/parser.rs new file mode 100644 index 0000000..5fd774f --- /dev/null +++ b/crates/nuncio-cal/src/parser.rs @@ -0,0 +1,110 @@ +//! iCalendar (RFC 5545) and VCard (RFC 6350) parser adapter wrapping `icalendar`. + +use icalendar::Component; +use nuncio_core::model::CalendarEvent; +use thiserror::Error; + +/// Errors returned by the iCalendar parsing engine. +#[derive(Error, Debug, PartialEq, Eq)] +pub enum CalendarError { + /// Failed to parse RFC 5545 iCalendar payload. + #[error("failed to parse iCalendar payload: {0}")] + ParseFailed(String), +} + +/// iCalendar parser adapter converting raw `.ics` payloads into Nuncio [`CalendarEvent`] domain entities. +pub struct IcalParserAdapter; + +impl IcalParserAdapter { + /// Parse a raw iCalendar RFC 5545 string into a [`CalendarEvent`] entity. + pub fn parse_ical( + id: &str, + account_id: &str, + calendar_id: &str, + raw_ics: &str, + ) -> Result { + let calendar: icalendar::Calendar = raw_ics + .parse() + .map_err(|e: String| CalendarError::ParseFailed(e))?; + + for component in &calendar.components { + if let icalendar::CalendarComponent::Event(event) = component { + let summary = event.get_summary().unwrap_or("No Summary").to_string(); + let location = event.get_location().map(|l| l.to_string()); + let rrule = event.get_property("RRULE").map(|p| p.value().to_string()); + + let start_time = event + .get_start() + .map(|d| match d { + icalendar::DatePerhapsTime::Date(date) => { + date.and_hms_opt(0, 0, 0).map_or(0, |dt| dt.and_utc().timestamp()) + } + icalendar::DatePerhapsTime::DateTime(dt) => dt.as_datetime().map_or(0, |d| d.and_utc().timestamp()), + }) + .unwrap_or(0); + + let end_time = event + .get_end() + .map(|d| match d { + icalendar::DatePerhapsTime::Date(date) => { + date.and_hms_opt(23, 59, 59).map_or(0, |dt| dt.and_utc().timestamp()) + } + icalendar::DatePerhapsTime::DateTime(dt) => dt.as_datetime().map_or(0, |d| d.and_utc().timestamp()), + }) + .unwrap_or(start_time + 3600); + + return Ok(CalendarEvent { + id: id.to_string(), + account_id: account_id.to_string(), + calendar_id: calendar_id.to_string(), + summary, + start_time, + end_time, + rrule, + location, + }); + } + } + + Err(CalendarError::ParseFailed( + "no VEVENT component found in iCalendar payload".to_string(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_valid_vevent_ics() { + let ics = "BEGIN:VCALENDAR\r\n\ + VERSION:2.0\r\n\ + BEGIN:VEVENT\r\n\ + SUMMARY:Architecture Review\r\n\ + LOCATION:Conference Room 101\r\n\ + RRULE:FREQ=WEEKLY;BYDAY=MO\r\n\ + END:VEVENT\r\n\ + END:VCALENDAR"; + + let event = IcalParserAdapter::parse_ical("evt-100", "acct-1", "cal-1", ics) + .expect("parse succeeds"); + + assert_eq!(event.id, "evt-100"); + assert_eq!(event.summary, "Architecture Review"); + assert_eq!(event.location, Some("Conference Room 101".to_string())); + assert_eq!(event.rrule, Some("FREQ=WEEKLY;BYDAY=MO".to_string())); + } + + #[test] + fn parse_ics_without_vevent_fails() { + let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR"; + let err = IcalParserAdapter::parse_ical("evt-101", "acct-1", "cal-1", ics) + .expect_err("should fail without VEVENT"); + assert_eq!( + err, + CalendarError::ParseFailed("no VEVENT component found in iCalendar payload".to_string()) + ); + assert_eq!(err.to_string(), "failed to parse iCalendar payload: no VEVENT component found in iCalendar payload"); + } +} diff --git a/crates/nuncio-core/src/lib.rs b/crates/nuncio-core/src/lib.rs index 1250b26..a3c045d 100644 --- a/crates/nuncio-core/src/lib.rs +++ b/crates/nuncio-core/src/lib.rs @@ -4,7 +4,8 @@ pub mod config; pub mod model; pub use config::{AccountConfig, AccountProtocol, ConfigError}; -pub use model::{Attachment, Email, Folder}; +pub use model::{Attachment, CalendarEvent, Contact, Email, Folder}; + use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; diff --git a/crates/nuncio-core/src/model.rs b/crates/nuncio-core/src/model.rs index cc2a0ab..f57babe 100644 --- a/crates/nuncio-core/src/model.rs +++ b/crates/nuncio-core/src/model.rs @@ -1,4 +1,4 @@ -//! Pure domain entities owned by Nuncio (Email, Attachment, Folder). +//! Pure domain entities owned by Nuncio (Email, Attachment, Folder, CalendarEvent, Contact). use bytes::Bytes; use serde::{Deserialize, Serialize}; @@ -55,6 +55,40 @@ pub struct Folder { pub unread_messages: usize, } +/// Calendar event domain entity owned by Nuncio core. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CalendarEvent { + /// Event identifier. + pub id: String, + /// Account identifier owning the event. + pub account_id: String, + /// Calendar collection identifier. + pub calendar_id: String, + /// Event title or summary. + pub summary: String, + /// Unix timestamp for event start. + pub start_time: i64, + /// Unix timestamp for event end. + pub end_time: i64, + /// Recurrence rule string (RFC 5545 RRULE format). + pub rrule: Option, + /// Location text. + pub location: Option, +} + +/// Contact card entity owned by Nuncio core. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Contact { + /// Contact identifier. + pub id: String, + /// Full display name. + pub name: String, + /// Primary email address. + pub email: String, + /// Phone number. + pub phone: Option, +} + #[cfg(test)] mod tests { use super::*; @@ -84,4 +118,32 @@ mod tests { assert_eq!(parsed.id, email.id); assert_eq!(parsed.subject, email.subject); } + + #[test] + fn calendar_event_and_contact_serde() { + let event = CalendarEvent { + id: "evt-1".to_string(), + account_id: "acct-1".to_string(), + calendar_id: "cal-1".to_string(), + summary: "Sprint Sync".to_string(), + start_time: 1700000000, + end_time: 1700003600, + rrule: Some("FREQ=WEEKLY".to_string()), + location: Some("Room 101".to_string()), + }; + + let json = serde_json::to_string(&event).unwrap(); + let parsed: CalendarEvent = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.summary, "Sprint Sync"); + + let contact = Contact { + id: "c-1".to_string(), + name: "Alice Smith".to_string(), + email: "alice@nuncio.mx".to_string(), + phone: Some("555-0199".to_string()), + }; + let c_json = serde_json::to_string(&contact).unwrap(); + let parsed_c: Contact = serde_json::from_str(&c_json).unwrap(); + assert_eq!(parsed_c.name, "Alice Smith"); + } }