use std::io::{Cursor, Read}; use askama::Template; use chrono::{Datelike, LocalResult, TimeZone, Utc}; use chrono_tz::Tz; use mailparse::{parse_content_type, parse_mail, MailHeader, MailHeaderMap, ParsedMail}; use quick_xml::de::from_str as xml_from_str; use tracing::{error, info, warn}; use zip::ZipArchive; use crate::{ error::ServerError, graphql::{Attachment, Body, DispositionType, Email, Html, PlainText, UnhandledContentType}, linkify_html, }; const APPLICATION_GZIP: &'static str = "application/gzip"; const APPLICATION_ZIP: &'static str = "application/zip"; const IMAGE_JPEG: &'static str = "image/jpeg"; const IMAGE_PJPEG: &'static str = "image/pjpeg"; const IMAGE_PNG: &'static str = "image/png"; const MESSAGE_RFC822: &'static str = "message/rfc822"; 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"; // Inline Askama filters module for template use mod filters { // Usage: {{ items|batch(7) }} pub fn batch( items: &[T], _: &dyn ::askama::Values, size: usize, ) -> askama::Result>> { if size == 0 { return Ok(vec![]); } let mut out = Vec::new(); let mut chunk = Vec::with_capacity(size); for item in items { chunk.push(item.clone()); if chunk.len() == size { out.push(chunk); chunk = Vec::with_capacity(size); } } if !chunk.is_empty() { out.push(chunk); } Ok(out) } } #[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 { 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 { 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 use ical::IcalParser; let mut parser = IcalParser::new(ical.as_bytes()); if let Some(Ok(calendar)) = parser.next() { 'event_loop: for event in calendar.events { for prop in &event.properties { match prop.name.as_str() { "SUMMARY" => { if summary.is_none() { summary = prop.value.clone(); break 'event_loop; } } "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); } } // 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()); } } } // 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: New event: Dentist appt @ Tue Sep 23, 2025 3pm - 4pm (PDT) (tconvertino@gmail.com) if let Some(caps) = regex::Regex::new(r"New event: [^@]+@ ([A-Za-z]{3}) ([A-Za-z]{3}) (\d{1,2}), (\d{4}) (\d{1,2})(?::(\d{2}))? ?([ap]m) ?- ?(\d{1,2})(?::(\d{2}))? ?([ap]m)").ok().and_then(|re| re.captures(&subject)) { let month = &caps[2]; let day = &caps[3]; let year = &caps[4]; let date_str = format!("{} {} {}", month, day, year); if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%b %d %Y") { let ymd = date.format("%Y%m%d").to_string(); start_date = Some(ymd.clone()); end_date = Some(ymd); } } else { // 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 // 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 needs_ical_flex = summary.is_some() || start_date.is_some() || end_date.is_some() || has_recurrence; if needs_ical_flex { 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(); let recurrence_display = if has_recurrence { "Repeats".to_string() } else { String::new() }; let template = IcalSummaryTemplate { summary: &summary_val, local_fmt_start: &start_val, local_fmt_end: &end_val, organizer: &organizer_val, organizer_cn: "", all_days: vec![], event_days: vec![], caption: String::new(), description_paragraphs: &[], today: Some(chrono::Local::now().date_naive()), recurrence_display, }; let fallback_html = template .render() .unwrap_or_else(|_| String::from("
")); match &mut body_html { Some(existing) => { if !existing.starts_with(&fallback_html) { *existing = format!("{}{}", fallback_html, existing); } } None => { body_html = Some(fallback_html); } } } // Final guarantee: if body_html is still None, set to minimal ical-flex HTML with empty fields using the template if body_html.is_none() { let template = IcalSummaryTemplate { summary: "", local_fmt_start: "", local_fmt_end: "", organizer: "", organizer_cn: "", all_days: vec![], event_days: vec![], caption: String::new(), description_paragraphs: &[], today: Some(chrono::Local::now().date_naive()), recurrence_display: String::new(), }; body_html = Some( template .render() .unwrap_or_else(|_| String::from("
")), ); } // 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 // recurrence detection and rendering is now handled by the template logic } // 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, organizer, start_date, end_date, body_html, } } pub fn email_addresses( _path: &str, m: &ParsedMail, header_name: &str, ) -> Result, ServerError> { let mut addrs = Vec::new(); for header_value in m.headers.get_all_values(header_name) { match mailparse::addrparse(&header_value) { Ok(mal) => { for ma in mal.into_inner() { match ma { mailparse::MailAddr::Group(gi) => { if !gi.group_name.contains("ndisclosed") {} } mailparse::MailAddr::Single(s) => addrs.push(Email { name: s.display_name, addr: Some(s.addr), photo_url: None, }), //println!("Single: {s}"), } } } Err(_) => { let v = header_value; if v.matches('@').count() == 1 { if v.matches('<').count() == 1 && v.ends_with('>') { let idx = v.find('<').unwrap(); let addr = &v[idx + 1..v.len() - 1].trim(); let name = &v[..idx].trim(); addrs.push(Email { name: Some(name.to_string()), addr: Some(addr.to_string()), photo_url: None, }); } } else { addrs.push(Email { name: Some(v), addr: None, photo_url: None, }); } } } } Ok(addrs) } pub fn extract_body(m: &ParsedMail, part_addr: &mut Vec) -> Result { let body = m.get_body()?; let ret = match m.ctype.mimetype.as_str() { TEXT_PLAIN => return Ok(Body::text(body)), TEXT_HTML => return Ok(Body::html(body)), MULTIPART_MIXED => extract_mixed(m, part_addr), MULTIPART_ALTERNATIVE => extract_alternative(m, part_addr), MULTIPART_RELATED => extract_related(m, part_addr), MULTIPART_REPORT => extract_report(m, part_addr), // APPLICATION_ZIP and APPLICATION_GZIP are handled in the thread function APPLICATION_ZIP => extract_unhandled(m), APPLICATION_GZIP => extract_unhandled(m), mt if mt.starts_with("application/") => Ok(Body::text("".to_string())), _ => extract_unhandled(m), }; if let Err(err) = ret { error!("Failed to extract body: {:?}", err); return Ok(extract_unhandled(m)?); } ret } pub fn extract_zip(m: &ParsedMail) -> Result { if let Ok(zip_bytes) = m.get_body_raw() { if let Ok(mut archive) = ZipArchive::new(Cursor::new(&zip_bytes)) { for i in 0..archive.len() { if let Ok(mut file) = archive.by_index(i) { let name = file.name().to_lowercase(); // Google DMARC reports are typically named like "google.com!example.com!...xml" // and may or may not contain "dmarc" in the filename. if is_dmarc_report_filename(&name) { let mut xml = String::new(); use std::io::Read; if file.read_to_string(&mut xml).is_ok() { match parse_dmarc_report(&xml) { Ok(report) => { return Ok(Body::html(format!( "
DMARC report summary:
{}
", report ))); } Err(e) => { return Ok(Body::html(format!( "
Failed to parse DMARC report XML: {}
", e ))); } } } } } } } } // If no DMARC report found, fall through to unhandled extract_unhandled(m) } pub fn extract_gzip(m: &ParsedMail) -> Result<(Body, Option), ServerError> { let pcd = m.get_content_disposition(); let filename = pcd.params.get("filename").map(|s| s.to_lowercase()); let is_dmarc_xml_file = filename.map_or(false, |name| is_dmarc_report_filename(&name)); if is_dmarc_xml_file { if let Ok(gz_bytes) = m.get_body_raw() { let mut decoder = flate2::read::GzDecoder::new(&gz_bytes[..]); let mut xml = String::new(); use std::io::Read; if decoder.read_to_string(&mut xml).is_ok() { match parse_dmarc_report(&xml) { Ok(report) => { return Ok(( Body::html(format!( "
DMARC report summary:
{}
", report )), Some(xml), )); } Err(e) => { return Ok((Body::html(format!( "
Failed to parse DMARC report XML: {}
", e )), None)); } } } } } Ok((extract_unhandled(m)?, None)) } pub fn extract_report(m: &ParsedMail, _part_addr: &mut Vec) -> Result { let mut html_part = None; let mut tlsrpt_part = None; for sp in &m.subparts { match sp.ctype.mimetype.as_str() { TEXT_HTML => html_part = Some(sp.get_body()?), "application/tlsrpt+gzip" => tlsrpt_part = Some(sp.get_body_raw()?), _ => {} // Ignore other parts for now } } let tlsrpt_summary_html = if let Some(gz_bytes) = tlsrpt_part { let mut decoder = flate2::read::GzDecoder::new(&gz_bytes[..]); let mut buffer = Vec::new(); if decoder.read_to_end(&mut buffer).is_ok() { if let Ok(json_str) = String::from_utf8(buffer) { match serde_json::from_str::(&json_str) { Ok(tlsrpt) => { let formatted_tlsrpt = FormattedTlsRpt { organization_name: tlsrpt.organization_name, date_range: FormattedTlsRptDateRange { start_datetime: tlsrpt.date_range.start_datetime, end_datetime: tlsrpt.date_range.end_datetime, }, contact_info: tlsrpt.contact_info.unwrap_or_else(|| "".to_string()), report_id: tlsrpt.report_id, policies: tlsrpt .policies .into_iter() .map(|policy| FormattedTlsRptPolicy { policy: FormattedTlsRptPolicyDetails { policy_type: policy.policy.policy_type, policy_string: policy.policy.policy_string, policy_domain: policy.policy.policy_domain, mx_host: policy .policy .mx_host .unwrap_or_else(|| Vec::new()) .into_iter() .map(|mx| match mx { MxHost::String(s) => FormattedTlsRptMxHost { hostname: s, failure_count: 0, result_type: "".to_string(), }, MxHost::Object(o) => FormattedTlsRptMxHost { hostname: o.hostname, failure_count: o.failure_count, result_type: o.result_type, }, }) .collect(), }, summary: policy.summary, failure_details: policy .failure_details .unwrap_or_else(|| Vec::new()) .into_iter() .map(|detail| FormattedTlsRptFailureDetails { result_type: detail.result_type, sending_mta_ip: detail .sending_mta_ip .unwrap_or_else(|| "".to_string()), receiving_ip: detail .receiving_ip .unwrap_or_else(|| "".to_string()), receiving_mx_hostname: detail .receiving_mx_hostname .unwrap_or_else(|| "".to_string()), failed_session_count: detail.failed_session_count, additional_info: detail .additional_info .unwrap_or_else(|| "".to_string()), failure_reason_code: detail .failure_reason_code .unwrap_or_else(|| "".to_string()), }) .collect(), }) .collect(), }; let template = TlsReportTemplate { report: &formatted_tlsrpt, }; template.render().unwrap_or_else(|e| format!("
Failed to render TLS report template: {}
", e)) } Err(e) => format!( "
Failed to parse TLS report JSON: {}
", e ), } } else { format!("
Failed to convert decompressed data to UTF-8.
") } } else { format!("
Failed to decompressed data.
") } } else { "".to_string() }; let final_html = if let Some(html) = html_part { format!("{}
{} ", html, tlsrpt_summary_html) } else { tlsrpt_summary_html }; Ok(Body::html(final_html)) } pub fn extract_unhandled(m: &ParsedMail) -> Result { let msg = format!( "Unhandled body content type:\n{}\n{}", render_content_type_tree(m), m.get_body()?, ); Ok(Body::UnhandledContentType(UnhandledContentType { text: msg, content_tree: render_content_type_tree(m), })) } pub fn is_dmarc_report_filename(name: &str) -> bool { let is = (name.ends_with(".xml.gz") || name.ends_with(".xml")) && name.contains('!'); error!("info_span {}: {}", name, is); is } // multipart/alternative defines multiple representations of the same message, and clients should // show the fanciest they can display. For this program, the priority is text/html, text/plain, // then give up. pub fn extract_alternative( m: &ParsedMail, part_addr: &mut Vec, ) -> Result { let handled_types = vec![ MULTIPART_ALTERNATIVE, MULTIPART_MIXED, MULTIPART_RELATED, TEXT_CALENDAR, TEXT_HTML, TEXT_PLAIN, ]; for sp in &m.subparts { if sp.ctype.mimetype.as_str() == MULTIPART_ALTERNATIVE { return extract_alternative(sp, part_addr); } } for sp in &m.subparts { if sp.ctype.mimetype.as_str() == MULTIPART_MIXED { return extract_mixed(sp, part_addr); } } for sp in &m.subparts { if sp.ctype.mimetype.as_str() == MULTIPART_RELATED { return extract_related(sp, part_addr); } } let mut ical_summary: Option = 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()?; let summary = render_ical_summary(&body)?; ical_summary = Some(summary); 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()?; if let Some(ref summary) = ical_summary { // Prepend summary to HTML body let combined = format!("{}
{}", summary, body); return Ok(Body::html(combined)); } else { return Ok(Body::html(body)); } } } for sp in &m.subparts { if sp.ctype.mimetype.as_str() == TEXT_PLAIN { let body = sp.get_body()?; if let Some(ref summary) = ical_summary { // Prepend summary to plain text body (strip HTML tags) let summary_text = html2text::from_read(summary.as_bytes(), 80)?; let combined = format!("{}\n\n{}", summary_text.trim(), body); return Ok(Body::text(combined)); } else { return Ok(Body::text(body)); } } } if let Some(summary) = ical_summary { return Ok(Body::html(summary)); } Err(ServerError::StringError(format!( "extract_alternative failed to find suitable subpart, searched: {:?}", handled_types ))) } // multipart/mixed defines multiple types of context all of which should be presented to the user // 'serially'. pub fn extract_mixed(m: &ParsedMail, part_addr: &mut Vec) -> Result { //todo!("add some sort of visual indicator there are unhandled types, i.e. .ics files"); let handled_types = vec![ IMAGE_JPEG, IMAGE_PJPEG, IMAGE_PNG, MESSAGE_RFC822, MULTIPART_ALTERNATIVE, MULTIPART_RELATED, TEXT_HTML, TEXT_PLAIN, APPLICATION_GZIP, ]; let mut unhandled_types: Vec<_> = m .subparts .iter() .map(|sp| sp.ctype.mimetype.as_str()) .filter(|mt| !handled_types.contains(mt) && !mt.starts_with("application/")) .collect(); unhandled_types.sort(); if !unhandled_types.is_empty() { warn!( "{} contains the following unhandled mimetypes {:?}", MULTIPART_MIXED, unhandled_types ); } let mut parts = Vec::new(); for (idx, sp) in m.subparts.iter().enumerate() { part_addr.push(idx.to_string()); match sp.ctype.mimetype.as_str() { MESSAGE_RFC822 => parts.push(extract_rfc822(&sp, part_addr)?), MULTIPART_RELATED => parts.push(extract_related(sp, part_addr)?), MULTIPART_ALTERNATIVE => parts.push(extract_alternative(sp, part_addr)?), TEXT_PLAIN => parts.push(Body::text(sp.get_body()?)), TEXT_HTML => parts.push(Body::html(sp.get_body()?)), IMAGE_PJPEG | IMAGE_JPEG | IMAGE_PNG => { let pcd = sp.get_content_disposition(); let filename = pcd .params .get("filename") .map(|s| s.clone()) .unwrap_or("".to_string()); // Only add inline images, attachments are handled as an attribute of the top level Message and rendered separate client-side. if pcd.disposition == mailparse::DispositionType::Inline { // TODO: make URL generation more programatic based on what the frontend has // mapped parts.push(Body::html(format!( r#""#, part_addr[0], part_addr .iter() .skip(1) .map(|i| i.to_string()) .collect::>() .join("."), filename ))); } } APPLICATION_GZIP => { let (html_body, raw_xml) = extract_gzip(sp)?; parts.push(html_body); if let Some(xml) = raw_xml { let pretty_printed_content = if sp.ctype.mimetype.as_str() == MULTIPART_REPORT { // This case is for TLS reports, not DMARC. // For DMARC, it's always XML. // Pretty print JSON (if it were TLS) if let Ok(parsed_json) = serde_json::from_str::(&xml) { serde_json::to_string_pretty(&parsed_json).unwrap_or(xml.to_string()) } else { xml.to_string() } } else { // DMARC reports are XML // Pretty print XML match pretty_print_xml_with_trimming(&xml) { Ok(pretty_xml) => pretty_xml, Err(e) => { error!("Failed to pretty print XML: {:?}", e); xml.to_string() } } }; parts.push(Body::html(format!( "\n
{}
", html_escape::encode_text(&pretty_printed_content) ))); } } mt => { if !mt.starts_with("application/") { parts.push(unhandled_html(MULTIPART_MIXED, mt)) } } } part_addr.pop(); } Ok(flatten_body_parts(&parts)) } pub fn unhandled_html(parent_type: &str, child_type: &str) -> Body { Body::Html(Html { html: format!( r#"
Unhandled mimetype {} in a {} message
"#, child_type, parent_type ), content_tree: String::new(), }) } pub fn flatten_body_parts(parts: &[Body]) -> Body { let html = parts .iter() .map(|p| match p { Body::PlainText(PlainText { text, .. }) => { format!( r#"

{}

"#, // Trim newlines to prevent excessive white space at the beginning/end of // presenation. Leave tabs and spaces incase plain text attempts to center a // header on the first line. linkify_html(&html_escape::encode_text(text).trim_matches('\n')) ) } Body::Html(Html { html, .. }) => html.clone(), Body::UnhandledContentType(UnhandledContentType { text, .. }) => { error!("text len {}", text.len()); format!( r#"

{}

"#, // Trim newlines to prevent excessive white space at the beginning/end of // presenation. Leave tabs and spaces incase plain text attempts to center a // header on the first line. linkify_html(&html_escape::encode_text(text).trim_matches('\n')) ) } }) .collect::>() .join("\n"); info!("flatten_body_parts {}", parts.len()); Body::html(html) } pub fn extract_related(m: &ParsedMail, part_addr: &mut Vec) -> Result { // TODO(wathiede): collect related things and change return type to new Body arm. let handled_types = vec![ MULTIPART_ALTERNATIVE, TEXT_HTML, TEXT_PLAIN, IMAGE_JPEG, IMAGE_PJPEG, IMAGE_PNG, ]; let mut unhandled_types: Vec<_> = m .subparts .iter() .map(|sp| sp.ctype.mimetype.as_str()) .filter(|mt| !handled_types.contains(mt) && !mt.starts_with("application/")) .collect(); unhandled_types.sort(); if !unhandled_types.is_empty() { warn!( "{} contains the following unhandled mimetypes {:?}", MULTIPART_RELATED, unhandled_types ); } for (i, sp) in m.subparts.iter().enumerate() { if sp.ctype.mimetype == IMAGE_PNG || sp.ctype.mimetype == IMAGE_JPEG || sp.ctype.mimetype == IMAGE_PJPEG { info!("sp.ctype {:#?}", sp.ctype); //info!("sp.headers {:#?}", sp.headers); if let Some(cid) = sp.headers.get_first_value("Content-Id") { let mut part_id = part_addr.clone(); part_id.push(i.to_string()); info!("cid: {} part_id {:?}", cid, part_id); } } } for sp in &m.subparts { if sp.ctype.mimetype == MULTIPART_ALTERNATIVE { return extract_alternative(m, part_addr); } } for sp in &m.subparts { if sp.ctype.mimetype == TEXT_HTML { let body = sp.get_body()?; return Ok(Body::html(body)); } } for sp in &m.subparts { if sp.ctype.mimetype == TEXT_PLAIN { let body = sp.get_body()?; return Ok(Body::text(body)); } } Err(ServerError::StringError(format!( "extract_related failed to find suitable subpart, searched: {:?}", handled_types ))) } pub fn walk_attachments Option + Copy>( m: &ParsedMail, visitor: F, ) -> Option { let mut cur_addr = Vec::new(); walk_attachments_inner(m, visitor, &mut cur_addr) } pub fn walk_attachments_inner Option + Copy>( m: &ParsedMail, visitor: F, cur_addr: &mut Vec, ) -> Option { for (idx, sp) in m.subparts.iter().enumerate() { cur_addr.push(idx); let val = visitor(sp, &cur_addr); if val.is_some() { return val; } let val = walk_attachments_inner(sp, visitor, cur_addr); if val.is_some() { return val; } cur_addr.pop(); } None } // TODO(wathiede): make this walk_attachments that takes a closure. // Then implement one closure for building `Attachment` and imlement another that can be used to // get the bytes for serving attachments of HTTP pub fn extract_attachments(m: &ParsedMail, id: &str) -> Result, ServerError> { let mut attachments = Vec::new(); if m.ctype.mimetype.starts_with("application/") { if let Some(attachment) = extract_attachment(m, id, &[]) { attachments.push(attachment); } } for (idx, sp) in m.subparts.iter().enumerate() { if let Some(attachment) = extract_attachment(sp, id, &[idx]) { // Filter out inline attachements, they're flattened into the body of the message. if attachment.disposition == DispositionType::Attachment { attachments.push(attachment); } } } Ok(attachments) } pub fn extract_attachment(m: &ParsedMail, id: &str, idx: &[usize]) -> Option { let pcd = m.get_content_disposition(); let pct = m .get_headers() .get_first_value("Content-Type") .map(|s| parse_content_type(&s)); let filename = match ( pcd.params.get("filename").map(|f| f.clone()), pct.map(|pct| pct.params.get("name").map(|f| f.clone())), ) { // Use filename from Content-Disposition (Some(filename), _) => Some(filename), // Use filename from Content-Type (_, Some(Some(name))) => Some(name), // No known filename _ => None, }; let filename = if let Some(fname) = filename { fname } else { if m.ctype.mimetype.starts_with("application/") { // Generate a default filename format!( "attachment-{}", idx.iter() .map(|i| i.to_string()) .collect::>() .join(".") ) } else { return None; } }; info!("filename {}", filename); // TODO: grab this from somewhere let content_id = None; let bytes = match m.get_body_raw() { Ok(bytes) => bytes, Err(err) => { error!("failed to get body for attachment: {}", err); return None; } }; return Some(Attachment { id: id.to_string(), idx: idx .iter() .map(|i| i.to_string()) .collect::>() .join("."), disposition: pcd.disposition.into(), filename: Some(filename), size: bytes.len(), // TODO: what is the default for ctype? // TODO: do we want to use m.ctype.params for anything? content_type: Some(m.ctype.mimetype.clone()), content_id, bytes, }); } pub fn email_address_strings(emails: &[Email]) -> Vec { emails .iter() .map(|e| e.to_string()) .inspect(|e| info!("e {}", e)) .collect() } pub fn extract_rfc822(m: &ParsedMail, part_addr: &mut Vec) -> Result { fn extract_headers(m: &ParsedMail) -> Result { let path = ""; let from = email_address_strings(&email_addresses(path, &m, "from")?).join(", "); let to = email_address_strings(&email_addresses(path, &m, "to")?).join(", "); let cc = email_address_strings(&email_addresses(path, &m, "cc")?).join(", "); let date = m.headers.get_first_value("date").unwrap_or(String::new()); let subject = m .headers .get_first_value("subject") .unwrap_or(String::new()); let text = format!( r#" ---------- Forwarded message ---------- From: {} To: {} CC: {} Date: {} Subject: {} "#, from, to, cc, date, subject ); Ok(Body::text(text)) } let inner_body = m.get_body()?; let inner_m = parse_mail(inner_body.as_bytes())?; let headers = extract_headers(&inner_m)?; let body = extract_body(&inner_m, part_addr)?; Ok(flatten_body_parts(&[headers, body])) } pub fn get_attachment_filename(header_value: &str) -> &str { info!("get_attachment_filename {}", header_value); // Strip last " let v = &header_value[..header_value.len() - 1]; if let Some(idx) = v.rfind('"') { &v[idx + 1..] } else { "" } } pub fn get_content_type<'a>(headers: &[MailHeader<'a>]) -> Option { if let Some(v) = headers.get_first_value("Content-Type") { if let Some(idx) = v.find(';') { return Some(v[..idx].to_string()); } else { return Some(v); } } None } pub fn get_content_id<'a>(headers: &[MailHeader<'a>]) -> Option { headers.get_first_value("Content-Id") } pub fn render_content_type_tree(m: &ParsedMail) -> String { const WIDTH: usize = 4; const SKIP_HEADERS: [&str; 4] = [ "Authentication-Results", "DKIM-Signature", "Received", "Received-SPF", ]; fn render_ct_rec(m: &ParsedMail, depth: usize) -> String { let mut parts = Vec::new(); let msg = format!("{} {}", "-".repeat(depth * WIDTH), m.ctype.mimetype); parts.push(msg); for sp in &m.subparts { parts.push(render_ct_rec(sp, depth + 1)) } parts.join("\n") } fn render_rec(m: &ParsedMail, depth: usize) -> String { let mut parts = Vec::new(); let msg = format!("{} {}", "-".repeat(depth * WIDTH), m.ctype.mimetype); parts.push(msg); let indent = " ".repeat(depth * WIDTH); if !m.ctype.charset.is_empty() { parts.push(format!("{} Character Set: {}", indent, m.ctype.charset)); } for (k, v) in m.ctype.params.iter() { parts.push(format!("{} {}: {}", indent, k, v)); } if !m.headers.is_empty() { parts.push(format!("{} == headers ==", indent)); for h in &m.headers { if h.get_key().starts_with('X') { continue; } if SKIP_HEADERS.contains(&h.get_key().as_str()) { continue; } parts.push(format!("{} {}: {}", indent, h.get_key_ref(), h.get_value())); } } for sp in &m.subparts { parts.push(render_rec(sp, depth + 1)) } parts.join("\n") } format!( "Outline:\n{}\n\nDetailed:\n{}\n\nNot showing headers:\n {}\n X.*", render_ct_rec(m, 1), render_rec(m, 1), SKIP_HEADERS.join("\n ") ) } // Add this helper function to parse the DMARC XML and summarize it. #[derive(Debug, serde::Deserialize)] pub struct FormattedDateRange { pub begin: String, pub end: String, } pub struct FormattedReportMetadata { pub org_name: String, pub email: String, pub report_id: String, pub date_range: Option, } pub struct FormattedRecord { pub source_ip: String, pub count: String, pub header_from: String, pub envelope_to: String, pub disposition: String, pub dkim: String, pub spf: String, pub reason: Vec, pub auth_results: Option, } pub struct FormattedAuthResults { pub dkim: Vec, pub spf: Vec, } pub struct FormattedAuthDKIM { pub domain: String, pub result: String, pub selector: String, } pub struct FormattedAuthSPF { pub domain: String, pub result: String, pub scope: String, } pub struct FormattedPolicyPublished { pub domain: String, pub adkim: String, pub aspf: String, pub p: String, pub sp: String, pub pct: String, } pub struct FormattedFeedback { pub report_metadata: Option, pub policy_published: Option, pub record: Option>, pub has_envelope_to: bool, } #[derive(Debug, serde::Deserialize)] pub struct Feedback { pub report_metadata: Option, pub policy_published: Option, pub record: Option>, } #[derive(Debug, serde::Deserialize)] pub struct ReportMetadata { pub org_name: Option, pub email: Option, pub report_id: Option, pub date_range: Option, } #[derive(Debug, serde::Deserialize)] pub struct DateRange { pub begin: Option, pub end: Option, } #[derive(Debug, serde::Deserialize)] pub struct PolicyPublished { pub domain: Option, pub adkim: Option, pub aspf: Option, pub p: Option, pub sp: Option, pub pct: Option, } #[derive(Debug, serde::Deserialize)] pub struct Record { pub row: Option, pub identifiers: Option, pub auth_results: Option, } #[derive(Debug, serde::Deserialize)] pub struct Row { pub source_ip: Option, pub count: Option, pub policy_evaluated: Option, } #[derive(Debug, serde::Deserialize)] pub struct PolicyEvaluated { pub disposition: Option, pub dkim: Option, pub spf: Option, pub reason: Option>, } #[derive(Debug, serde::Deserialize, Clone)] pub struct Reason { #[serde(rename = "type")] pub reason_type: Option, pub comment: Option, } #[derive(Debug, serde::Deserialize)] pub struct TlsRpt { #[serde(rename = "organization-name")] pub organization_name: String, #[serde(rename = "date-range")] pub date_range: TlsRptDateRange, #[serde(rename = "contact-info")] pub contact_info: Option, #[serde(rename = "report-id")] pub report_id: String, pub policies: Vec, } #[derive(Debug, serde::Deserialize)] pub struct TlsRptDateRange { #[serde(rename = "start-datetime")] pub start_datetime: String, #[serde(rename = "end-datetime")] pub end_datetime: String, } #[derive(Debug, serde::Deserialize)] pub struct TlsRptPolicy { pub policy: TlsRptPolicyDetails, pub summary: TlsRptSummary, #[serde(rename = "failure-details")] pub failure_details: Option>, } #[derive(Debug, serde::Deserialize)] pub struct TlsRptPolicyDetails { #[serde(rename = "policy-type")] pub policy_type: String, #[serde(rename = "policy-string")] pub policy_string: Vec, #[serde(rename = "policy-domain")] pub policy_domain: String, #[serde(rename = "mx-host")] pub mx_host: Option>, } #[derive(Debug, serde::Deserialize)] pub struct TlsRptSummary { #[serde(rename = "total-successful-session-count")] pub total_successful_session_count: u64, #[serde(rename = "total-failure-session-count")] pub total_failure_session_count: u64, } #[derive(Debug, serde::Deserialize)] #[serde(untagged)] pub enum MxHost { String(String), Object(TlsRptMxHost), } #[derive(Debug, serde::Deserialize)] pub struct TlsRptMxHost { pub hostname: String, #[serde(rename = "failure-count")] pub failure_count: u64, #[serde(rename = "result-type")] pub result_type: String, } #[derive(Debug, serde::Deserialize)] pub struct TlsRptFailureDetails { #[serde(rename = "result-type")] pub result_type: String, #[serde(rename = "sending-mta-ip")] pub sending_mta_ip: Option, #[serde(rename = "receiving-ip")] pub receiving_ip: Option, #[serde(rename = "receiving-mx-hostname")] pub receiving_mx_hostname: Option, #[serde(rename = "failed-session-count")] pub failed_session_count: u64, #[serde(rename = "additional-info")] pub additional_info: Option, #[serde(rename = "failure-reason-code")] pub failure_reason_code: Option, } #[derive(Debug)] pub struct FormattedTlsRpt { pub organization_name: String, pub date_range: FormattedTlsRptDateRange, pub contact_info: String, pub report_id: String, pub policies: Vec, } #[derive(Debug)] pub struct FormattedTlsRptDateRange { pub start_datetime: String, pub end_datetime: String, } #[derive(Debug)] pub struct FormattedTlsRptPolicy { pub policy: FormattedTlsRptPolicyDetails, pub summary: TlsRptSummary, pub failure_details: Vec, } #[derive(Debug)] pub struct FormattedTlsRptPolicyDetails { pub policy_type: String, pub policy_string: Vec, pub policy_domain: String, pub mx_host: Vec, } #[derive(Debug)] pub struct FormattedTlsRptMxHost { pub hostname: String, pub failure_count: u64, pub result_type: String, } #[derive(Debug)] pub struct FormattedTlsRptFailureDetails { pub result_type: String, pub sending_mta_ip: String, pub receiving_ip: String, pub receiving_mx_hostname: String, pub failed_session_count: u64, pub additional_info: String, pub failure_reason_code: String, } #[derive(Debug, serde::Deserialize)] pub struct Identifiers { pub header_from: Option, pub envelope_to: Option, } #[derive(Debug, serde::Deserialize)] pub struct AuthResults { pub dkim: Option>, pub spf: Option>, } #[derive(Debug, serde::Deserialize)] pub struct AuthDKIM { pub domain: Option, pub result: Option, pub selector: Option, } #[derive(Debug, serde::Deserialize)] pub struct AuthSPF { pub domain: Option, pub result: Option, pub scope: Option, } #[derive(Template)] #[template(path = "dmarc_report.html")] pub struct DmarcReportTemplate<'a> { pub report: &'a FormattedFeedback, } #[derive(Template)] #[template(path = "tls_report.html")] 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 all_days: Vec, pub event_days: Vec, pub caption: String, pub description_paragraphs: &'a [String], pub today: Option, pub recurrence_display: 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) .map_err(|e| ServerError::StringError(format!("DMARC XML parse error: {}", e)))?; let formatted_report_metadata = feedback.report_metadata.map(|meta| { let date_range = meta.date_range.map(|dr| FormattedDateRange { begin: match Utc.timestamp_opt(dr.begin.unwrap_or(0) as i64, 0) { chrono::LocalResult::Single(d) => Some(d), _ => None, } .map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_else(|| "".to_string()), end: match Utc.timestamp_opt(dr.end.unwrap_or(0) as i64, 0) { chrono::LocalResult::Single(d) => Some(d), _ => None, } .map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_else(|| "".to_string()), }); FormattedReportMetadata { org_name: meta.org_name.unwrap_or_else(|| "".to_string()), email: meta.email.unwrap_or_else(|| "".to_string()), report_id: meta.report_id.unwrap_or_else(|| "".to_string()), date_range, } }); let formatted_record = feedback.record.map(|records| { records .into_iter() .map(|rec| { let auth_results = rec.auth_results.map(|auth| { let dkim = auth .dkim .map(|dkims| { dkims .into_iter() .map(|d| FormattedAuthDKIM { domain: d.domain.unwrap_or_else(|| "".to_string()), result: d.result.unwrap_or_else(|| "".to_string()), selector: d.selector.unwrap_or_else(|| "".to_string()), }) .collect() }) .unwrap_or_else(|| Vec::new()); let spf = auth .spf .map(|spfs| { spfs.into_iter() .map(|s| FormattedAuthSPF { domain: s.domain.unwrap_or_else(|| "".to_string()), result: s.result.unwrap_or_else(|| "".to_string()), scope: s.scope.unwrap_or_else(|| "".to_string()), }) .collect() }) .unwrap_or_else(|| Vec::new()); FormattedAuthResults { dkim, spf } }); FormattedRecord { source_ip: rec .row .as_ref() .and_then(|r| r.source_ip.clone()) .unwrap_or_else(|| "".to_string()), count: rec .row .as_ref() .and_then(|r| r.count.map(|c| c.to_string())) .unwrap_or_else(|| "".to_string()), header_from: rec .identifiers .as_ref() .and_then(|i| i.header_from.clone()) .unwrap_or_else(|| "".to_string()), envelope_to: rec .identifiers .as_ref() .and_then(|i| i.envelope_to.clone()) .unwrap_or_else(|| "".to_string()), disposition: rec .row .as_ref() .and_then(|r| r.policy_evaluated.as_ref()) .and_then(|p| p.disposition.clone()) .unwrap_or_else(|| "".to_string()), dkim: rec .row .as_ref() .and_then(|r| r.policy_evaluated.as_ref()) .and_then(|p| p.dkim.clone()) .unwrap_or_else(|| "".to_string()), spf: rec .row .as_ref() .and_then(|r| r.policy_evaluated.as_ref()) .and_then(|p| p.spf.clone()) .unwrap_or_else(|| "".to_string()), reason: rec .row .as_ref() .and_then(|r| r.policy_evaluated.as_ref()) .and_then(|p| p.reason.clone()) .unwrap_or_else(|| Vec::new()) .into_iter() .map(|r| { let mut s = String::new(); if let Some(reason_type) = r.reason_type { s.push_str(&format!("Type: {}", reason_type)); } if let Some(comment) = r.comment { if !s.is_empty() { s.push_str(", "); } s.push_str(&format!("Comment: {}", comment)); } s }) .collect(), auth_results, } }) .collect() }); let formatted_policy_published = feedback .policy_published .map(|pol| FormattedPolicyPublished { domain: pol.domain.unwrap_or_else(|| "".to_string()), adkim: pol.adkim.unwrap_or_else(|| "".to_string()), aspf: pol.aspf.unwrap_or_else(|| "".to_string()), p: pol.p.unwrap_or_else(|| "".to_string()), sp: pol.sp.unwrap_or_else(|| "".to_string()), pct: pol.pct.unwrap_or_else(|| "".to_string()), }); let has_envelope_to = formatted_record .as_ref() .map_or(false, |r: &Vec| { r.iter().any(|rec| !rec.envelope_to.is_empty()) }); let formatted_feedback = FormattedFeedback { report_metadata: formatted_report_metadata, policy_published: formatted_policy_published, record: formatted_record, has_envelope_to, }; let template = DmarcReportTemplate { report: &formatted_feedback, }; let html = template.render()?; Ok(html) } pub fn pretty_print_xml_with_trimming(xml_input: &str) -> Result { use std::io::Cursor; use quick_xml::{ events::{BytesText, Event}, reader::Reader, writer::Writer, }; let mut reader = Reader::from_str(xml_input); reader.config_mut().trim_text(true); let mut writer = Writer::new_with_indent(Cursor::new(Vec::new()), b' ', 4); let mut buf = Vec::new(); loop { match reader.read_event_into(&mut buf) { Ok(Event::Eof) => break, Ok(Event::Text(e)) => { let trimmed_text = e.decode()?.trim().to_string(); writer.write_event(Event::Text(BytesText::new(&trimmed_text)))?; } Ok(event) => { writer.write_event(event)?; } Err(e) => { return Err(ServerError::StringError(format!( "XML parsing error: {}", e ))) } } buf.clear(); } let result = writer.into_inner().into_inner(); Ok(String::from_utf8(result)?) } use ical::IcalParser; pub fn render_ical_summary(ical_data: &str) -> Result { let mut summary_parts = Vec::new(); let mut parser = IcalParser::new(ical_data.as_bytes()); use std::collections::HashSet; use chrono::{Datelike, NaiveDate}; while let Some(Ok(calendar)) = parser.next() { for event in calendar.events { let mut summary = None; let mut description = None; let mut dtstart = None; let mut dtend = None; let mut organizer = None; let mut organizer_cn = None; let mut tzid: Option = None; let mut rrule: Option = None; for prop in &event.properties { match prop.name.as_str() { "SUMMARY" => summary = prop.value.as_deref(), "DESCRIPTION" => description = prop.value.as_deref(), "DTSTART" => { dtstart = prop.value.as_deref(); if let Some(params) = &prop.params { if let Some((_, values)) = params.iter().find(|(k, _)| k == "TZID") { if let Some(val) = values.get(0) { tzid = Some(val.clone()); } } } } "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()); } } } } "RRULE" => rrule = prop.value.clone(), _ => {} } } // Always use America/Los_Angeles for Google Calendar events if no TZID is present let event_tz: Tz = tzid .as_deref() .unwrap_or("America/Los_Angeles") .parse() .unwrap_or(chrono_tz::America::Los_Angeles); // Parse start/end as chrono DateTime let (local_fmt_start, local_fmt_end, event_days, recurrence_display) = if let Some(dtstart) = dtstart { let fallback = chrono::DateTime::::from_timestamp(0, 0) .map(|dt| dt.with_timezone(&event_tz)) .unwrap_or_else(|| { event_tz .with_ymd_and_hms(1970, 1, 1, 0, 0, 0) .single() .unwrap_or_else(|| event_tz.timestamp_opt(0, 0).single().unwrap()) }); let start = parse_ical_datetime_tz(dtstart, event_tz).unwrap_or(fallback); let end = dtend .and_then(|d| parse_ical_datetime_tz(d, event_tz)) .unwrap_or(start); // Use the event's TZ for all calendar grid/highlighting logic let allday = dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false)); let fmt_start = if allday { start.format("%a %b %e, %Y").to_string() } else { start.format("%-I:%M %p %a %b %e, %Y").to_string() }; let fmt_end = if allday { end.format("%a %b %e, %Y").to_string() } else { end.format("%-I:%M %p %a %b %e, %Y").to_string() }; // All calendar grid and event_days logic below uses start/end in event's TZ // Recurrence support: parse RRULE and generate event_days accordingly let mut days = vec![]; let mut recurrence_display = String::new(); if let Some(rrule_str) = rrule { // Very basic RRULE parser for FREQ, INTERVAL, BYDAY, UNTIL, COUNT let mut freq = "DAILY"; let mut interval = 1; let mut byday: Option> = None; let mut until: Option = None; let mut count: Option = None; for part in rrule_str.split(';') { let mut kv = part.splitn(2, '='); let k = kv.next().unwrap_or(""); let v = kv.next().unwrap_or(""); match k { "FREQ" => freq = v, "INTERVAL" => interval = v.parse().unwrap_or(1), "BYDAY" => { byday = Some(v.split(',').map(|s| s.to_string()).collect()) } "UNTIL" => { if v.len() >= 8 { until = chrono::NaiveDate::parse_from_str(&v[0..8], "%Y%m%d") .ok(); } } "COUNT" => count = v.parse().ok(), _ => {} } } // Human-readable recurrence string recurrence_display = match freq { "DAILY" => format!( "Every {} day{}", interval, if interval > 1 { "s" } else { "" } ), "WEEKLY" => { let days = byday.as_ref().map(|v| v.join(", ")).unwrap_or_default(); if !days.is_empty() { format!( "Every {} week{} on {}", interval, if interval > 1 { "s" } else { "" }, days ) } else { format!( "Every {} week{}", interval, if interval > 1 { "s" } else { "" } ) } } "MONTHLY" => format!( "Every {} month{}", interval, if interval > 1 { "s" } else { "" } ), "YEARLY" => format!( "Every {} year{}", interval, if interval > 1 { "s" } else { "" } ), _ => format!("Repeats: {}", freq), }; // Generate event days for the recurrence let cur = start.date_naive(); let max_span = 366; // safety: don't generate more than a year let mut weekday_set = HashSet::new(); if let Some(ref byday_vec) = byday { for wd in byday_vec { let wd = wd.trim(); let chrono_wd = match wd { "MO" => Some(chrono::Weekday::Mon), "TU" => Some(chrono::Weekday::Tue), "WE" => Some(chrono::Weekday::Wed), "TH" => Some(chrono::Weekday::Thu), "FR" => Some(chrono::Weekday::Fri), "SA" => Some(chrono::Weekday::Sat), "SU" => Some(chrono::Weekday::Sun), _ => None, }; if let Some(wd) = chrono_wd { weekday_set.insert(wd); } } } if freq == "WEEKLY" { // For weekly, only add days that match BYDAY and are in the correct interval week let mut cur_date = cur; let mut added = 0; let until_date = until.unwrap_or(cur_date + chrono::Duration::days(max_span as i64)); // Find the first week start (the week containing the DTSTART) let week_start = cur_date - chrono::Duration::days( cur_date.weekday().num_days_from_monday() as i64 ); while cur_date <= until_date && added < count.unwrap_or(max_span) { let weeks_since_start = ((cur_date - week_start).num_days() / 7) as usize; if weeks_since_start % interval == 0 && weekday_set.contains(&cur_date.weekday()) { days.push(cur_date); added += 1; } cur_date = cur_date.succ_opt().unwrap(); } } else { let mut cur_date = cur; let mut added = 0; while added < count.unwrap_or(max_span) { if let Some(until_date) = until { if cur_date > until_date { break; } } match freq { "DAILY" => { days.push(cur_date); cur_date = cur_date.succ_opt().unwrap(); } "MONTHLY" => { days.push(cur_date); cur_date = cur_date.with_day(1).unwrap().succ_opt().unwrap(); while cur_date.day() != 1 { cur_date = cur_date.succ_opt().unwrap(); } } "YEARLY" => { days.push(cur_date); cur_date = cur_date .with_year(cur_date.year() + interval as i32) .unwrap(); } _ => { days.push(cur_date); cur_date = cur_date.succ_opt().unwrap(); } } added += 1; } } (fmt_start, fmt_end, days, recurrence_display) } else { // No RRULE: just add all days between start and end let d = start.date_naive(); let mut end_d = end.date_naive(); 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(); } } 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, recurrence_display) } } else { (String::new(), String::new(), vec![], String::new()) }; // 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()) }; // 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 description_paragraphs_val = &description_paragraphs; // Compute calendar grid for template rendering 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, all_days, event_days: event_days.clone(), caption, description_paragraphs: description_paragraphs_val, today: Some(chrono::Local::now().date_naive()), recurrence_display, }; summary_parts.push(template.render()?); } } Ok(summary_parts.join("
")) } 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 } } #[cfg(test)] mod tests { #[test] fn google_calendar_email_thursday_highlights_thursday() { use mailparse::parse_mail; let raw_email = include_str!("../../server/testdata/google-calendar-example-thursday.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); // Debug: print the rendered HTML for inspection let html = meta.body_html.expect("body_html"); println!("Rendered HTML: {}", html); // Check that the calendar table highlights Thursday, not Friday // Look for a table header row with days of week (allow whitespace) let thursday_idx = html .find(">\n Thu<") .or_else(|| html.find(">Thu<")) .expect("Should have a Thursday column"); let friday_idx = html .find(">\n Fri<") .or_else(|| html.find(">Fri<")) .expect("Should have a Friday column"); // Find the first highlighted cell (background:#ffd700) let highlight_idx = html .find("background:#ffd700") .expect("Should highlight a day"); // The highlight should be closer to Thursday than Friday assert!( highlight_idx > thursday_idx && highlight_idx < friday_idx, "Thursday should be highlighted, not Friday" ); } use super::*; #[test] fn google_calendar_email_3_single_event_metadata() { use mailparse::parse_mail; let raw_email = include_str!("../../server/testdata/google-calendar-example-3.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("Dentist appt".to_string())); // Organizer: from From header, extract email address assert_eq!(meta.organizer, Some("tconvertino@gmail.com".to_string())); // Dates: should extract Sep 23, 2025, 3pm-4pm assert_eq!(meta.start_date, Some("20250923".to_string())); assert_eq!(meta.end_date, Some("20250923".to_string())); // Should not be recurring if let Some(ref html) = meta.body_html { assert!( html.contains("Dentist appt"), "HTML should contain the summary" ); assert!( html.contains("20250923"), "HTML should contain the event date" ); assert!( !html.contains("Repeats"), "HTML should not mention recurrence" ); } else { panic!("No body_html rendered"); } } #[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 with labels assert!( html.contains("Summary: McClure BLT"), "HTML should contain the labeled summary/title" ); assert!( html.contains("Organizer: calendar-notification@google.com"), "HTML should contain the labeled organizer" ); assert!( html.contains("Start: 20250911"), "HTML should contain the labeled start time" ); assert!( html.contains("End: 20260131"), "HTML should contain the labeled end time" ); 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: Repeats") || html.contains("recurr") || html.contains("RRULE")) { println!("FAIL: html did not contain recurrence info:\n{}", html); } assert!( html.contains("Repeats: Repeats") || html.contains("recurr") || html.contains("RRULE"), "Recurrence info should be present in HTML" ); } #[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)); } #[test] fn google_calendar_email_2_renders_ical_summary() { 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())); // 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)); } #[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; // This .ics should contain an RRULE for recurrence let ical = include_str!("../../server/testdata/ical-straddle.ics"); let html = render_ical_summary(&ical).expect("render ical summary"); // Should mention recurrence in the display assert!(html.contains("Repeats") || html.contains("recurr") || html.contains("RRULE")); // Should only highlight the correct days (not all days between start and end) // For a weekly event, check that only the correct weekday cells are highlighted // (e.g., if event is every Monday, only Mondays are highlighted) // This is a weak test: just check that not all days in the range are highlighted let highlighted = html.matches("background:#ffd700").count(); let total_days = html.matches(" 0, "Should highlight at least one day"); assert!( highlighted < total_days / 2, "Should not highlight all days" ); } }