diff --git a/server/src/nm.rs b/server/src/nm.rs index 55bbf41..6c67a48 100644 --- a/server/src/nm.rs +++ b/server/src/nm.rs @@ -1,7 +1,7 @@ use std::{ collections::{HashMap, HashSet}, fs::File, - io::Cursor, + io::{Cursor, Read}, }; use askama::Template; @@ -33,6 +33,7 @@ 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_HTML: &'static str = "text/html"; const TEXT_PLAIN: &'static str = "text/plain"; @@ -453,6 +454,7 @@ fn extract_body(m: &ParsedMail, part_addr: &mut Vec) -> Result 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 => extract_zip(m), _ => extract_unhandled(m), }; @@ -528,6 +530,83 @@ fn extract_gzip(m: &ParsedMail) -> Result { extract_unhandled(m) } +fn extract_report(m: &ParsedMail, _part_addr: &mut Vec) -> Result { + let mut html_part = None; + let mut tlsrpt_part = None; + + for sp in &m.subparts { + match sp.ctype.mimetype.as_str() { + TEXT_HTML => html_part = Some(sp.get_body()?), + "application/tlsrpt+gzip" => tlsrpt_part = Some(sp.get_body_raw()?), + _ => {} // Ignore other parts for now + } + } + + let tlsrpt_summary_html = if let Some(gz_bytes) = tlsrpt_part { + let mut decoder = flate2::read::GzDecoder::new(&gz_bytes[..]); + let mut buffer = Vec::new(); + if decoder.read_to_end(&mut buffer).is_ok() { + if let Ok(json_str) = String::from_utf8(buffer) { + match serde_json::from_str::(&json_str) { + Ok(tlsrpt) => { + let formatted_tlsrpt = FormattedTlsRpt { + organization_name: tlsrpt.organization_name, + date_range: FormattedTlsRptDateRange { + start_datetime: tlsrpt.date_range.start_datetime, + end_datetime: tlsrpt.date_range.end_datetime, + }, + contact_info: tlsrpt.contact_info.unwrap_or_else(|| "".to_string()), + report_id: tlsrpt.report_id, + policies: tlsrpt.policies.into_iter().map(|policy| { + FormattedTlsRptPolicy { + policy: FormattedTlsRptPolicyDetails { + policy_type: policy.policy.policy_type, + policy_string: policy.policy.policy_string, + policy_domain: policy.policy.policy_domain, + mx_host: policy.policy.mx_host.unwrap_or_else(|| Vec::new()), + }, + summary: policy.summary, + failure_details: policy.failure_details.unwrap_or_else(|| Vec::new()).into_iter().map(|detail| { + FormattedTlsRptFailureDetails { + result_type: detail.result_type, + sending_mta_ip: detail.sending_mta_ip.unwrap_or_else(|| "".to_string()), + receiving_ip: detail.receiving_ip.unwrap_or_else(|| "".to_string()), + receiving_mx_hostname: detail.receiving_mx_hostname.unwrap_or_else(|| "".to_string()), + failed_session_count: detail.failed_session_count, + additional_info: detail.additional_info.unwrap_or_else(|| "".to_string()), + failure_reason_code: detail.failure_reason_code.unwrap_or_else(|| "".to_string()), + } + }).collect(), + } + }).collect(), + }; + let template = TlsReportTemplate { report: &formatted_tlsrpt }; + template.render().unwrap_or_else(|e| format!("
Failed to render TLS report template: {}
", e)) + } + Err(e) => format!( + "
Failed to parse TLS report JSON: {}
", + e + ), + } + } else { + format!("
Failed to convert decompressed data to UTF-8.
") + } + } else { + format!("
Failed to decompressed data.
") + } + } else { + "".to_string() + }; + + let final_html = if let Some(html) = html_part { + format!("{}
{} ", html, tlsrpt_summary_html) + } else { + tlsrpt_summary_html + }; + + Ok(Body::html(final_html)) +} + fn extract_unhandled(m: &ParsedMail) -> Result { let msg = format!( "Unhandled body content type:\n{}\n{}", @@ -1287,6 +1366,123 @@ pub struct Reason { pub reason_type: Option, pub comment: Option, } + +#[derive(Debug, serde::Deserialize)] +pub struct TlsRpt { + #[serde(rename = "organization-name")] + pub organization_name: String, + #[serde(rename = "date-range")] + pub date_range: TlsRptDateRange, + #[serde(rename = "contact-info")] + pub contact_info: Option, + #[serde(rename = "report-id")] + pub report_id: String, + pub policies: Vec, +} + +#[derive(Debug, serde::Deserialize)] +pub struct TlsRptDateRange { + #[serde(rename = "start-datetime")] + pub start_datetime: String, + #[serde(rename = "end-datetime")] + pub end_datetime: String, +} + +#[derive(Debug, serde::Deserialize)] +pub struct TlsRptPolicy { + pub policy: TlsRptPolicyDetails, + pub summary: TlsRptSummary, + #[serde(rename = "failure-details")] + pub failure_details: Option>, +} + +#[derive(Debug, serde::Deserialize)] +pub struct TlsRptPolicyDetails { + #[serde(rename = "policy-type")] + pub policy_type: String, + #[serde(rename = "policy-string")] + pub policy_string: Vec, + #[serde(rename = "policy-domain")] + pub policy_domain: String, + #[serde(rename = "mx-host")] + pub mx_host: Option>, +} + +#[derive(Debug, serde::Deserialize)] +pub struct TlsRptSummary { + #[serde(rename = "total-successful-session-count")] + pub total_successful_session_count: u64, + #[serde(rename = "total-failure-session-count")] + pub total_failure_session_count: u64, +} + +#[derive(Debug, serde::Deserialize)] +pub struct TlsRptMxHost { + pub hostname: String, + #[serde(rename = "failure-count")] + pub failure_count: u64, + #[serde(rename = "result-type")] + pub result_type: String, +} + +#[derive(Debug, serde::Deserialize)] +pub struct TlsRptFailureDetails { + #[serde(rename = "result-type")] + pub result_type: String, + #[serde(rename = "sending-mta-ip")] + pub sending_mta_ip: Option, + #[serde(rename = "receiving-ip")] + pub receiving_ip: Option, + #[serde(rename = "receiving-mx-hostname")] + pub receiving_mx_hostname: Option, + #[serde(rename = "failed-session-count")] + pub failed_session_count: u64, + #[serde(rename = "additional-info")] + pub additional_info: Option, + #[serde(rename = "failure-reason-code")] + pub failure_reason_code: Option, +} + +#[derive(Debug)] +pub struct FormattedTlsRpt { + pub organization_name: String, + pub date_range: FormattedTlsRptDateRange, + pub contact_info: String, + pub report_id: String, + pub policies: Vec, +} + +#[derive(Debug)] +pub struct FormattedTlsRptDateRange { + pub start_datetime: String, + pub end_datetime: String, +} + +#[derive(Debug)] +pub struct FormattedTlsRptPolicy { + pub policy: FormattedTlsRptPolicyDetails, + pub summary: TlsRptSummary, + pub failure_details: Vec, +} + +#[derive(Debug)] +pub struct FormattedTlsRptPolicyDetails { + pub policy_type: String, + pub policy_string: Vec, + pub policy_domain: String, + pub mx_host: Vec, +} + +#[derive(Debug)] +pub struct 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, @@ -1315,6 +1511,12 @@ pub struct DmarcReportTemplate<'a> { pub report: &'a FormattedFeedback, } +#[derive(Template)] +#[template(path = "tls_report.html")] +pub struct TlsReportTemplate<'a> { + pub report: &'a FormattedTlsRpt, +} + // 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) @@ -1379,25 +1581,62 @@ pub fn parse_dmarc_report(xml: &str) -> Result { }); 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()), - 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, - } + 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()), + 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() }); diff --git a/server/templates/tls_report.html b/server/templates/tls_report.html new file mode 100644 index 0000000..4548c4e --- /dev/null +++ b/server/templates/tls_report.html @@ -0,0 +1,43 @@ + + + + TLS Report + + +

TLS Report Summary:

+

Organization: {{ report.organization_name }}

+

Date Range: {{ report.date_range.start_datetime }} to {{ report.date_range.end_datetime }}

+

Contact: {{ report.contact_info }}

+

Report ID: {{ report.report_id }}

+ +

Policies:

+ {% for policy in report.policies %} +
Policy Domain: {{ policy.policy.policy_domain }}
+
    +
  • Policy Type: {{ policy.policy.policy_type }}
  • +
  • Policy String: {{ policy.policy.policy_string | join(", ") }}
  • +
  • Successful Sessions: {{ policy.summary.total_successful_session_count }}
  • +
  • Failed Sessions: {{ policy.summary.total_failure_session_count }}
  • +
+ +
    + {% for mx_host in policy.policy.mx_host %} +
  • Hostname: {{ mx_host.hostname }}, Failures: {{ mx_host.failure_count }}, Result: {{ mx_host.result_type }}
  • + {% endfor %} +
+ +
    + {% for detail in policy.failure_details %} +
  • Result: {{ detail.result_type }}, Sending IP: {{ detail.sending_mta_ip }}, Failed Sessions: {{ detail.failed_session_count }} + {% if detail.failure_reason_code != "" %} + (Reason: {{ detail.failure_reason_code }}) + {% endif %} +
  • + (Receiving IP: {{ detail.receiving_ip }}) + (Receiving MX: {{ detail.receiving_mx_hostname }}) + (Additional Info: {{ detail.additional_info }}) + {% endfor %} +
+ {% endfor %} + + \ No newline at end of file