server: extract calendar info and render widget on email w/o ics
This commit is contained in:
@@ -1,3 +1,312 @@
|
||||
// --- TESTS ---
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn google_calendar_email_renders_ical_summary() {
|
||||
use mailparse::parse_mail;
|
||||
let raw_email = include_str!("../testdata/google-calendar-example.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
|
||||
assert_eq!(meta.summary, Some("Tamara and Scout in Alaska".to_string()));
|
||||
assert_eq!(meta.organizer, Some("tconvertino@gmail.com".to_string()));
|
||||
assert_eq!(meta.start_date, Some("20250624".to_string()));
|
||||
assert_eq!(meta.end_date, Some("20250701".to_string()));
|
||||
// Debug: print the rendered HTML for inspection
|
||||
if let Some(ref html) = meta.body_html {
|
||||
println!("Rendered HTML: {}", html);
|
||||
} else {
|
||||
println!("No body_html rendered");
|
||||
}
|
||||
// Assert ical summary is rendered and prepended (look for 'ical-flex' class)
|
||||
assert!(meta
|
||||
.body_html
|
||||
.as_ref()
|
||||
.map(|h| h.contains("ical-flex"))
|
||||
.unwrap_or(false));
|
||||
}
|
||||
}
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct ExtractedCalendarMetadata {
|
||||
pub is_google_calendar_event: bool,
|
||||
pub summary: Option<String>,
|
||||
pub organizer: Option<String>,
|
||||
pub start_date: Option<String>,
|
||||
pub end_date: Option<String>,
|
||||
pub body_html: Option<String>,
|
||||
}
|
||||
|
||||
/// 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;
|
||||
let mut start_date = None;
|
||||
let mut end_date = None;
|
||||
let mut body_html = None;
|
||||
|
||||
// Check sender
|
||||
if let Some(from) = m.headers.get_first_value("Sender") {
|
||||
if from.contains("calendar-notification@google.com") {
|
||||
is_google = true;
|
||||
}
|
||||
}
|
||||
// Check for Google Calendar subject
|
||||
if let Some(subject) = m.headers.get_first_value("Subject") {
|
||||
if subject.contains("New event:") || subject.contains("Google Calendar") {
|
||||
is_google = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract from text/calendar part if present
|
||||
fn find_ical<'a>(m: &'a ParsedMail) -> Option<String> {
|
||||
if m.ctype.mimetype == TEXT_CALENDAR {
|
||||
m.get_body().ok()
|
||||
} else {
|
||||
for sp in &m.subparts {
|
||||
if let Some(b) = find_ical(sp) {
|
||||
return Some(b);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
let ical_opt = find_ical(m);
|
||||
if let Some(ical) = ical_opt {
|
||||
// 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 {
|
||||
for prop in &event.properties {
|
||||
match prop.name.as_str() {
|
||||
"SUMMARY" => summary = prop.value.clone(),
|
||||
"ORGANIZER" => organizer = prop.value.clone(),
|
||||
"DTSTART" => {
|
||||
if let Some(dt) = &prop.value {
|
||||
if dt.len() >= 8 {
|
||||
start_date = Some(dt[0..8].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
"DTEND" => {
|
||||
if let Some(dt) = &prop.value {
|
||||
if dt.len() >= 8 {
|
||||
end_date = Some(dt[0..8].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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, e.g. "@ Tue Jun 24 - Mon Jun 30, 2025"
|
||||
if let Some(at_idx) = subject.find('@') {
|
||||
let after_at = &subject[at_idx + 1..];
|
||||
// Look for a date range like "Tue Jun 24 - Mon Jun 30, 2025"
|
||||
let date_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) = &date_re {
|
||||
if let Some(caps) = re.captures(after_at) {
|
||||
// e.g. Tue Jun 24 - Mon Jun 30, 2025
|
||||
let start_month = &caps[2];
|
||||
let start_day = &caps[3];
|
||||
let end_month = &caps[5];
|
||||
let end_day = &caps[6];
|
||||
let year = &caps[7];
|
||||
// Try to parse months as numbers
|
||||
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));
|
||||
// Increment end date by one day to match iCalendar exclusive end date
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 local_fmt_start = start_date
|
||||
.as_ref()
|
||||
.and_then(|d| NaiveDate::parse_from_str(d, "%Y%m%d").ok())
|
||||
.map(|d| d.format("%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| d.format("%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<String> = 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,
|
||||
};
|
||||
if let Ok(rendered) = template.render() {
|
||||
body_html = Some(rendered);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: try to extract from HTML body if present
|
||||
if body_html.is_none() {
|
||||
if let Body::Html(h) = body {
|
||||
body_html = Some(h.html.clone());
|
||||
}
|
||||
}
|
||||
ExtractedCalendarMetadata {
|
||||
is_google_calendar_event: is_google,
|
||||
summary,
|
||||
organizer,
|
||||
start_date,
|
||||
end_date,
|
||||
body_html,
|
||||
}
|
||||
}
|
||||
// Inline Askama filters module for template use
|
||||
mod filters {
|
||||
// Usage: {{ items|batch(7) }}
|
||||
@@ -356,6 +665,7 @@ pub fn extract_alternative(
|
||||
}
|
||||
}
|
||||
let mut ical_summary: Option<String> = None;
|
||||
// Try to find a text/calendar part as before
|
||||
for sp in &m.subparts {
|
||||
if sp.ctype.mimetype.as_str() == TEXT_CALENDAR {
|
||||
let body = sp.get_body()?;
|
||||
@@ -364,6 +674,15 @@ pub fn extract_alternative(
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If not found, try to detect Google Calendar event and render summary from metadata
|
||||
if ical_summary.is_none() {
|
||||
let meta = extract_calendar_metadata_from_mail(m, &Body::text(String::new()));
|
||||
if meta.is_google_calendar_event {
|
||||
if let Some(rendered) = meta.body_html {
|
||||
ical_summary = Some(rendered);
|
||||
}
|
||||
}
|
||||
}
|
||||
for sp in &m.subparts {
|
||||
if sp.ctype.mimetype.as_str() == TEXT_HTML {
|
||||
let body = sp.get_body()?;
|
||||
|
||||
Reference in New Issue
Block a user