From c8850404b8c0ed151d0fb14a3367b67306b6ccaf Mon Sep 17 00:00:00 2001 From: Bill Thiede Date: Mon, 11 Aug 2025 14:53:12 -0700 Subject: [PATCH] server: rework dmarc parsing to use askama --- Cargo.lock | 52 ++++ server/Cargo.toml | 1 + server/build.rs | 1 + server/src/error.rs | 2 + server/src/nm.rs | 457 +++++++++++++++++------------ server/src/templates.rs | 7 + server/templates/dmarc_report.html | 89 ++++++ 7 files changed, 418 insertions(+), 191 deletions(-) create mode 100644 server/src/templates.rs create mode 100644 server/templates/dmarc_report.html diff --git a/Cargo.lock b/Cargo.lock index 79eb06b..63e663c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,48 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a" +[[package]] +name = "askama" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +dependencies = [ + "askama_derive", + "itoa 1.0.15", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 2.0.104", +] + +[[package]] +name = "askama_parser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow", +] + [[package]] name = "async-graphql" version = "7.0.17" @@ -524,6 +566,15 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bincode" version = "2.0.1" @@ -3152,6 +3203,7 @@ version = "0.17.27" dependencies = [ "ammonia", "anyhow", + "askama", "async-graphql", "async-graphql-axum", "async-trait", diff --git a/server/Cargo.toml b/server/Cargo.toml index 995297c..0c325a6 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -14,6 +14,7 @@ version.workspace = true [dependencies] ammonia = "4.1.0" anyhow = "1.0.98" +askama = { version = "0.14.0", features = ["derive"] } async-graphql = { version = "7", features = ["log"] } async-graphql-axum = "7.0.16" async-trait = "0.1.88" diff --git a/server/build.rs b/server/build.rs index a49ab18..5535cfe 100644 --- a/server/build.rs +++ b/server/build.rs @@ -2,4 +2,5 @@ fn main() { // Calling `build_info_build::build_script` collects all data and makes it available to `build_info::build_info!` // and `build_info::format!` in the main program. build_info_build::build_script(); + println!("cargo:rerun-if-changed=templates"); } diff --git a/server/src/error.rs b/server/src/error.rs index d8aa35b..e39c8ae 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -39,4 +39,6 @@ pub enum ServerError { QueryParseError(#[from] QueryParserError), #[error("impossible: {0}")] InfaillibleError(#[from] Infallible), + #[error("askama error: {0}")] + AskamaError(#[from] askama::Error), } diff --git a/server/src/nm.rs b/server/src/nm.rs index 9e6cdcd..21709b1 100644 --- a/server/src/nm.rs +++ b/server/src/nm.rs @@ -4,6 +4,8 @@ use std::{ io::Cursor, }; +use askama::Template; +use chrono::{TimeZone, Utc}; use letterbox_notmuch::Notmuch; use letterbox_shared::{compute_color, Rule}; use mailparse::{parse_content_type, parse_mail, MailHeader, MailHeaderMap, ParsedMail}; @@ -471,7 +473,9 @@ fn extract_zip(m: &ParsedMail) -> Result { 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 name.ends_with(".xml") && (name.contains("dmarc") || name.starts_with("google.com!")) { + if name.ends_with(".xml") + && (name.contains("dmarc") || name.starts_with("google.com!")) + { let mut xml = String::new(); use std::io::Read; if file.read_to_string(&mut xml).is_ok() { @@ -1175,194 +1179,265 @@ fn find_tags<'a, 'b>(rules: &'a [Rule], headers: &'b [MailHeader]) -> (bool, Has } // Add this helper function to parse the DMARC XML and summarize it. -fn parse_dmarc_report(xml: &str) -> Result { - #[derive(Debug, serde::Deserialize)] - struct Feedback { - report_metadata: Option, - policy_published: Option, - record: Option>, - } - #[derive(Debug, serde::Deserialize)] - struct ReportMetadata { - org_name: Option, - email: Option, - report_id: Option, - date_range: Option, - } - #[derive(Debug, serde::Deserialize)] - struct DateRange { - begin: Option, - end: Option, - } - #[derive(Debug, serde::Deserialize)] - struct PolicyPublished { - domain: Option, - adkim: Option, - aspf: Option, - p: Option, - sp: Option, - pct: Option, - } - #[derive(Debug, serde::Deserialize)] - struct Record { - row: Option, - identifiers: Option, - auth_results: Option, - } - #[derive(Debug, serde::Deserialize)] - struct Row { - source_ip: Option, - count: Option, - policy_evaluated: Option, - } - #[derive(Debug, serde::Deserialize)] - struct PolicyEvaluated { - disposition: Option, - dkim: Option, - spf: Option, - reason: Option>, - } - #[derive(Debug, serde::Deserialize)] - struct Reason { - #[serde(rename = "type")] - reason_type: Option, - comment: Option, - } - #[derive(Debug, serde::Deserialize)] - struct Identifiers { - header_from: Option, - } - #[derive(Debug, serde::Deserialize)] - struct AuthResults { - dkim: Option>, - spf: Option>, - } - #[derive(Debug, serde::Deserialize)] - struct AuthDKIM { - domain: Option, - result: Option, - selector: Option, - } - #[derive(Debug, serde::Deserialize)] - struct AuthSPF { - domain: Option, - result: Option, - scope: Option, - } - - let feedback: Feedback = xml_from_str(xml) - .map_err(|e| ServerError::StringError(format!("DMARC XML parse error: {e}")))?; - let mut summary = String::new(); - if let Some(meta) = feedback.report_metadata { - if let Some(org) = meta.org_name { - summary += &format!("Reporter: {}
", org); - } - if let Some(email) = meta.email { - summary += &format!("Contact: {}
", email); - } - if let Some(rid) = meta.report_id { - summary += &format!("Report ID: {}
", rid); - } - if let Some(dr) = meta.date_range { - if let (Some(begin), Some(end)) = (dr.begin, dr.end) { - use chrono::{NaiveDateTime, TimeZone, Utc}; - let begin_dt = Utc.timestamp_opt(begin as i64, 0).single(); - let end_dt = Utc.timestamp_opt(end as i64, 0).single(); - summary += &format!("Date range: {} to {}
", - begin_dt.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or(begin.to_string()), - end_dt.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or(end.to_string()) - ); - } - } - } - if let Some(pol) = feedback.policy_published { - summary += "Policy Published:
    "; - if let Some(domain) = pol.domain { - summary += &format!("
  • Domain: {}
  • ", domain); - } - if let Some(adkim) = pol.adkim { - summary += &format!("
  • ADKIM: {}
  • ", adkim); - } - if let Some(aspf) = pol.aspf { - summary += &format!("
  • ASPF: {}
  • ", aspf); - } - if let Some(p) = pol.p { - summary += &format!("
  • Policy: {}
  • ", p); - } - if let Some(sp) = pol.sp { - summary += &format!("
  • Subdomain Policy: {}
  • ", sp); - } - if let Some(pct) = pol.pct { - summary += &format!("
  • Percent: {}
  • ", pct); - } - summary += "
"; - } - if let Some(records) = feedback.record { - summary += "Records:"; - for rec in records { - let mut row_html = String::new(); - let mut source_ip = String::new(); - let mut count = String::new(); - let mut header_from = String::new(); - let mut disposition = String::new(); - let mut dkim = String::new(); - let mut spf = String::new(); - if let Some(r) = &rec.row { - if let Some(ref s) = r.source_ip { - source_ip = s.clone(); - } - if let Some(c) = r.count { - count = c.to_string(); - } - if let Some(ref pe) = r.policy_evaluated { - if let Some(ref disp) = pe.disposition { - disposition = disp.clone(); - } - if let Some(ref d) = pe.dkim { - dkim = d.clone(); - } - if let Some(ref s) = pe.spf { - spf = s.clone(); - } - } - } - if let Some(ids) = &rec.identifiers { - if let Some(ref hf) = ids.header_from { - header_from = hf.clone(); - } - } - row_html += &format!(""; - summary += &row_html; - } - summary += "
Source IPCountHeader FromDispositionDKIMSPFAuth Results
{}{}{}{}{}{}", - source_ip, count, header_from, disposition, dkim, spf); - // Auth Results - let mut auths = String::new(); - if let Some(auth) = &rec.auth_results { - if let Some(dkims) = &auth.dkim { - for dkimres in dkims { - auths += &format!("DKIM: domain={} selector={} result={}
", - dkimres.domain.as_deref().unwrap_or(""), - dkimres.selector.as_deref().unwrap_or(""), - dkimres.result.as_deref().unwrap_or("") - ); - } - } - if let Some(spfs) = &auth.spf { - for spfres in spfs { - auths += &format!("SPF: domain={} scope={} result={}
", - spfres.domain.as_deref().unwrap_or(""), - spfres.scope.as_deref().unwrap_or(""), - spfres.result.as_deref().unwrap_or("") - ); - } - } - } - row_html += &auths; - row_html += "
"; - } - if summary.is_empty() { - summary = "No DMARC summary found.".to_string(); - } - Ok(summary) +#[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 disposition: String, + pub dkim: String, + pub spf: String, + 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>, +} + +#[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)] +pub struct Reason { + #[serde(rename = "type")] + pub reason_type: Option, + pub comment: Option, +} +#[derive(Debug, serde::Deserialize)] +pub struct Identifiers { + pub header_from: 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, +} + +// 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").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").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()), + 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()), + 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 formatted_feedback = FormattedFeedback { + report_metadata: formatted_report_metadata, + policy_published: formatted_policy_published, + record: formatted_record, + }; + + let template = DmarcReportTemplate { + report: &formatted_feedback, + }; + let html = template.render()?; + Ok(html) } diff --git a/server/src/templates.rs b/server/src/templates.rs new file mode 100644 index 0000000..e09555b --- /dev/null +++ b/server/src/templates.rs @@ -0,0 +1,7 @@ +use askama::Template; + +#[derive(Template)] +#[template(path = "dmarc_report.html")] +pub struct DmarcReportTemplate<'a> { + pub feedback: &'a crate::nm::Feedback, +} diff --git a/server/templates/dmarc_report.html b/server/templates/dmarc_report.html new file mode 100644 index 0000000..b5505e0 --- /dev/null +++ b/server/templates/dmarc_report.html @@ -0,0 +1,89 @@ + + + + DMARC Report + + + {% if report.report_metadata.is_some() %} + {% let meta = report.report_metadata.as_ref().unwrap() %} + Reporter: {{ meta.org_name }}
+ Contact: {{ meta.email }}
+ Report ID: {{ meta.report_id }}
+ {% if meta.date_range.is_some() %} + {% let dr = meta.date_range.as_ref().unwrap() %} + Date range: + {{ dr.begin }} + to + {{ dr.end }} +
+ {% endif %} + {% endif %} + + {% if report.policy_published.is_some() %} + {% let pol = report.policy_published.as_ref().unwrap() %} + Policy Published: +
    +
  • Domain: {{ pol.domain }}
  • +
  • ADKIM: {{ pol.adkim }}
  • +
  • ASPF: {{ pol.aspf }}
  • +
  • Policy: {{ pol.p }}
  • +
  • Subdomain Policy: {{ pol.sp }}
  • +
  • Percent: {{ pol.pct }}
  • +
+ {% endif %} + + {% if report.record.is_some() %} + Records: + + + + + + + + + + + + + + {% for rec in report.record.as_ref().unwrap() %} + + + + + + + + + + {% endfor %} + +
Source IPCountHeader FromDispositionDKIMSPFAuth Results
{{ rec.source_ip }}{{ rec.count }}{{ rec.header_from }}{{ rec.disposition }}{{ rec.dkim }}{{ rec.spf }} + {% if rec.auth_results.is_some() %} + {% let auth = rec.auth_results.as_ref().unwrap() %} + {% for dkimres in auth.dkim %} + + DKIM: domain={{ dkimres.domain }} + selector={{ dkimres.selector }} + result={{ dkimres.result }} +
+ {% endfor %} + + {% for spfres in auth.spf %} + + SPF: domain={{ spfres.domain }} + scope={{ spfres.scope }} + result={{ spfres.result }} +
+ {% endfor %} + + {% endif %} +
+ {% endif %} + + {% if report.report_metadata.is_none() && report.policy_published.is_none() && report.record.is_none() %} +

No DMARC summary found.

+ {% endif %} + +