From c2428c073cfc05b326bf7a0e57c66f209e08477d Mon Sep 17 00:00:00 2001 From: Bill Thiede Date: Tue, 19 Aug 2025 09:51:58 -0700 Subject: [PATCH] server: broken parsing of google ics --- Cargo.lock | 55 ++++++++++++++++++- server/Cargo.toml | 2 + server/src/email_extract.rs | 103 ++++++++++++++++++++++++++++++++++-- 3 files changed, 155 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 59785ff..4f589ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2410,7 +2410,7 @@ dependencies = [ "base64 0.21.7", "byteorder", "flate2", - "nom", + "nom 7.1.3", "num-traits", ] @@ -2856,6 +2856,28 @@ dependencies = [ "cc", ] +[[package]] +name = "ical" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4bad4eb99ee34e58a1e642114eded65b4ea5ea3c1584971a1afc12a3b927670" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "icalendar" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e69a1b52143a7fc94b55d008e8ab30506ebc93774ddb2752ee44266378fc5d6" +dependencies = [ + "chrono", + "iso8601", + "nom 8.0.0", + "nom-language", + "uuid", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -3095,6 +3117,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "iso8601" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46" +dependencies = [ + "nom 8.0.0", +] + [[package]] name = "itertools" version = "0.14.0" @@ -3219,6 +3250,8 @@ dependencies = [ "futures 0.3.31", "headers", "html-escape", + "ical", + "icalendar", "letterbox-notmuch 0.17.34", "letterbox-shared 0.17.34", "linkify", @@ -3859,6 +3892,24 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom-language" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29" +dependencies = [ + "nom 8.0.0", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -6200,7 +6251,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e810cdeeebca57fc3f7bfec5f85fdbea9031b2ac9b990eb5ff49b371d52bbe6a" dependencies = [ - "nom", + "nom 7.1.3", "serde", "serde_json", ] diff --git a/server/Cargo.toml b/server/Cargo.toml index 74b39b9..1ed0028 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -29,6 +29,8 @@ flate2 = "1.1.2" futures = "0.3.31" headers = "0.4.0" html-escape = "0.2.13" +icalendar = "0.17.1" +ical = "0.10" letterbox-notmuch = { path = "../notmuch", version = "0.17.34", registry = "xinu" } letterbox-shared = { path = "../shared", version = "0.17.34", registry = "xinu" } linkify = "0.10.0" diff --git a/server/src/email_extract.rs b/server/src/email_extract.rs index 8629c45..ef868aa 100644 --- a/server/src/email_extract.rs +++ b/server/src/email_extract.rs @@ -24,6 +24,7 @@ const MULTIPART_ALTERNATIVE: &'static str = "multipart/alternative"; const MULTIPART_MIXED: &'static str = "multipart/mixed"; const MULTIPART_RELATED: &'static str = "multipart/related"; const MULTIPART_REPORT: &'static str = "multipart/report"; +const TEXT_CALENDAR: &'static str = "text/calendar"; const TEXT_HTML: &'static str = "text/html"; const TEXT_PLAIN: &'static str = "text/plain"; @@ -308,6 +309,7 @@ pub fn extract_alternative( MULTIPART_ALTERNATIVE, MULTIPART_MIXED, MULTIPART_RELATED, + TEXT_CALENDAR, TEXT_HTML, TEXT_PLAIN, ]; @@ -332,6 +334,13 @@ pub fn extract_alternative( return Ok(Body::html(body)); } } + for sp in &m.subparts { + if sp.ctype.mimetype.as_str() == TEXT_CALENDAR { + let body = sp.get_body()?; + let summary = render_ical_summary(&body)?; + return Ok(Body::html(summary)); + } + } for sp in &m.subparts { if sp.ctype.mimetype.as_str() == TEXT_PLAIN { let body = sp.get_body()?; @@ -414,9 +423,9 @@ pub fn extract_mixed(m: &ParsedMail, part_addr: &mut Vec) -> Result(&xml) { - serde_json::to_string_pretty(&parsed_json).unwrap_or(xml) + serde_json::to_string_pretty(&parsed_json).unwrap_or(xml.to_string()) } else { - xml + xml.to_string() } } else { // DMARC reports are XML @@ -425,7 +434,7 @@ pub fn extract_mixed(m: &ParsedMail, part_addr: &mut Vec) -> Result pretty_xml, Err(e) => { error!("Failed to pretty print XML: {:?}", e); - xml + xml.to_string() } } }; @@ -1263,6 +1272,73 @@ pub fn pretty_print_xml_with_trimming(xml_input: &str) -> Result Result { + let mut summary_parts = Vec::new(); + let mut parser = IcalParser::new(ical_data.as_bytes()); + while let Some(Ok(calendar)) = parser.next() { + for event in calendar.events { + let mut event_summary = String::new(); + let mut summary = None; + let mut dtstart = None; + let mut dtend = None; + let mut organizer = None; + let mut organizer_cn = None; + for prop in &event.properties { + match prop.name.as_str() { + "SUMMARY" => summary = prop.value.as_deref(), + "DTSTART" => dtstart = prop.value.as_deref(), + "DTEND" => dtend = prop.value.as_deref(), + "ORGANIZER" => { + organizer = prop.value.as_deref(); + if let Some(params) = &prop.params { + if let Some((_, values)) = params.iter().find(|(k, _)| k == "CN") { + if let Some(cn) = values.get(0) { + organizer_cn = Some(cn.as_str()); + } + } + } + }, + _ => {} + } + } + if let Some(summary) = summary { + event_summary.push_str(&format!("Summary: {}
", summary)); + } + if let Some(dtstart) = dtstart { + let formatted = parse_ical_datetime(dtstart).unwrap_or_else(|| dtstart.to_string()); + event_summary.push_str(&format!("Start: {}
", formatted)); + } + if let Some(dtend) = dtend { + let formatted = parse_ical_datetime(dtend).unwrap_or_else(|| dtend.to_string()); + event_summary.push_str(&format!("End: {}
", formatted)); + } + if let Some(cn) = organizer_cn { + event_summary.push_str(&format!("Organizer: {}
", cn)); + } else if let Some(organizer) = organizer { + event_summary.push_str(&format!("Organizer: {}
", organizer)); + } + summary_parts.push(event_summary); + } + } + + fn parse_ical_datetime(dt: &str) -> Option { + use chrono::{NaiveDateTime, DateTime, Utc}; + let dt = dt.split(':').last().unwrap_or(dt); + if let Ok(ndt) = NaiveDateTime::parse_from_str(dt, "%Y%m%dT%H%M%SZ") { + let dt_utc: DateTime = DateTime::from_utc(ndt, Utc); + return Some(dt_utc.format("%Y-%m-%dT%H:%M:%SZ").to_string()); + } + if let Ok(ndt) = NaiveDateTime::parse_from_str(dt, "%Y%m%dT%H%M%S") { + return Some(ndt.format("%Y-%m-%dT%H:%M:%S").to_string()); + } + None + } + + Ok(summary_parts.join("
")) +} + #[cfg(test)] mod tests { use std::fs; @@ -1283,4 +1359,25 @@ mod tests { let html = parse_dmarc_report(&xml).unwrap(); assert!(!html.contains("Envelope To")); } + + #[test] + fn test_ical_render() { + let ical = fs::read_to_string("testdata/ical-example-1.ics").unwrap(); + let html = render_ical_summary(&ical).unwrap(); + assert!(html.contains("Summary: dentist night guard
")); + assert!(html.contains("Start: 2025-01-08T08:00:00
")); + assert!(html.contains("End: 2025-01-08T09:00:00
")); + assert!(html.contains("Organizer: Bill Thiede
")); + } + + #[test] + fn test_ical_render_2() { + let ical = fs::read_to_string("testdata/ical-example-2.ics").unwrap(); + let html = render_ical_summary(&ical).unwrap(); + println!("HTML OUTPUT: {}", html); + assert!(html.contains("Summary: [tenative] dinner w/ amatute
")); + assert!(html.contains("Start: 2025-08-13T01:00:00Z
")); + assert!(html.contains("End: 2025-08-13T03:00:00Z
")); + assert!(html.contains("Organizer: Family
")); + } }