From 5b48c5dbc3d007f40999714b8f128dbcf5e4bc1b Mon Sep 17 00:00:00 2001 From: Bill Thiede Date: Tue, 19 Aug 2025 13:26:33 -0700 Subject: [PATCH] server: move calendar rendering to askama template --- server/src/email_extract.rs | 508 +++++++++-------------------- server/src/templates.rs | 7 - server/templates/ical_summary.html | 55 ++++ 3 files changed, 208 insertions(+), 362 deletions(-) delete mode 100644 server/src/templates.rs create mode 100644 server/templates/ical_summary.html diff --git a/server/src/email_extract.rs b/server/src/email_extract.rs index 5bf4cc7..0053c04 100644 --- a/server/src/email_extract.rs +++ b/server/src/email_extract.rs @@ -1091,6 +1091,18 @@ pub struct TlsReportTemplate<'a> { pub report: &'a FormattedTlsRpt, } +#[derive(Template)] +#[template(path = "ical_summary.html")] +pub struct IcalSummaryTemplate<'a> { + pub summary: &'a str, + pub local_fmt_start: &'a str, + pub local_fmt_end: &'a str, + pub organizer: &'a str, + pub organizer_cn: &'a str, + pub calendar_html: &'a str, + pub description_paragraphs: &'a [String], +} + // Add this helper function to parse the DMARC XML and summarize it. pub fn parse_dmarc_report(xml: &str) -> Result { let feedback: Feedback = xml_from_str(xml) @@ -1298,7 +1310,6 @@ pub fn render_ical_summary(ical_data: &str) -> Result { 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 description = None; let mut dtstart = None; @@ -1336,376 +1347,163 @@ pub fn render_ical_summary(ical_data: &str) -> Result { } // Parse start/end as chrono DateTime - let (start_dt, end_dt, local_fmt_start, local_fmt_end, event_days) = - if let Some(dtstart) = dtstart { - let tz: Tz = tzid - .as_deref() - .unwrap_or("UTC") - .parse() - .unwrap_or(chrono_tz::UTC); - let fallback = tz.from_utc_datetime( - &chrono::NaiveDateTime::from_timestamp_opt(0, 0).unwrap(), - ); - let start = parse_ical_datetime_tz(dtstart, tz).unwrap_or(fallback); - let end = dtend - .and_then(|d| parse_ical_datetime_tz(d, tz)) - .unwrap_or(start); - let local_start = start.with_timezone(&Local); - let local_end = end.with_timezone(&Local); - let fmt_start = local_start.format("%c").to_string(); - let fmt_end = local_end.format("%c").to_string(); - let mut days = vec![]; - let mut d = start.date_naive(); - let mut end_d = end.date_naive(); - // Check for all-day event (DATE, not DATE-TIME) - let allday = - dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false)); - if allday { - // DTEND is exclusive for all-day events - if end_d > d { - end_d = end_d.pred_opt().unwrap(); - } - } - // Only include actual event days - let mut day_iter = d; - while day_iter <= end_d { - days.push(day_iter); - day_iter = day_iter.succ_opt().unwrap(); - } - #[cfg(test)] - println!("DEBUG event_days: {:?}", days); - (start, end, fmt_start, fmt_end, days) - } else { - let tz = chrono_tz::UTC; - let fallback = tz.from_utc_datetime( - &chrono::NaiveDateTime::from_timestamp_opt(0, 0).unwrap(), - ); - (fallback, fallback, String::new(), String::new(), vec![]) - }; - - // Render a single merged calendar table for all spanned months - let mut calendar_html = String::new(); - if !event_days.is_empty() { - use chrono::Datelike; - let first_event = event_days.first().unwrap(); - let last_event = event_days.last().unwrap(); - // First day of the first event's month - let first_of_month = - NaiveDate::from_ymd_opt(first_event.year(), first_event.month(), 1).unwrap(); - // Last day of the last event's month - let last_of_month = { - let next_month = if last_event.month() == 12 { - NaiveDate::from_ymd_opt(last_event.year() + 1, 1, 1).unwrap() - } else { - NaiveDate::from_ymd_opt(last_event.year(), last_event.month() + 1, 1) - .unwrap() - }; - next_month.pred_opt().unwrap() - }; - // Find the first Sunday before or on the first day of the first event month - let mut cal_start = first_of_month; - while cal_start.weekday() != chrono::Weekday::Sun { - cal_start = cal_start.pred_opt().unwrap(); - } - // Find the last Saturday after or on the last day of the last event month - let mut cal_end = last_of_month; - while cal_end.weekday() != chrono::Weekday::Sat { - cal_end = cal_end.succ_opt().unwrap(); - } - // Collect all days in the calendar range - let mut all_days = vec![]; - let mut d = cal_start; - while d <= cal_end { - all_days.push(d); - d = d.succ_opt().unwrap(); - } - // Table header: show month/year range - let start_month = first_event.format("%B %Y"); - let end_month = last_event.format("%B %Y"); - let caption = if start_month.to_string() == end_month.to_string() { - start_month.to_string() - } else { - format!("{} – {}", start_month, end_month) - }; - calendar_html.push_str(&format!( - "" - )); - calendar_html.push_str(&format!("", caption)); - calendar_html.push_str(""); - for wd in ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] { - calendar_html.push_str(&format!("", wd)); - } - calendar_html.push_str(""); - for week in all_days.chunks(7) { - calendar_html.push_str(""); - for day in week { - #[cfg(test)] - println!("CAL DAY: {} is_event: {}", day, event_days.contains(day)); - let is_event = event_days.contains(day); - let style = if is_event { - "background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;" - } else { - "border:1px solid #eee; text-align:center;background:#f7f7f7;color:#bbb;" - }; - calendar_html.push_str(&format!( - "", - style, - day.day() - )); - } - calendar_html.push_str(""); - } - calendar_html.push_str("
{}
{}
{}
"); - } - // Remove block-level summary wrapper, move its styles to the flex child - let summary_inner = { - let mut s = String::new(); - s.push_str("
Summary: "); - if let Some(summary) = summary { - s.push_str(summary); - } - s.push_str("
"); - s.push_str(&format!( - "
Start: {}
", - local_fmt_start - )); - s.push_str(&format!( - "
End: {}
", - local_fmt_end - )); - if let Some(cn) = organizer_cn { - s.push_str(&format!( - "
Organizer: {}
", - cn - )); - } else if let Some(organizer) = organizer { - s.push_str(&format!( - "
Organizer: {}
", - organizer - )); - } - s - }; - - // Responsive layout: row with wrap by default, summary expands, calendar right-justified - let event_block = format!( - "\ -
\ -
{}
\ - {}\ -
", - summary_inner, - if !calendar_html.is_empty() { - format!("
{}
", calendar_html) - } else { - String::new() - } - ); - - // Description: only if present and non-whitespace, below summary+calendar - let mut desc_block = String::new(); - if let Some(desc) = description { - // Replace escaped newlines with real newlines, then split - let desc = desc.replace("\\n", "\n"); - if desc.trim().len() > 0 { - let paragraphs: Vec = desc - .lines() - .map(|line| line.trim()) - .filter(|line| !line.is_empty()) - .map(|line| { - format!("

{}

", line) - }) - .collect(); - if !paragraphs.is_empty() { - desc_block = format!( - "
{}
", - paragraphs.join("\n") - ); + let (local_fmt_start, local_fmt_end, event_days) = if let Some(dtstart) = dtstart { + let tz: Tz = tzid + .as_deref() + .unwrap_or("UTC") + .parse() + .unwrap_or(chrono_tz::UTC); + let fallback = + tz.from_utc_datetime(&chrono::NaiveDateTime::from_timestamp_opt(0, 0).unwrap()); + let start = parse_ical_datetime_tz(dtstart, tz).unwrap_or(fallback); + let end = dtend + .and_then(|d| parse_ical_datetime_tz(d, tz)) + .unwrap_or(start); + let local_start = start.with_timezone(&Local); + let local_end = end.with_timezone(&Local); + let fmt_start = local_start.format("%c").to_string(); + let fmt_end = local_end.format("%c").to_string(); + let mut days = vec![]; + let mut d = start.date_naive(); + let mut end_d = end.date_naive(); + // Check for all-day event (DATE, not DATE-TIME) + let allday = dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false)); + if allday { + // DTEND is exclusive for all-day events + if end_d > d { + end_d = end_d.pred_opt().unwrap(); } } - } - summary_parts.push(format!("{}{}", event_block, desc_block)); - } - } - - fn render_month_calendar_widget(date: NaiveDate, event_days: &[NaiveDate]) -> String { - let first = date.with_day(1).unwrap(); - let last = first - .with_day(1) - .unwrap() - .succ_opt() - .unwrap_or(first) - .pred_opt() - .unwrap_or(first); - let month_name = first.format("%B").to_string(); - let year = first.year(); - let mut html = String::from( - "" - ); - html.push_str(&format!("", month_name, year)); - html.push_str(""); - for wd in ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] { - html.push_str(&format!("", wd)); - } - html.push_str(""); - let mut cur = first; - let mut started = false; - for _ in 0..first.weekday().num_days_from_sunday() { - html.push_str(""); - } - while cur.month() == first.month() { - if cur.weekday() == chrono::Weekday::Sun && started { - html.push_str(""); - } - started = true; - if event_days.contains(&cur) { - html.push_str(&format!("", cur.day())); + // Only include actual event days + let mut day_iter = d; + while day_iter <= end_d { + days.push(day_iter); + day_iter = day_iter.succ_opt().unwrap(); + } + (fmt_start, fmt_end, days) } else { - html.push_str(&format!( - "", - cur.day() - )); - } - cur = match cur.succ_opt() { - Some(d) => d, - None => break, + (String::new(), String::new(), vec![]) }; - } - let last_wd = last.weekday().num_days_from_sunday(); - for _ in last_wd + 1..7 { - html.push_str(""); - } - html.push_str("
{} {}
{}
{}{}
"); - html - } - fn parse_ical_datetime_tz(dt: &str, tz: Tz) -> Option> { - let dt = dt.split(':').last().unwrap_or(dt); - if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(dt, "%Y%m%dT%H%M%SZ") { - Some(tz.from_utc_datetime(&ndt)) - } else if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(dt, "%Y%m%dT%H%M%S") { - match tz.from_local_datetime(&ndt) { - LocalResult::Single(dt) => Some(dt), - _ => None, - } - } else if let Ok(nd) = chrono::NaiveDate::parse_from_str(dt, "%Y%m%d") { - // All-day event: treat as midnight in local time - let ndt = nd.and_hms_opt(0, 0, 0).unwrap(); - match tz.from_local_datetime(&ndt) { - LocalResult::Single(dt) => Some(dt), - _ => None, - } - } else { - None + // Render calendar table HTML + let calendar_html = if !event_days.is_empty() { + render_merged_calendar_table(&event_days) + } else { + String::new() + }; + + // Description paragraphs + let description_paragraphs: Vec = if let Some(desc) = description { + let desc = desc.replace("\\n", "\n"); + desc.lines() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) + .collect::>() + } else { + Vec::new() + }; + + let summary_val = summary.unwrap_or(""); + let organizer_val = organizer.unwrap_or(""); + let organizer_cn_val = organizer_cn.unwrap_or(""); + let local_fmt_start_val = &local_fmt_start; + let local_fmt_end_val = &local_fmt_end; + let calendar_html_val = &calendar_html; + let description_paragraphs_val = &description_paragraphs; + let template = IcalSummaryTemplate { + summary: summary_val, + local_fmt_start: local_fmt_start_val, + local_fmt_end: local_fmt_end_val, + organizer: organizer_val, + organizer_cn: organizer_cn_val, + calendar_html: calendar_html_val, + description_paragraphs: description_paragraphs_val, + }; + summary_parts.push(template.render()?); } } - - fn parse_ical_datetime(dt: &str) -> Option { - use chrono::{DateTime, NaiveDateTime, 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 = TimeZone::from_utc_datetime(&Utc, &ndt); - 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; - - use super::*; - - #[test] - fn test_parse_dmarc_report() { - let xml = fs::read_to_string("testdata/dmarc-example.xml").unwrap(); - let html = parse_dmarc_report(&xml).unwrap(); - assert!(html.contains("hotmail.com")); - assert!(html.contains("msn.com")); +fn render_merged_calendar_table(event_days: &[NaiveDate]) -> String { + use chrono::Datelike; + let first_event = event_days.first().unwrap(); + let last_event = event_days.last().unwrap(); + let first_of_month = + NaiveDate::from_ymd_opt(first_event.year(), first_event.month(), 1).unwrap(); + let last_of_month = { + let next_month = if last_event.month() == 12 { + NaiveDate::from_ymd_opt(last_event.year() + 1, 1, 1).unwrap() + } else { + NaiveDate::from_ymd_opt(last_event.year(), last_event.month() + 1, 1).unwrap() + }; + next_month.pred_opt().unwrap() + }; + let mut cal_start = first_of_month; + while cal_start.weekday() != chrono::Weekday::Sun { + cal_start = cal_start.pred_opt().unwrap(); } - - #[test] - fn test_parse_dmarc_report_no_envelope_to() { - let xml = fs::read_to_string("testdata/dmarc-example-no-envelope-to.xml").unwrap(); - let html = parse_dmarc_report(&xml).unwrap(); - assert!(!html.contains("Envelope To")); + let mut cal_end = last_of_month; + while cal_end.weekday() != chrono::Weekday::Sat { + cal_end = cal_end.succ_opt().unwrap(); } - - #[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: ")); - assert!(html.contains("End: ")); - assert!(html.contains("Organizer: Bill Thiede")); + let mut all_days = vec![]; + let mut d = cal_start; + while d <= cal_end { + all_days.push(d); + d = d.succ_opt().unwrap(); } - - #[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: ")); - assert!(html.contains("End: ")); - assert!(html.contains("Organizer: Family")); + let start_month = first_event.format("%B %Y"); + let end_month = last_event.format("%B %Y"); + let caption = if start_month.to_string() == end_month.to_string() { + start_month.to_string() + } else { + format!("{} – {}", start_month, end_month) + }; + let mut calendar_html = String::new(); + calendar_html.push_str(&format!( + "" + )); + calendar_html.push_str(&format!("", caption)); + calendar_html.push_str(""); + for wd in ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] { + calendar_html.push_str(&format!("", wd)); } - - #[test] - fn test_ical_multiday() { - let ical = fs::read_to_string("testdata/ical-multiday.ics").unwrap(); - let html = render_ical_summary(&ical).unwrap(); - println!("HTML MULTIDAY: {}", html); - // Print the calendar row containing 28, 29, 30 for debug - if let Some(row_start) = html.find("") { - if let Some(row_end) = html[row_start..].find("") { - println!( - "CALENDAR ROW: {}", - &html[row_start..row_start + row_end + 5] - ); - } - } - assert!(html.contains("Summary: Multi-day Event")); - assert!(html.contains("This event spans multiple days")); - // Should highlight 28, 29, 30 in the merged calendar table (event days), and dim 27, 31 (out-of-event) - for day in [28, 29, 30] { - assert!(html.contains(&format!("", day)), "Missing highlighted day {}", day); - } - for day in [27, 31] { - assert!(html.contains(&format!("", day)), "Missing dimmed day {}", day); + calendar_html.push_str(""); + for week in all_days.chunks(7) { + calendar_html.push_str(""); + for day in week { + let is_event = event_days.contains(day); + let style = if is_event { + "background:#ffd700; color:#222; font-weight:bold; border:1px solid #aaa; border-radius:4px; text-align:center;" + } else { + "border:1px solid #eee; text-align:center;background:#f7f7f7;color:#bbb;" + }; + calendar_html.push_str(&format!("", style, day.day())); } + calendar_html.push_str(""); } + calendar_html.push_str("
{}
{}
{}{}
{}
"); + calendar_html +} - #[test] - fn test_ical_straddle() { - let ical = fs::read_to_string("testdata/ical-straddle.ics").unwrap(); - let html = render_ical_summary(&ical).unwrap(); - println!("HTML STRADDLE: {}", html); - assert!(html.contains("Summary: Straddle Month Event")); - assert!(html.contains("This event straddles two months")); - // Should highlight 30, 31 in August and 1, 2 in September - assert!(html.contains("30")); - assert!(html.contains("31")); - assert!(html.contains("1")); - assert!(html.contains("2")); - } - - #[test] - fn test_ical_straddle_real() { - let ical = fs::read_to_string("testdata/ical-straddle-real.ics").unwrap(); - let html = render_ical_summary(&ical).unwrap(); - println!("HTML STRADDLE REAL: {}", html); - // Should highlight 30, 31 in August and 1 in September (DTEND is exclusive) - assert!(html.contains("30")); - assert!(html.contains("31")); - assert!(html.contains("1")); - assert!(!html.contains("2")); +fn parse_ical_datetime_tz(dt: &str, tz: Tz) -> Option> { + let dt = dt.split(':').last().unwrap_or(dt); + if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(dt, "%Y%m%dT%H%M%SZ") { + Some(tz.from_utc_datetime(&ndt)) + } else if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(dt, "%Y%m%dT%H%M%S") { + match tz.from_local_datetime(&ndt) { + LocalResult::Single(dt) => Some(dt), + _ => None, + } + } else if let Ok(nd) = chrono::NaiveDate::parse_from_str(dt, "%Y%m%d") { + // All-day event: treat as midnight in local time + let ndt = nd.and_hms_opt(0, 0, 0).unwrap(); + match tz.from_local_datetime(&ndt) { + LocalResult::Single(dt) => Some(dt), + _ => None, + } + } else { + None } } diff --git a/server/src/templates.rs b/server/src/templates.rs deleted file mode 100644 index e09555b..0000000 --- a/server/src/templates.rs +++ /dev/null @@ -1,7 +0,0 @@ -use askama::Template; - -#[derive(Template)] -#[template(path = "dmarc_report.html")] -pub struct DmarcReportTemplate<'a> { - pub feedback: &'a crate::nm::Feedback, -} diff --git a/server/templates/ical_summary.html b/server/templates/ical_summary.html new file mode 100644 index 0000000..0b11c10 --- /dev/null +++ b/server/templates/ical_summary.html @@ -0,0 +1,55 @@ + + +
+
+
Summary: {{ summary }}
+
Start: {{ local_fmt_start }}
+
End: {{ local_fmt_end }}
+ {% if !organizer_cn.is_empty() %} +
Organizer: {{ organizer_cn }}
+ {% elif !organizer.is_empty() %} +
Organizer: {{ organizer }}
+ {% endif %} +
+ {% if !calendar_html.is_empty() %} + {% if !calendar_html.is_empty() %} +
{{ calendar_html | safe }}
+ {% endif %} + {% endif %} +
+{% if !description_paragraphs.is_empty() %} +
+ {% for p in description_paragraphs %} +

{{ p }}

+ {% endfor %} +
+{% endif %}