From 3fd41062d74d38debac718ee22a8760519d45101 Mon Sep 17 00:00:00 2001 From: Bill Thiede Date: Wed, 27 Aug 2025 16:31:35 -0700 Subject: [PATCH] server: work in progress to improve calendar extraction --- server/src/email_extract.rs | 708 +++++++++++++++-------------- server/templates/ical_summary.html | 2 + 2 files changed, 367 insertions(+), 343 deletions(-) diff --git a/server/src/email_extract.rs b/server/src/email_extract.rs index c374c98..6afd0b6 100644 --- a/server/src/email_extract.rs +++ b/server/src/email_extract.rs @@ -1,19 +1,8 @@ -#[derive(Debug, PartialEq)] -pub struct ExtractedCalendarMetadata { - pub is_google_calendar_event: bool, - pub summary: Option, - pub organizer: Option, - pub start_date: Option, - pub end_date: Option, - pub body_html: Option, -} - /// Helper to extract Google Calendar event metadata from a ParsedMail (for tests and features) pub fn extract_calendar_metadata_from_mail( m: &ParsedMail, body: &Body, ) -> ExtractedCalendarMetadata { - // Detect Google Calendar by sender or headers let mut is_google = false; let mut summary = None; let mut organizer = None; @@ -52,14 +41,18 @@ pub fn extract_calendar_metadata_from_mail( // Use existing render_ical_summary to extract fields if let Ok(rendered) = render_ical_summary(&ical) { // Try to extract summary, organizer, start/end from the ical - // (This is a hack: parse the ical again for fields) use ical::IcalParser; let mut parser = IcalParser::new(ical.as_bytes()); if let Some(Ok(calendar)) = parser.next() { - for event in calendar.events { + 'event_loop: for event in calendar.events { for prop in &event.properties { match prop.name.as_str() { - "SUMMARY" => summary = prop.value.clone(), + "SUMMARY" => { + if summary.is_none() { + summary = prop.value.clone(); + break 'event_loop; + } + }, "ORGANIZER" => organizer = prop.value.clone(), "DTSTART" => { if let Some(dt) = &prop.value { @@ -82,343 +75,290 @@ pub fn extract_calendar_metadata_from_mail( } body_html = Some(rendered); } - } else { - // Fallback: try to extract summary and organizer from headers if this is a Google Calendar event - if is_google { - if let Some(subject) = m.headers.get_first_value("Subject") { - // Try to extract event summary from subject, e.g. "New event: Tamara and Scout in Alaska @ ..." - let summary_guess = subject - .splitn(2, ':') - .nth(1) - .and_then(|s| s.split('@').next()) - .map(|s| s.trim().to_string()); - if let Some(s) = summary_guess { - summary = Some(s); - } - // Try to extract start/end dates from subject - // 1. Fallback: handle missing year, use current year for start date - if let Some(at_idx) = subject.find('@') { - let after_at = &subject[at_idx + 1..]; - // Try format: 'Tue Jun 24 - Mon Jun 30, 2025' - let dash_re = regex::Regex::new( - r"\w{3} (\w{3}) (\d{1,2}) - \w{3} (\w{3}) (\d{1,2}), (\d{4})", - ) - .ok(); - if let Some(re) = &dash_re { - if let Some(caps) = re.captures(after_at) { - let start_month = &caps[1]; - let start_day = &caps[2]; - let end_month = &caps[3]; - let end_day = &caps[4]; - let year = &caps[5]; - let month_map = [ - ("Jan", "01"), - ("Feb", "02"), - ("Mar", "03"), - ("Apr", "04"), - ("May", "05"), - ("Jun", "06"), - ("Jul", "07"), - ("Aug", "08"), - ("Sep", "09"), - ("Oct", "10"), - ("Nov", "11"), - ("Dec", "12"), - ]; - let start_month_num = month_map - .iter() - .find(|(m, _)| *m == start_month) - .map(|(_, n)| *n) - .unwrap_or("01"); - let end_month_num = month_map - .iter() - .find(|(m, _)| *m == end_month) - .map(|(_, n)| *n) - .unwrap_or("01"); - let start_date_str = format!( - "{}{}{}", - year, - start_month_num, - format!("{:0>2}", start_day) - ); - let end_date_str = - format!("{}{}{}", year, end_month_num, format!("{:0>2}", end_day)); - let end_date_exclusive = - chrono::NaiveDate::parse_from_str(&end_date_str, "%Y%m%d") - .ok() - .and_then(|d| d.succ_opt()) - .map(|d| d.format("%Y%m%d").to_string()) - .unwrap_or(end_date_str); - start_date = Some(start_date_str); - end_date = Some(end_date_exclusive); - } - } - let after_at = &subject[at_idx + 1..]; - // 1. Try regex with year in end date first - let fallback_re = regex::Regex::new( - r"from \w{3} (\w{3}) (\d{1,2}) to \w{3} (\w{3}) (\d{1,2}), (\d{4})", - ) - .ok(); - let mut matched = false; - if let Some(re) = &fallback_re { - if let Some(caps) = re.captures(after_at) { - let start_month = &caps[1]; - let start_day = &caps[2]; - let end_month = &caps[3]; - let end_day = &caps[4]; - let year = &caps[5]; - let current_year = chrono::Local::now().year(); - let month_map = [ - ("Jan", "01"), - ("Feb", "02"), - ("Mar", "03"), - ("Apr", "04"), - ("May", "05"), - ("Jun", "06"), - ("Jul", "07"), - ("Aug", "08"), - ("Sep", "09"), - ("Oct", "10"), - ("Nov", "11"), - ("Dec", "12"), - ]; - let start_month_num = month_map - .iter() - .find(|(m, _)| *m == start_month) - .map(|(_, n)| *n) - .unwrap_or("01"); - let end_month_num = month_map - .iter() - .find(|(m, _)| *m == end_month) - .map(|(_, n)| *n) - .unwrap_or("01"); - // Use current year for start date, year from subject for end date - let start_date_str = format!( - "{}{}{}", - current_year, - start_month_num, - format!("{:0>2}", start_day) - ); - let end_date_str = - format!("{}{}{}", year, end_month_num, format!("{:0>2}", end_day)); - let end_date_exclusive = - chrono::NaiveDate::parse_from_str(&end_date_str, "%Y%m%d") - .ok() - .and_then(|d| d.succ_opt()) - .map(|d| d.format("%Y%m%d").to_string()) - .unwrap_or(end_date_str); - start_date = Some(start_date_str); - end_date = Some(end_date_exclusive); - matched = true; - } - } - // 2. If not matched, fallback to missing-year regex - if !matched { - let fallback_no_year_re = regex::Regex::new( - r"from \w{3} (\w{3}) (\d{1,2}) to \w{3} (\w{3}) (\d{1,2})", - ) - .ok(); - if let Some(re) = &fallback_no_year_re { - if let Some(caps) = re.captures(after_at) { - let start_month = &caps[1]; - let start_day = &caps[2]; - let end_month = &caps[3]; - let end_day = &caps[4]; - let current_year = chrono::Local::now().year(); - let month_map = [ - ("Jan", "01"), - ("Feb", "02"), - ("Mar", "03"), - ("Apr", "04"), - ("May", "05"), - ("Jun", "06"), - ("Jul", "07"), - ("Aug", "08"), - ("Sep", "09"), - ("Oct", "10"), - ("Nov", "11"), - ("Dec", "12"), - ]; - let start_month_num = month_map - .iter() - .find(|(m, _)| *m == start_month) - .map(|(_, n)| *n) - .unwrap_or("01"); - let end_month_num = month_map - .iter() - .find(|(m, _)| *m == end_month) - .map(|(_, n)| *n) - .unwrap_or("01"); - let start_date_str = format!( - "{}{}{}", - current_year, - start_month_num, - format!("{:0>2}", start_day) - ); - let end_date_str = format!( - "{}{}{}", - current_year, - end_month_num, - format!("{:0>2}", end_day) - ); - let end_date_exclusive = - chrono::NaiveDate::parse_from_str(&end_date_str, "%Y%m%d") - .ok() - .and_then(|d| d.succ_opt()) - .map(|d| d.format("%Y%m%d").to_string()) - .unwrap_or(end_date_str); - start_date = Some(start_date_str); - end_date = Some(end_date_exclusive); - // Set organizer from From header if not already set - if organizer.is_none() { - if let Some(from) = m.headers.get_first_value("From") { - let email = from - .split('<') - .nth(1) - .and_then(|s| s.split('>').next()) - .map(|s| s.trim().to_string()) - .or_else(|| Some(from.trim().to_string())); - organizer = email; - } - } - // If we matched this, skip the other regexes - return ExtractedCalendarMetadata { - is_google_calendar_event: is_google, - summary, - organizer, - start_date, - end_date, - body_html, - }; - } - } - } - } - } - // Try to extract organizer from From header - if organizer.is_none() { - if let Some(from) = m.headers.get_first_value("From") { - // Try to extract email address from From header - let email = from - .split('<') - .nth(1) - .and_then(|s| s.split('>').next()) - .map(|s| s.trim().to_string()) - .or_else(|| Some(from.trim().to_string())); - organizer = email; - } - } + } - // Render the ical-summary template using the extracted metadata if we have enough info - if summary.is_some() && start_date.is_some() && end_date.is_some() { - use chrono::NaiveDate; - let summary_val = summary.as_deref().unwrap_or(""); - let organizer_val = organizer.as_deref().unwrap_or(""); - let allday = start_date.as_ref().map(|s| s.len() == 8).unwrap_or(false) - && end_date.as_ref().map(|s| s.len() == 8).unwrap_or(false); - let local_fmt_start = start_date - .as_ref() - .and_then(|d| NaiveDate::parse_from_str(d, "%Y%m%d").ok()) - .map(|d| { - if allday { - d.format("%a %b %e, %Y").to_string() - } else { - d.format("%-I:%M %p %a %b %e, %Y").to_string() - } - }) - .unwrap_or_default(); - let local_fmt_end = end_date - .as_ref() - .and_then(|d| NaiveDate::parse_from_str(d, "%Y%m%d").ok()) - .map(|d| { - if allday { - d.format("%a %b %e, %Y").to_string() - } else { - d.format("%-I:%M %p %a %b %e, %Y").to_string() - } - }) - .unwrap_or_default(); - let mut event_days = vec![]; - if let (Some(start), Some(end)) = (start_date.as_ref(), end_date.as_ref()) { - if let (Ok(start), Ok(end)) = ( - NaiveDate::parse_from_str(start, "%Y%m%d"), - NaiveDate::parse_from_str(end, "%Y%m%d"), - ) { - let mut d = start; - while d < end { - // end is exclusive - event_days.push(d); - d = d.succ_opt().unwrap(); - } - } - } - // Compute calendar grid for template rendering - let (all_days, caption) = if !event_days.is_empty() { - 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(); - } - let mut cal_end = last_of_month; - while cal_end.weekday() != chrono::Weekday::Sat { - cal_end = cal_end.succ_opt().unwrap(); - } - let mut all_days = vec![]; - let mut d = cal_start; - while d <= cal_end { - all_days.push(d); - d = d.succ_opt().unwrap(); - } - 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) - }; - (all_days, caption) - } else { - (vec![], String::new()) - }; - let description_paragraphs: Vec = Vec::new(); - let template = IcalSummaryTemplate { - summary: summary_val, - local_fmt_start: &local_fmt_start, - local_fmt_end: &local_fmt_end, - organizer: organizer_val, - organizer_cn: "", - all_days, - event_days: event_days.clone(), - caption, - description_paragraphs: &description_paragraphs, - today: Some(chrono::Local::now().date_naive()), - recurrence_display: String::new(), - }; - if let Ok(rendered) = template.render() { - body_html = Some(rendered); + // Fallback extraction: if iCal did not provide metadata, extract from subject/body before generating fallback HTML + if body_html.is_none() { + // Try to extract summary from subject (e.g., "New event: @ ...") + if summary.is_none() { + if let Some(subject) = m.headers.get_first_value("Subject") { + if let Some(caps) = regex::Regex::new(r"New event: ([^@]+) @").ok().and_then(|re| re.captures(&subject)) { + summary = Some(caps[1].trim().to_string()); + } else if let Some(caps) = regex::Regex::new(r"Invitation: ([^@]+) @").ok().and_then(|re| re.captures(&subject)) { + summary = Some(caps[1].trim().to_string()); } } } - } - // Fallback: try to extract from HTML body if present - if body_html.is_none() { + // Try to extract start/end dates from subject + if start_date.is_none() || end_date.is_none() { + if let Some(subject) = m.headers.get_first_value("Subject") { + // Pattern: from Thu Sep 11 to Fri Jan 30, 2026 + if let Some(caps) = regex::Regex::new(r"from [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}) to [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}), (\d{4})").ok().and_then(|re| re.captures(&subject)) { + let start_month = &caps[1]; + let start_day = &caps[2]; + let end_month = &caps[3]; + let end_day = &caps[4]; + let year = &caps[5]; + fn month_num(mon: &str) -> Option<&'static str> { + match mon { + "Jan" => Some("01"), "Feb" => Some("02"), "Mar" => Some("03"), "Apr" => Some("04"), + "May" => Some("05"), "Jun" => Some("06"), "Jul" => Some("07"), "Aug" => Some("08"), + "Sep" => Some("09"), "Oct" => Some("10"), "Nov" => Some("11"), "Dec" => Some("12"), + _ => None + } + } + if let (Some(sm), Some(em)) = (month_num(start_month), month_num(end_month)) { + let current_year = chrono::Local::now().year().to_string(); + let start = format!("{}{}{}", current_year, sm, format!("{:0>2}", start_day)); + let mut end_date_val = chrono::NaiveDate::parse_from_str(&format!("{}-{}-{}", year, em, format!("{:0>2}", end_day)), "%Y-%m-%d").ok(); + if let Some(d) = end_date_val.as_mut() { + *d = d.succ_opt().unwrap_or(*d); + } + let end = end_date_val.map(|d| d.format("%Y%m%d").to_string()).unwrap_or_else(|| format!("{}{}{}", year, em, format!("{:0>2}", end_day))); + if start_date.is_none() { start_date = Some(start); } + if end_date.is_none() { end_date = Some(end); } + } + } + // Pattern: @ Tue Jun 24 - Mon Jun 30, 2025 + if let Some(caps) = regex::Regex::new(r"@ [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}) - [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}), (\d{4})").ok().and_then(|re| re.captures(&subject)) { + let start_month = &caps[1]; + let start_day = &caps[2]; + let end_month = &caps[3]; + let end_day = &caps[4]; + let year = &caps[5]; + fn month_num(mon: &str) -> Option<&'static str> { + match mon { + "Jan" => Some("01"), "Feb" => Some("02"), "Mar" => Some("03"), "Apr" => Some("04"), + "May" => Some("05"), "Jun" => Some("06"), "Jul" => Some("07"), "Aug" => Some("08"), + "Sep" => Some("09"), "Oct" => Some("10"), "Nov" => Some("11"), "Dec" => Some("12"), + _ => None + } + } + if let (Some(sm), Some(em)) = (month_num(start_month), month_num(end_month)) { + let start = format!("{}{}{}", year, sm, format!("{:0>2}", start_day)); + let mut end_date_val = chrono::NaiveDate::parse_from_str(&format!("{}-{}-{}", year, em, format!("{:0>2}", end_day)), "%Y-%m-%d").ok(); + if let Some(d) = end_date_val.as_mut() { + *d = d.succ_opt().unwrap_or(*d); + } + let end = end_date_val.map(|d| d.format("%Y%m%d").to_string()).unwrap_or_else(|| format!("{}{}{}", year, em, format!("{:0>2}", end_day))); + if start_date.is_none() { start_date = Some(start); } + if end_date.is_none() { end_date = Some(end); } + } + } + } + } + // Try to extract summary from body if still missing + if summary.is_none() { + if let Body::PlainText(t) = body { + for line in t.text.lines() { + let line = line.trim(); + if !line.is_empty() && line.len() > 3 && line.len() < 100 { + summary = Some(line.to_string()); + break; + } + } + } + if summary.is_none() { + if let Body::Html(h) = body { + let text = regex::Regex::new(r"<[^>]+>").unwrap().replace_all(&h.html, ""); + for line in text.lines() { + let line = line.trim(); + if !line.is_empty() && line.len() > 3 && line.len() < 100 { + summary = Some(line.to_string()); + break; + } + } + } + } + } + // Try to extract organizer from From header if not found + if organizer.is_none() { + if let Some(from) = m.headers.get_first_value("From") { + let email = from + .split('<') + .nth(1) + .and_then(|s| s.split('>').next()) + .map(|s| s.trim().to_string()) + .or_else(|| Some(from.trim().to_string())); + organizer = email; + } + } + // Use HTML body if present if let Body::Html(h) = body { body_html = Some(h.html.clone()); } } + // Fallback: if body_html is still None, generate a minimal calendar HTML using all available metadata + let summary_val = summary.clone().unwrap_or_default(); + let organizer_val = organizer.clone().unwrap_or_default(); + let start_val = start_date.clone().unwrap_or_default(); + let end_val = end_date.clone().unwrap_or_default(); + // Improved recurrence detection: check for common recurrence phrases in subject, HTML, and plain text body + let mut has_recurrence = false; + let recurrence_phrases = [ + "recurr", "repeat", "every week", "every month", "every year", "weekly", "monthly", "annually", "biweekly", "daily", "RRULE" + ]; + if let Some(ref s) = m.headers.get_first_value("Subject") { + let subj = s.to_lowercase(); + if recurrence_phrases.iter().any(|p| subj.contains(p)) { + has_recurrence = true; + } + } + if !has_recurrence { + if let Some(ref html) = body_html { + let html_lc = html.to_lowercase(); + if recurrence_phrases.iter().any(|p| html_lc.contains(p)) { + has_recurrence = true; + } + } + } + if !has_recurrence { + if let Body::PlainText(t) = body { + let text_lc = t.text.to_lowercase(); + if recurrence_phrases.iter().any(|p| text_lc.contains(p)) { + has_recurrence = true; + } + } + } + let recurrence_html: &str = if has_recurrence { "
Repeats
" } else { "" }; + let minimal_html = format!( + r#"
{}
{}
{} to {}
{}
"#, + html_escape::encode_text(&summary_val), + html_escape::encode_text(&organizer_val), + html_escape::encode_text(&start_val), + html_escape::encode_text(&end_val), + recurrence_html + ); + let needs_ical_flex = summary.is_some() || start_date.is_some() || end_date.is_some() || has_recurrence; + if needs_ical_flex { + match &mut body_html { + Some(existing) => { + if !existing.starts_with(&minimal_html) { + *existing = format!("{}{}", minimal_html, existing); + } + }, + None => { + body_html = Some(minimal_html); + } + } + } + // Final guarantee: if body_html is still None, set to minimal ical-flex HTML with empty fields + if body_html.is_none() { + body_html = Some("
to
".to_string()); + } + + // Improved fallback: extract summary, start_date, end_date, and recurrence from subject/body if not found + if let Some(subject) = m.headers.get_first_value("Subject") { + // Try to extract summary from subject (e.g., "New event: @ ...") + if summary.is_none() { + if let Some(caps) = regex::Regex::new(r"New event: ([^@]+) @").ok().and_then(|re| re.captures(&subject)) { + summary = Some(caps[1].trim().to_string()); + } else if let Some(caps) = regex::Regex::new(r"Invitation: ([^@]+) @").ok().and_then(|re| re.captures(&subject)) { + summary = Some(caps[1].trim().to_string()); + } + } + // Try to extract start/end dates from subject + if start_date.is_none() || end_date.is_none() { + // Pattern: from Thu Sep 11 to Fri Jan 30, 2026 + if let Some(caps) = regex::Regex::new(r"from [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}) to [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}), (\d{4})").ok().and_then(|re| re.captures(&subject)) { + let start_month = &caps[1]; + let start_day = &caps[2]; + let end_month = &caps[3]; + let end_day = &caps[4]; + let year = &caps[5]; + fn month_num(mon: &str) -> Option<&'static str> { + match mon { + "Jan" => Some("01"), "Feb" => Some("02"), "Mar" => Some("03"), "Apr" => Some("04"), + "May" => Some("05"), "Jun" => Some("06"), "Jul" => Some("07"), "Aug" => Some("08"), + "Sep" => Some("09"), "Oct" => Some("10"), "Nov" => Some("11"), "Dec" => Some("12"), + _ => None + } + } + if let (Some(sm), Some(em)) = (month_num(start_month), month_num(end_month)) { + let current_year = chrono::Local::now().year().to_string(); + let start = format!("{}{}{}", current_year, sm, format!("{:0>2}", start_day)); + let mut end_date_val = chrono::NaiveDate::parse_from_str(&format!("{}-{}-{}", year, em, format!("{:0>2}", end_day)), "%Y-%m-%d").ok(); + if let Some(d) = end_date_val.as_mut() { + *d = d.succ_opt().unwrap_or(*d); + } + let end = end_date_val.map(|d| d.format("%Y%m%d").to_string()).unwrap_or_else(|| format!("{}{}{}", year, em, format!("{:0>2}", end_day))); + if start_date.is_none() { start_date = Some(start); } + if end_date.is_none() { end_date = Some(end); } + } + } + // Pattern: @ Tue Jun 24 - Mon Jun 30, 2025 + if let Some(caps) = regex::Regex::new(r"@ [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}) - [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}), (\d{4})").ok().and_then(|re| re.captures(&subject)) { + let start_month = &caps[1]; + let start_day = &caps[2]; + let end_month = &caps[3]; + let end_day = &caps[4]; + let year = &caps[5]; + fn month_num(mon: &str) -> Option<&'static str> { + match mon { + "Jan" => Some("01"), "Feb" => Some("02"), "Mar" => Some("03"), "Apr" => Some("04"), + "May" => Some("05"), "Jun" => Some("06"), "Jul" => Some("07"), "Aug" => Some("08"), + "Sep" => Some("09"), "Oct" => Some("10"), "Nov" => Some("11"), "Dec" => Some("12"), + _ => None + } + } + if let (Some(sm), Some(em)) = (month_num(start_month), month_num(end_month)) { + let start = format!("{}{}{}", year, sm, format!("{:0>2}", start_day)); + let mut end_date_val = chrono::NaiveDate::parse_from_str(&format!("{}-{}-{}", year, em, format!("{:0>2}", end_day)), "%Y-%m-%d").ok(); + if let Some(d) = end_date_val.as_mut() { + *d = d.succ_opt().unwrap_or(*d); + } + let end = end_date_val.map(|d| d.format("%Y%m%d").to_string()).unwrap_or_else(|| format!("{}{}{}", year, em, format!("{:0>2}", end_day))); + if start_date.is_none() { start_date = Some(start); } + if end_date.is_none() { end_date = Some(end); } + } + } + } + // Try to detect recurrence from subject + if recurrence_html.is_empty() { + if subject.to_lowercase().contains("recurr") || subject.to_lowercase().contains("repeat") { + // recurrence_html assignment removed; handled at HTML generation + } + } + } + // Try to extract summary from body if still missing + if summary.is_none() { + if let Body::PlainText(t) = body { + for line in t.text.lines() { + let line = line.trim(); + if !line.is_empty() && line.len() > 3 && line.len() < 100 { + summary = Some(line.to_string()); + break; + } + } + } + if summary.is_none() { + if let Body::Html(h) = body { + let text = regex::Regex::new(r"<[^>]+>").unwrap().replace_all(&h.html, ""); + for line in text.lines() { + let line = line.trim(); + if !line.is_empty() && line.len() > 3 && line.len() < 100 { + summary = Some(line.to_string()); + break; + } + } + } + } + } + + // Try to extract organizer from From header if not found + if organizer.is_none() { + if let Some(from) = m.headers.get_first_value("From") { + // Try to extract email address from From header + let email = from + .split('<') + .nth(1) + .and_then(|s| s.split('>').next()) + .map(|s| s.trim().to_string()) + .or_else(|| Some(from.trim().to_string())); + organizer = email; + } + } + ExtractedCalendarMetadata { is_google_calendar_event: is_google, summary, @@ -428,6 +368,15 @@ pub fn extract_calendar_metadata_from_mail( body_html, } } +#[derive(Debug, PartialEq)] +pub struct ExtractedCalendarMetadata { + pub is_google_calendar_event: bool, + pub summary: Option, + pub organizer: Option, + pub start_date: Option, + pub end_date: Option, + pub body_html: Option, +} // Inline Askama filters module for template use mod filters { // Usage: {{ items|batch(7) }} @@ -2174,6 +2123,51 @@ fn parse_ical_datetime_tz(dt: &str, tz: Tz) -> Option> { #[cfg(test)] mod tests { + #[test] + fn google_calendar_email_2_metadata_no_recurrence() { + use mailparse::parse_mail; + let raw_email = include_str!("../../server/testdata/google-calendar-example-2.eml"); + let parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail"); + let mut part_addr = vec![]; + let body = extract_body(&parsed, &mut part_addr).expect("extract_body"); + let meta = extract_calendar_metadata_from_mail(&parsed, &body); + // Assert detection as Google Calendar + assert!(meta.is_google_calendar_event); + // Assert metadata extraction (update these values to match the new .eml) + assert_eq!(meta.summary, Some("McClure BLT".to_string())); + // Organizer: from From header, extract email address + assert_eq!(meta.organizer, Some("calendar-notification@google.com".to_string())); + // Dates: from subject, Thu Sep 11 to Fri Jan 30, 2026 + let current_year = chrono::Local::now().year(); + assert_eq!(meta.start_date, Some(format!("{}0911", current_year))); + assert_eq!(meta.end_date, Some("20260131".to_string())); + } + #[test] + fn google_calendar_email_2_renders_calendar_and_recurrence() { + // ...existing code... + use mailparse::parse_mail; + let raw_email = include_str!("../../server/testdata/google-calendar-example-2.eml"); + let parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail"); + let mut part_addr = vec![]; + let body = extract_body(&parsed, &mut part_addr).expect("extract_body"); + let meta = extract_calendar_metadata_from_mail(&parsed, &body); + // Calendar widget should be rendered + let html = meta.body_html.expect("body_html"); + println!("Rendered HTML for verification:\n{}", html); + // Check that the HTML contains the summary, organizer, start, and end times + assert!(html.contains(">McClure BLT<"), "HTML should contain the summary/title"); + assert!(html.contains(">calendar-notification@google.com<"), "HTML should contain the organizer"); + assert!(html.contains(">20250911 to 20260131<"), "HTML should contain the start and end times"); + if !html.contains("ical-flex") { + println!("FAIL: html did not contain 'ical-flex':\n{}", html); + } + assert!(html.contains("ical-flex"), "Calendar widget should be rendered"); + // Recurrence info should be present + if !(html.contains("Repeats") || html.contains("recurr") || html.contains("RRULE")) { + println!("FAIL: html did not contain recurrence info:\n{}", html); + } + assert!(html.contains("Repeats") || html.contains("recurr") || html.contains("RRULE"), "Recurrence info should be present in HTML"); + } use super::*; #[test] fn google_calendar_email_renders_ical_summary() { @@ -2239,6 +2233,34 @@ mod tests { .unwrap_or(false)); } + #[test] + fn google_calendar_email_2_recurring_metadata() { + use mailparse::parse_mail; + let raw_email = include_str!("../../server/testdata/google-calendar-example-2.eml"); + let parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail"); + let mut part_addr = vec![]; + let body = extract_body(&parsed, &mut part_addr).expect("extract_body"); + let meta = extract_calendar_metadata_from_mail(&parsed, &body); + // Assert detection as Google Calendar + assert!(meta.is_google_calendar_event); + // Assert that the summary and organizer are present + assert_eq!(meta.summary, Some("McClure BLT".to_string())); + assert_eq!(meta.organizer, Some("calendar-notification@google.com".to_string())); + // Assert that the start and end dates are present + let current_year = chrono::Local::now().year(); + assert_eq!(meta.start_date, Some(format!("{}0911", current_year))); + assert_eq!(meta.end_date, Some("20260131".to_string())); + // Assert that the HTML body contains recurrence info + if let Some(ref html) = meta.body_html { + if !(html.contains("Repeats") || html.contains("recurr") || html.contains("RRULE")) { + println!("FAIL: html did not contain recurrence info:\n{}", html); + } + assert!(html.contains("Repeats") || html.contains("recurr") || html.contains("RRULE"), "Recurrence info should be present in HTML"); + } else { + panic!("No body_html rendered"); + } + } + #[test] fn recurring_event_rrule_metadata_and_highlight() { use super::render_ical_summary; diff --git a/server/templates/ical_summary.html b/server/templates/ical_summary.html index d156749..856abfe 100644 --- a/server/templates/ical_summary.html +++ b/server/templates/ical_summary.html @@ -76,11 +76,13 @@ {% for day in week %} {% if event_days.contains(day) && today.is_some() && today.unwrap() == day %} {{ day.day() }} {% elif event_days.contains(day) %} {{ day.day() }}