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:| Source IP | Count | Header From | Disposition | DKIM | SPF | Auth Results |
";
- 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!("| {} | {} | {} | {} | {} | {} | ",
- 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 += " |
";
- summary += &row_html;
- }
- summary += "
";
- }
- 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:
+
+
+
+ | Source IP |
+ Count |
+ Header From |
+ Disposition |
+ DKIM |
+ SPF |
+ Auth Results |
+
+
+
+ {% for rec in report.record.as_ref().unwrap() %}
+
+ | {{ 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 %}
+ |
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% if report.report_metadata.is_none() && report.policy_published.is_none() && report.record.is_none() %}
+ No DMARC summary found.
+ {% endif %}
+
+