server: broken parsing of google ics

This commit is contained in:
Bill Thiede 2025-08-19 09:51:58 -07:00
parent 574de65c35
commit c2428c073c
3 changed files with 155 additions and 5 deletions

55
Cargo.lock generated
View File

@ -2410,7 +2410,7 @@ dependencies = [
"base64 0.21.7", "base64 0.21.7",
"byteorder", "byteorder",
"flate2", "flate2",
"nom", "nom 7.1.3",
"num-traits", "num-traits",
] ]
@ -2856,6 +2856,28 @@ dependencies = [
"cc", "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]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.0.0" version = "2.0.0"
@ -3095,6 +3117,15 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 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]] [[package]]
name = "itertools" name = "itertools"
version = "0.14.0" version = "0.14.0"
@ -3219,6 +3250,8 @@ dependencies = [
"futures 0.3.31", "futures 0.3.31",
"headers", "headers",
"html-escape", "html-escape",
"ical",
"icalendar",
"letterbox-notmuch 0.17.34", "letterbox-notmuch 0.17.34",
"letterbox-shared 0.17.34", "letterbox-shared 0.17.34",
"linkify", "linkify",
@ -3859,6 +3892,24 @@ dependencies = [
"minimal-lexical", "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]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.46.0" version = "0.46.0"
@ -6200,7 +6251,7 @@ version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e810cdeeebca57fc3f7bfec5f85fdbea9031b2ac9b990eb5ff49b371d52bbe6a" checksum = "e810cdeeebca57fc3f7bfec5f85fdbea9031b2ac9b990eb5ff49b371d52bbe6a"
dependencies = [ dependencies = [
"nom", "nom 7.1.3",
"serde", "serde",
"serde_json", "serde_json",
] ]

View File

@ -29,6 +29,8 @@ flate2 = "1.1.2"
futures = "0.3.31" futures = "0.3.31"
headers = "0.4.0" headers = "0.4.0"
html-escape = "0.2.13" html-escape = "0.2.13"
icalendar = "0.17.1"
ical = "0.10"
letterbox-notmuch = { path = "../notmuch", version = "0.17.34", registry = "xinu" } letterbox-notmuch = { path = "../notmuch", version = "0.17.34", registry = "xinu" }
letterbox-shared = { path = "../shared", version = "0.17.34", registry = "xinu" } letterbox-shared = { path = "../shared", version = "0.17.34", registry = "xinu" }
linkify = "0.10.0" linkify = "0.10.0"

View File

@ -24,6 +24,7 @@ const MULTIPART_ALTERNATIVE: &'static str = "multipart/alternative";
const MULTIPART_MIXED: &'static str = "multipart/mixed"; const MULTIPART_MIXED: &'static str = "multipart/mixed";
const MULTIPART_RELATED: &'static str = "multipart/related"; const MULTIPART_RELATED: &'static str = "multipart/related";
const MULTIPART_REPORT: &'static str = "multipart/report"; const MULTIPART_REPORT: &'static str = "multipart/report";
const TEXT_CALENDAR: &'static str = "text/calendar";
const TEXT_HTML: &'static str = "text/html"; const TEXT_HTML: &'static str = "text/html";
const TEXT_PLAIN: &'static str = "text/plain"; const TEXT_PLAIN: &'static str = "text/plain";
@ -308,6 +309,7 @@ pub fn extract_alternative(
MULTIPART_ALTERNATIVE, MULTIPART_ALTERNATIVE,
MULTIPART_MIXED, MULTIPART_MIXED,
MULTIPART_RELATED, MULTIPART_RELATED,
TEXT_CALENDAR,
TEXT_HTML, TEXT_HTML,
TEXT_PLAIN, TEXT_PLAIN,
]; ];
@ -332,6 +334,13 @@ pub fn extract_alternative(
return Ok(Body::html(body)); 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 { for sp in &m.subparts {
if sp.ctype.mimetype.as_str() == TEXT_PLAIN { if sp.ctype.mimetype.as_str() == TEXT_PLAIN {
let body = sp.get_body()?; let body = sp.get_body()?;
@ -414,9 +423,9 @@ pub fn extract_mixed(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body
// For DMARC, it's always XML. // For DMARC, it's always XML.
// Pretty print JSON (if it were TLS) // Pretty print JSON (if it were TLS)
if let Ok(parsed_json) = serde_json::from_str::<serde_json::Value>(&xml) { if let Ok(parsed_json) = serde_json::from_str::<serde_json::Value>(&xml) {
serde_json::to_string_pretty(&parsed_json).unwrap_or(xml) serde_json::to_string_pretty(&parsed_json).unwrap_or(xml.to_string())
} else { } else {
xml xml.to_string()
} }
} else { } else {
// DMARC reports are XML // DMARC reports are XML
@ -425,7 +434,7 @@ pub fn extract_mixed(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body
Ok(pretty_xml) => pretty_xml, Ok(pretty_xml) => pretty_xml,
Err(e) => { Err(e) => {
error!("Failed to pretty print XML: {:?}", 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<String, ServerE
Ok(String::from_utf8(result)?) Ok(String::from_utf8(result)?)
} }
use ical::IcalParser;
pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
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!("<b>Summary:</b> {}<br>", summary));
}
if let Some(dtstart) = dtstart {
let formatted = parse_ical_datetime(dtstart).unwrap_or_else(|| dtstart.to_string());
event_summary.push_str(&format!("<b>Start:</b> {}<br>", formatted));
}
if let Some(dtend) = dtend {
let formatted = parse_ical_datetime(dtend).unwrap_or_else(|| dtend.to_string());
event_summary.push_str(&format!("<b>End:</b> {}<br>", formatted));
}
if let Some(cn) = organizer_cn {
event_summary.push_str(&format!("<b>Organizer:</b> {}<br>", cn));
} else if let Some(organizer) = organizer {
event_summary.push_str(&format!("<b>Organizer:</b> {}<br>", organizer));
}
summary_parts.push(event_summary);
}
}
fn parse_ical_datetime(dt: &str) -> Option<String> {
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<Utc> = 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("<hr>"))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::fs; use std::fs;
@ -1283,4 +1359,25 @@ mod tests {
let html = parse_dmarc_report(&xml).unwrap(); let html = parse_dmarc_report(&xml).unwrap();
assert!(!html.contains("Envelope To")); 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("<b>Summary:</b> dentist night guard<br>"));
assert!(html.contains("<b>Start:</b> 2025-01-08T08:00:00<br>"));
assert!(html.contains("<b>End:</b> 2025-01-08T09:00:00<br>"));
assert!(html.contains("<b>Organizer:</b> Bill Thiede<br>"));
}
#[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("<b>Summary:</b> [tenative] dinner w/ amatute<br>"));
assert!(html.contains("<b>Start:</b> 2025-08-13T01:00:00Z<br>"));
assert!(html.contains("<b>End:</b> 2025-08-13T03:00:00Z<br>"));
assert!(html.contains("<b>Organizer:</b> Family<br>"));
}
} }