letterbox/server/src/email_extract.rs

2484 lines
101 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 APPLICATION_TLSRPT_GZIP: &'static str = "application/tlsrpt+gzip";
const IMAGE_JPEG: &'static str = "image/jpeg";
const IMAGE_PJPEG: &'static str = "image/pjpeg";
const IMAGE_PNG: &'static str = "image/png";
const MESSAGE_DELIVERY_STATUS: &'static str = "message/delivery-status";
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<T: Clone>(
items: &[T],
_: &dyn ::askama::Values,
size: usize,
) -> askama::Result<Vec<Vec<T>>> {
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<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 {
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
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: <summary> @ ...")
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("<div class='ical-flex'></div>"));
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("<div class='ical-flex'></div>")),
);
}
// 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: <summary> @ ...")
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<Vec<Email>, 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<String>) -> Result<Body, ServerError> {
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<Body, ServerError> {
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!(
"<div class=\"dmarc-report\">DMARC report summary:<br>{}</div>",
report
)));
}
Err(e) => {
return Ok(Body::html(format!(
"<div class=\"dmarc-report-error\">Failed to parse DMARC report XML: {}</div>",
e
)));
}
}
}
}
}
}
}
}
// If no DMARC report found, fall through to unhandled
extract_unhandled(m)
}
pub fn extract_gzip(m: &ParsedMail) -> Result<(Body, Option<String>), 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!(
"<div class=\"dmarc-report\">DMARC report summary:<br>{}</div>",
report
)),
Some(xml),
));
}
Err(e) => {
return Ok((Body::html(format!(
"<div class=\"dmarc-report-error\">Failed to parse DMARC report XML: {}</div>",
e
)), None));
}
}
}
}
}
Ok((extract_unhandled(m)?, None))
}
pub fn extract_report(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, ServerError> {
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() {
APPLICATION_TLSRPT_GZIP => {
let gz_bytes = sp.get_body_raw()?;
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::<TlsRpt>(&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,
};
let html = template.render().unwrap_or_else(|e| format!("<div class=\"tlsrpt-error\">Failed to render TLS report template: {}</div>", e));
parts.push(Body::html(html));
}
Err(e) => {
let html = format!(
"<div class=\"tlsrpt-error\">Failed to parse TLS report JSON: {}</div>",
e
);
parts.push(Body::html(html));
}
}
} else {
let html = format!("<div class=\"tlsrpt-error\">Failed to convert decompressed data to UTF-8.</div>");
parts.push(Body::html(html));
}
} else {
let html =
format!("<div class=\"tlsrpt-error\">Failed to decompress data.</div>");
parts.push(Body::html(html));
}
}
MESSAGE_RFC822 => {
parts.push(extract_rfc822(&sp, part_addr)?);
}
TEXT_HTML => {
let body = sp.get_body()?;
parts.push(Body::html(body));
}
MESSAGE_DELIVERY_STATUS => {
let body = extract_delivery_status(sp)?;
parts.push(body);
}
TEXT_PLAIN => {
let body = sp.get_body()?;
parts.push(Body::text(body));
}
_ => {
// For any other content type, try to extract the body using the general extract_body function
match extract_body(sp, part_addr) {
Ok(body) => parts.push(body),
Err(_) => {
// If extraction fails, create an unhandled content type body
let msg = format!(
"Unhandled report subpart content type: {}\n{}",
sp.ctype.mimetype,
sp.get_body()
.unwrap_or_else(|_| "Failed to get body".to_string())
);
parts.push(Body::UnhandledContentType(UnhandledContentType {
text: msg,
content_tree: render_content_type_tree(sp),
}));
}
}
}
}
part_addr.pop();
}
if parts.is_empty() {
return Ok(Body::html(
"<div class=\"report-error\">No report content found</div>".to_string(),
));
}
// Add <hr> tags between subparts for better visual separation
let html = parts
.iter()
.map(|p| match p {
Body::PlainText(PlainText { text, .. }) => {
format!(
r#"<p class="view-part-text-plain font-mono whitespace-pre-line">{}</p>"#,
linkify_html(&html_escape::encode_text(text).trim_matches('\n'))
)
}
Body::Html(Html { html, .. }) => html.clone(),
Body::UnhandledContentType(UnhandledContentType { text, .. }) => {
format!(
r#"<p class="view-part-unhandled">{}</p>"#,
linkify_html(&html_escape::encode_text(text).trim_matches('\n'))
)
}
})
.collect::<Vec<_>>()
.join("<hr>\n");
Ok(Body::html(html))
}
pub fn extract_delivery_status(m: &ParsedMail) -> Result<Body, ServerError> {
Ok(Body::text(m.get_body()?))
}
pub fn extract_unhandled(m: &ParsedMail) -> Result<Body, ServerError> {
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<String>,
) -> Result<Body, ServerError> {
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<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()?;
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!("{}<hr>{}", 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<String>) -> Result<Body, ServerError> {
//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#"<img src="/api/view/attachment/{}/{}/{}">"#,
part_addr[0],
part_addr
.iter()
.skip(1)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.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::<serde_json::Value>(&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<pre>{}</pre>",
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#"
<div class="p-4 error">
Unhandled mimetype {} in a {} message
</div>
"#,
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#"<p class="view-part-text-plain font-mono whitespace-pre-line">{}</p>"#,
// 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#"<p class="view-part-unhandled">{}</p>"#,
// 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::<Vec<_>>()
.join("\n");
info!("flatten_body_parts {}", parts.len());
Body::html(html)
}
pub fn extract_related(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, ServerError> {
// 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<T, F: Fn(&ParsedMail, &[usize]) -> Option<T> + Copy>(
m: &ParsedMail,
visitor: F,
) -> Option<T> {
let mut cur_addr = Vec::new();
walk_attachments_inner(m, visitor, &mut cur_addr)
}
pub fn walk_attachments_inner<T, F: Fn(&ParsedMail, &[usize]) -> Option<T> + Copy>(
m: &ParsedMail,
visitor: F,
cur_addr: &mut Vec<usize>,
) -> Option<T> {
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<Vec<Attachment>, 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<Attachment> {
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::<Vec<_>>()
.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::<Vec<String>>()
.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<String> {
emails
.iter()
.map(|e| e.to_string())
.inspect(|e| info!("e {}", e))
.collect()
}
pub fn extract_rfc822(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, ServerError> {
fn extract_headers(m: &ParsedMail) -> Result<Body, ServerError> {
let path = "<in-memory>";
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<String> {
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<String> {
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<FormattedDateRange>,
}
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<String>,
pub auth_results: Option<FormattedAuthResults>,
}
pub struct FormattedAuthResults {
pub dkim: Vec<FormattedAuthDKIM>,
pub spf: Vec<FormattedAuthSPF>,
}
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<FormattedReportMetadata>,
pub policy_published: Option<FormattedPolicyPublished>,
pub record: Option<Vec<FormattedRecord>>,
pub has_envelope_to: bool,
}
#[derive(Debug, serde::Deserialize)]
pub struct Feedback {
pub report_metadata: Option<ReportMetadata>,
pub policy_published: Option<PolicyPublished>,
pub record: Option<Vec<Record>>,
}
#[derive(Debug, serde::Deserialize)]
pub struct ReportMetadata {
pub org_name: Option<String>,
pub email: Option<String>,
pub report_id: Option<String>,
pub date_range: Option<DateRange>,
}
#[derive(Debug, serde::Deserialize)]
pub struct DateRange {
pub begin: Option<u64>,
pub end: Option<u64>,
}
#[derive(Debug, serde::Deserialize)]
pub struct PolicyPublished {
pub domain: Option<String>,
pub adkim: Option<String>,
pub aspf: Option<String>,
pub p: Option<String>,
pub sp: Option<String>,
pub pct: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct Record {
pub row: Option<Row>,
pub identifiers: Option<Identifiers>,
pub auth_results: Option<AuthResults>,
}
#[derive(Debug, serde::Deserialize)]
pub struct Row {
pub source_ip: Option<String>,
pub count: Option<u64>,
pub policy_evaluated: Option<PolicyEvaluated>,
}
#[derive(Debug, serde::Deserialize)]
pub struct PolicyEvaluated {
pub disposition: Option<String>,
pub dkim: Option<String>,
pub spf: Option<String>,
pub reason: Option<Vec<Reason>>,
}
#[derive(Debug, serde::Deserialize, Clone)]
pub struct Reason {
#[serde(rename = "type")]
pub reason_type: Option<String>,
pub comment: Option<String>,
}
#[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<String>,
#[serde(rename = "report-id")]
pub report_id: String,
pub policies: Vec<TlsRptPolicy>,
}
#[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<Vec<TlsRptFailureDetails>>,
}
#[derive(Debug, serde::Deserialize)]
pub struct TlsRptPolicyDetails {
#[serde(rename = "policy-type")]
pub policy_type: String,
#[serde(rename = "policy-string")]
pub policy_string: Vec<String>,
#[serde(rename = "policy-domain")]
pub policy_domain: String,
#[serde(rename = "mx-host")]
pub mx_host: Option<Vec<MxHost>>,
}
#[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<String>,
#[serde(rename = "receiving-ip")]
pub receiving_ip: Option<String>,
#[serde(rename = "receiving-mx-hostname")]
pub receiving_mx_hostname: Option<String>,
#[serde(rename = "failed-session-count")]
pub failed_session_count: u64,
#[serde(rename = "additional-info")]
pub additional_info: Option<String>,
#[serde(rename = "failure-reason-code")]
pub failure_reason_code: Option<String>,
}
#[derive(Debug)]
pub struct FormattedTlsRpt {
pub organization_name: String,
pub date_range: FormattedTlsRptDateRange,
pub contact_info: String,
pub report_id: String,
pub policies: Vec<FormattedTlsRptPolicy>,
}
#[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<FormattedTlsRptFailureDetails>,
}
#[derive(Debug)]
pub struct FormattedTlsRptPolicyDetails {
pub policy_type: String,
pub policy_string: Vec<String>,
pub policy_domain: String,
pub mx_host: Vec<FormattedTlsRptMxHost>,
}
#[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<String>,
pub envelope_to: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct AuthResults {
pub dkim: Option<Vec<AuthDKIM>>,
pub spf: Option<Vec<AuthSPF>>,
}
#[derive(Debug, serde::Deserialize)]
pub struct AuthDKIM {
pub domain: Option<String>,
pub result: Option<String>,
pub selector: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct AuthSPF {
pub domain: Option<String>,
pub result: Option<String>,
pub scope: Option<String>,
}
#[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<chrono::NaiveDate>,
pub event_days: Vec<chrono::NaiveDate>,
pub caption: String,
pub description_paragraphs: &'a [String],
pub today: Option<chrono::NaiveDate>,
pub recurrence_display: String,
}
// Add this helper function to parse the DMARC XML and summarize it.
pub fn parse_dmarc_report(xml: &str) -> Result<String, ServerError> {
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<FormattedRecord>| {
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<String, ServerError> {
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<String, ServerError> {
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<String> = None;
let mut rrule: Option<String> = 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::<chrono::Utc>::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<Vec<String>> = None;
let mut until: Option<NaiveDate> = None;
let mut count: Option<usize> = 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<String> = 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::<Vec<_>>()
} 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("<hr>"))
}
fn parse_ical_datetime_tz(dt: &str, tz: Tz) -> Option<chrono::DateTime<Tz>> {
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);
let html = meta.body_html.expect("body_html");
// Print event date info for debugging
for part in parsed.subparts.iter() {
if part.ctype.mimetype == TEXT_CALENDAR {
if let Ok(ical) = part.get_body() {
println!("ICAL data: {}", ical);
if let Some(start) = ical.lines().find(|l| l.starts_with("DTSTART:")) {
println!("Start date: {}", start);
}
}
}
}
println!("Rendered HTML: {}", html);
// Look for September 11 (Thursday) being highlighted
// The calendar should show Sept 11 highlighted with background:#ffd700 and the correct data-event-day
assert!(html.contains(r#"data-event-day="2025-09-11""#));
assert!(html.contains(r#"background:#ffd700"#));
// Since 1:00 AM UTC on Friday 9/12 is 6:00 PM PDT on Thursday 9/11, verify times are correct
assert!(html.contains("6:00 PM Thu Sep 11, 2025"));
}
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("<b>Summary:</b> McClure BLT"),
"HTML should contain the labeled summary/title"
);
assert!(
html.contains("<b>Organizer:</b> calendar-notification@google.com"),
"HTML should contain the labeled organizer"
);
assert!(
html.contains("<b>Start:</b> 20250911"),
"HTML should contain the labeled start time"
);
assert!(
html.contains("<b>End:</b> 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("<b>Repeats:</b> Repeats")
|| html.contains("recurr")
|| html.contains("RRULE"))
{
println!("FAIL: html did not contain recurrence info:\n{}", html);
}
assert!(
html.contains("<b>Repeats:</b> 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("<td").count();
assert!(highlighted > 0, "Should highlight at least one day");
assert!(
highlighted < total_days / 2,
"Should not highlight all days"
);
}
}