Skip to main content

mailrs_dmarc/
lib.rs

1#![deny(missing_docs)]
2#![deny(rustdoc::broken_intra_doc_links)]
3
4//! DMARC (RFC 7489) — policy parsing, identifier alignment, message
5//! evaluation, and aggregate-report generation.
6//!
7//! ## 1.1 — full mail-auth replacement
8//!
9//! As of 1.1 this crate **fully replaces** the DMARC half of
10//! `stalwart/mail-auth`:
11//!
12//! - [`policy::DmarcPolicy::parse`] — TXT record parser (RFC 7489 §6.3).
13//! - [`align::check`] — identifier alignment, strict + relaxed (RFC 7489 §3.1).
14//! - [`eval::evaluate`] — pure-function DMARC outcome from SPF + DKIM
15//!   verdicts + policy (RFC 7489 §6.6).
16//!
17//! The original 1.0 surface (aggregate reporting, store trait, XML
18//! builder, mailto extraction) is unchanged:
19//!
20//! - **Result recording** — [`DmarcStore`] trait + a Postgres reference
21//!   impl ([`PgDmarcStore`]) behind the default `pg-store` feature.
22//! - **Aggregate XML report generation** —
23//!   [`generate_dmarc_report_xml`] produces the canonical
24//!   `<feedback>` document a `rua` mailbox expects.
25//! - **Report-mail formatting** — [`format_report_email`] wraps the
26//!   gzipped XML into the multipart/mixed envelope per §7.2.1.
27//! - **`rua` extraction** — [`extract_rua_from_dmarc_record`] pulls a
28//!   `mailto:` URI out of a `_dmarc.<domain>` TXT record.
29//!
30//! The crate is store-agnostic by default: implement [`DmarcStore`] over
31//! whatever you have (SQLite, Redis, S3 + flat files), feed verified
32//! results in via [`DmarcStore::record_result`], and at report time
33//! pull a day's results with [`DmarcStore::get_results_for_date`] and
34//! pass them to [`generate_dmarc_report_xml`].
35//!
36//! ## Example
37//!
38//! ```no_run
39//! use mailrs_dmarc::{
40//!     DmarcResultRecord, format_report_email, generate_dmarc_report_xml,
41//! };
42//!
43//! let results = vec![DmarcResultRecord {
44//!     source_ip: "192.0.2.1".into(),
45//!     from_domain: "example.com".into(),
46//!     spf_result: "pass".into(),
47//!     dkim_result: "pass".into(),
48//!     dmarc_result: "pass".into(),
49//!     disposition: "none".into(),
50//! }];
51//! let xml = generate_dmarc_report_xml(
52//!     "Example Inc.", "postmaster@reporter.example",
53//!     "example.com!2026-05-20", "example.com",
54//!     1715990400, 1716076800, &results,
55//! );
56//! let email = format_report_email(
57//!     "postmaster@reporter.example", "rua@example.com",
58//!     "example.com", "example.com!2026-05-20",
59//!     "2026-05-20", &xml,
60//! );
61//! ```
62
63use std::collections::HashMap;
64use std::io::Write;
65
66use async_trait::async_trait;
67
68pub mod align;
69pub mod eval;
70pub mod policy;
71
72pub use align::{check as align_check, organizational_domain};
73pub use eval::{evaluate, DkimSignatureResult, DmarcInput, DmarcOutcome, SpfResult};
74pub use policy::{Alignment, DmarcParseError, DmarcPolicy, PolicyAction};
75
76/// One verified DMARC result, recorded per inbound message.
77///
78/// Two equivalent ways to construct one:
79///
80/// ```
81/// use mailrs_dmarc::DmarcResultRecord;
82/// // Fluent constructor — preferred for the canonical 6-field record.
83/// let r = DmarcResultRecord::new(
84///     "192.0.2.1", "example.com", "pass", "pass", "pass", "none",
85/// );
86/// # let _ = r;
87///
88/// // Struct literal — still supported, useful with partial values.
89/// let r = DmarcResultRecord {
90///     source_ip: "192.0.2.1".into(),
91///     ..Default::default()
92/// };
93/// # let _ = r;
94/// ```
95///
96/// **Forward-compatibility note:** this struct is not `#[non_exhaustive]`
97/// in 1.x to keep struct-literal call sites working. Future fields that
98/// don't fit the 6-field shape will arrive in a 2.0 release alongside
99/// `#[non_exhaustive]`.
100#[derive(Debug, Clone, Default)]
101pub struct DmarcResultRecord {
102    /// Client-IP that delivered the message.
103    pub source_ip: String,
104    /// Domain in the `From:` header.
105    pub from_domain: String,
106    /// SPF verification result (`pass` / `fail` / `softfail` / `neutral` / `none`).
107    pub spf_result: String,
108    /// DKIM verification result (`pass` / `fail` / `neutral` / `none`).
109    pub dkim_result: String,
110    /// DMARC alignment + policy result (`pass` / `fail`).
111    pub dmarc_result: String,
112    /// Action taken (`none` / `quarantine` / `reject`).
113    pub disposition: String,
114}
115
116impl DmarcResultRecord {
117    /// Construct a [`DmarcResultRecord`] from the 6 canonical fields.
118    ///
119    /// Use this constructor in code outside the `mailrs-dmarc` crate
120    /// because [`DmarcResultRecord`] is `#[non_exhaustive]` and cannot
121    /// be built with struct-literal syntax from other crates.
122    pub fn new(
123        source_ip: impl Into<String>,
124        from_domain: impl Into<String>,
125        spf_result: impl Into<String>,
126        dkim_result: impl Into<String>,
127        dmarc_result: impl Into<String>,
128        disposition: impl Into<String>,
129    ) -> Self {
130        Self {
131            source_ip: source_ip.into(),
132            from_domain: from_domain.into(),
133            spf_result: spf_result.into(),
134            dkim_result: dkim_result.into(),
135            dmarc_result: dmarc_result.into(),
136            disposition: disposition.into(),
137        }
138    }
139}
140
141/// Pluggable storage for DMARC verification results.
142///
143/// Implementations should be cheap to clone (`Arc<Self>` style) since
144/// callers typically share a single instance across the inbound
145/// pipeline and the daily report task.
146#[async_trait]
147pub trait DmarcStore: Send + Sync {
148    /// Backend-specific error type returned by every trait method.
149    type Error: std::fmt::Debug + Send;
150
151    /// Append a verified result. Called per-message during inbound.
152    async fn record_result(&self, record: &DmarcResultRecord) -> Result<(), Self::Error>;
153
154    /// Fetch all results recorded on `date` (YYYY-MM-DD). Called once
155    /// per day by the report generator.
156    async fn get_results_for_date(
157        &self,
158        date: &str,
159    ) -> Result<Vec<DmarcResultRecord>, Self::Error>;
160
161    /// Prune results older than `days`. Called periodically.
162    async fn cleanup_old(&self, days: i64) -> Result<u64, Self::Error>;
163}
164
165#[cfg(feature = "pg-store")]
166pub use pg::PgDmarcStore;
167
168#[cfg(feature = "pg-store")]
169mod pg {
170    use async_trait::async_trait;
171    use sqlx::PgPool;
172
173    use super::{DmarcResultRecord, DmarcStore};
174
175    /// Postgres-backed [`DmarcStore`]. Expects a table:
176    ///
177    /// ```sql
178    /// CREATE TABLE dmarc_results (
179    ///   source_ip   text NOT NULL,
180    ///   from_domain text NOT NULL,
181    ///   spf_result  text NOT NULL,
182    ///   dkim_result text NOT NULL,
183    ///   dmarc_result text NOT NULL,
184    ///   disposition text NOT NULL,
185    ///   report_date date NOT NULL DEFAULT CURRENT_DATE
186    /// );
187    /// CREATE INDEX dmarc_results_by_date ON dmarc_results(report_date);
188    /// ```
189    pub struct PgDmarcStore {
190        pool: PgPool,
191    }
192
193    impl PgDmarcStore {
194        /// Construct a [`PgDmarcStore`] from an existing pool. The caller
195        /// owns the pool lifecycle.
196        pub fn new(pool: PgPool) -> Self {
197            Self { pool }
198        }
199    }
200
201    #[async_trait]
202    impl DmarcStore for PgDmarcStore {
203        type Error = sqlx::Error;
204
205        async fn record_result(&self, record: &DmarcResultRecord) -> Result<(), sqlx::Error> {
206            sqlx::query(
207                "INSERT INTO dmarc_results (source_ip, from_domain, spf_result, dkim_result, dmarc_result, disposition)
208                 VALUES ($1, $2, $3, $4, $5, $6)",
209            )
210            .bind(&record.source_ip)
211            .bind(&record.from_domain)
212            .bind(&record.spf_result)
213            .bind(&record.dkim_result)
214            .bind(&record.dmarc_result)
215            .bind(&record.disposition)
216            .execute(&self.pool)
217            .await?;
218            Ok(())
219        }
220
221        async fn get_results_for_date(
222            &self,
223            date: &str,
224        ) -> Result<Vec<DmarcResultRecord>, sqlx::Error> {
225            let rows: Vec<(String, String, String, String, String, String)> = sqlx::query_as(
226                "SELECT source_ip, from_domain, spf_result, dkim_result, dmarc_result, disposition
227                 FROM dmarc_results WHERE report_date = $1::date",
228            )
229            .bind(date)
230            .fetch_all(&self.pool)
231            .await?;
232            Ok(rows
233                .into_iter()
234                .map(|r| DmarcResultRecord {
235                    source_ip: r.0,
236                    from_domain: r.1,
237                    spf_result: r.2,
238                    dkim_result: r.3,
239                    dmarc_result: r.4,
240                    disposition: r.5,
241                })
242                .collect())
243        }
244
245        async fn cleanup_old(&self, days: i64) -> Result<u64, sqlx::Error> {
246            let cutoff = chrono::Utc::now() - chrono::Duration::days(days);
247            let cutoff_date = cutoff.format("%Y-%m-%d").to_string();
248            let result = sqlx::query("DELETE FROM dmarc_results WHERE report_date < $1::date")
249                .bind(cutoff_date)
250                .execute(&self.pool)
251                .await?;
252            Ok(result.rows_affected())
253        }
254    }
255}
256
257/// Aggregated row for report generation.
258#[derive(Debug, Clone, Hash, PartialEq, Eq)]
259struct AggKey {
260    source_ip: String,
261    from_domain: String,
262    disposition: String,
263    dkim_result: String,
264    spf_result: String,
265}
266
267/// Generate a DMARC aggregate report XML body (RFC 7489 §12.4).
268pub fn generate_dmarc_report_xml(
269    org_name: &str,
270    email: &str,
271    report_id: &str,
272    domain: &str,
273    begin_ts: i64,
274    end_ts: i64,
275    results: &[DmarcResultRecord],
276) -> String {
277    let mut agg: HashMap<AggKey, u32> = HashMap::new();
278    for r in results {
279        let key = AggKey {
280            source_ip: r.source_ip.clone(),
281            from_domain: r.from_domain.clone(),
282            disposition: r.disposition.clone(),
283            dkim_result: r.dkim_result.clone(),
284            spf_result: r.spf_result.clone(),
285        };
286        *agg.entry(key).or_insert(0) += 1;
287    }
288
289    let mut xml = String::new();
290    xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n");
291    xml.push_str("<feedback>\n");
292
293    xml.push_str("  <report_metadata>\n");
294    xml.push_str(&format!("    <org_name>{}</org_name>\n", escape_xml(org_name)));
295    xml.push_str(&format!("    <email>{}</email>\n", escape_xml(email)));
296    xml.push_str(&format!("    <report_id>{report_id}</report_id>\n"));
297    xml.push_str("    <date_range>\n");
298    xml.push_str(&format!("      <begin>{begin_ts}</begin>\n"));
299    xml.push_str(&format!("      <end>{end_ts}</end>\n"));
300    xml.push_str("    </date_range>\n");
301    xml.push_str("  </report_metadata>\n");
302
303    xml.push_str("  <policy_published>\n");
304    xml.push_str(&format!("    <domain>{}</domain>\n", escape_xml(domain)));
305    xml.push_str("    <adkim>r</adkim>\n");
306    xml.push_str("    <aspf>r</aspf>\n");
307    xml.push_str("    <p>none</p>\n");
308    xml.push_str("    <sp>none</sp>\n");
309    xml.push_str("    <pct>100</pct>\n");
310    xml.push_str("  </policy_published>\n");
311
312    let mut keys: Vec<_> = agg.keys().collect();
313    keys.sort_by(|a, b| (&a.source_ip, &a.from_domain).cmp(&(&b.source_ip, &b.from_domain)));
314
315    for key in keys {
316        let count = agg[key];
317        xml.push_str("  <record>\n");
318        xml.push_str("    <row>\n");
319        xml.push_str(&format!("      <source_ip>{}</source_ip>\n", key.source_ip));
320        xml.push_str(&format!("      <count>{count}</count>\n"));
321        xml.push_str("      <policy_evaluated>\n");
322        xml.push_str(&format!("        <disposition>{}</disposition>\n", key.disposition));
323        xml.push_str(&format!("        <dkim>{}</dkim>\n", key.dkim_result));
324        xml.push_str(&format!("        <spf>{}</spf>\n", key.spf_result));
325        xml.push_str("      </policy_evaluated>\n");
326        xml.push_str("    </row>\n");
327        xml.push_str("    <identifiers>\n");
328        xml.push_str(&format!(
329            "      <header_from>{}</header_from>\n",
330            escape_xml(&key.from_domain)
331        ));
332        xml.push_str("    </identifiers>\n");
333        xml.push_str("    <auth_results>\n");
334        xml.push_str("      <spf>\n");
335        xml.push_str(&format!(
336            "        <domain>{}</domain>\n",
337            escape_xml(&key.from_domain)
338        ));
339        xml.push_str(&format!("        <result>{}</result>\n", key.spf_result));
340        xml.push_str("      </spf>\n");
341        xml.push_str("      <dkim>\n");
342        xml.push_str(&format!(
343            "        <domain>{}</domain>\n",
344            escape_xml(&key.from_domain)
345        ));
346        xml.push_str(&format!("        <result>{}</result>\n", key.dkim_result));
347        xml.push_str("      </dkim>\n");
348        xml.push_str("    </auth_results>\n");
349        xml.push_str("  </record>\n");
350    }
351
352    xml.push_str("</feedback>\n");
353    xml
354}
355
356fn escape_xml(s: &str) -> String {
357    s.replace('&', "&amp;")
358        .replace('<', "&lt;")
359        .replace('>', "&gt;")
360        .replace('"', "&quot;")
361}
362
363fn gzip_compress(data: &[u8]) -> Vec<u8> {
364    let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
365    let _ = encoder.write_all(data);
366    encoder.finish().unwrap_or_default()
367}
368
369/// Format a DMARC aggregate report email with a gzipped XML attachment.
370pub fn format_report_email(
371    from: &str,
372    to: &str,
373    org_domain: &str,
374    report_id: &str,
375    date: &str,
376    xml: &str,
377) -> Vec<u8> {
378    use base64::Engine;
379    let gz = gzip_compress(xml.as_bytes());
380    let b64 = base64::engine::general_purpose::STANDARD.encode(&gz);
381    let boundary = format!("dmarc-report-{report_id}");
382    let filename = format!("{org_domain}!{to}!{date}!{report_id}.xml.gz");
383    let now = chrono::Utc::now().to_rfc2822();
384
385    let mut msg = format!(
386        "From: {from}\r\n\
387         To: {to}\r\n\
388         Subject: Report domain: {org_domain} Submitter: {from} Report-ID: <{report_id}>\r\n\
389         Date: {now}\r\n\
390         MIME-Version: 1.0\r\n\
391         Content-Type: multipart/mixed; boundary=\"{boundary}\"\r\n\
392         \r\n\
393         --{boundary}\r\n\
394         Content-Type: text/plain; charset=utf-8\r\n\
395         \r\n\
396         DMARC aggregate report for {org_domain} ({date})\r\n\
397         \r\n\
398         --{boundary}\r\n\
399         Content-Type: application/gzip\r\n\
400         Content-Disposition: attachment; filename=\"{filename}\"\r\n\
401         Content-Transfer-Encoding: base64\r\n\
402         \r\n"
403    );
404
405    for chunk in b64.as_bytes().chunks(76) {
406        msg.push_str(std::str::from_utf8(chunk).unwrap_or(""));
407        msg.push_str("\r\n");
408    }
409    msg.push_str(&format!("--{boundary}--\r\n"));
410
411    msg.into_bytes()
412}
413
414/// Extract the `rua` mailbox from a `_dmarc.<domain>` TXT record.
415pub fn extract_rua_from_dmarc_record(txt: &str) -> Option<String> {
416    for part in txt.split(';') {
417        let part = part.trim();
418        if let Some(value) = part.strip_prefix("rua=") {
419            for uri in value.split(',') {
420                let uri = uri.trim();
421                if let Some(addr) = uri.strip_prefix("mailto:") {
422                    return Some(addr.to_string());
423                }
424            }
425        }
426    }
427    None
428}
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn generate_report_xml_basic() {
435        let results = vec![
436            DmarcResultRecord {
437                source_ip: "1.2.3.4".into(),
438                from_domain: "example.com".into(),
439                spf_result: "pass".into(),
440                dkim_result: "pass".into(),
441                dmarc_result: "pass".into(),
442                disposition: "none".into(),
443            },
444            DmarcResultRecord {
445                source_ip: "1.2.3.4".into(),
446                from_domain: "example.com".into(),
447                spf_result: "pass".into(),
448                dkim_result: "pass".into(),
449                dmarc_result: "pass".into(),
450                disposition: "none".into(),
451            },
452            DmarcResultRecord {
453                source_ip: "5.6.7.8".into(),
454                from_domain: "example.com".into(),
455                spf_result: "fail".into(),
456                dkim_result: "fail".into(),
457                dmarc_result: "fail".into(),
458                disposition: "reject".into(),
459            },
460        ];
461
462        let xml = generate_dmarc_report_xml(
463            "Test Org",
464            "dmarc@test.com",
465            "rpt-001",
466            "test.com",
467            1000000,
468            1086400,
469            &results,
470        );
471
472        assert!(xml.contains("<org_name>Test Org</org_name>"));
473        assert!(xml.contains("<email>dmarc@test.com</email>"));
474        assert!(xml.contains("<report_id>rpt-001</report_id>"));
475        assert!(xml.contains("<begin>1000000</begin>"));
476        assert!(xml.contains("<end>1086400</end>"));
477        assert!(xml.contains("<count>2</count>")); // aggregated
478        assert!(xml.contains("<count>1</count>"));
479        assert!(xml.contains("<source_ip>1.2.3.4</source_ip>"));
480        assert!(xml.contains("<source_ip>5.6.7.8</source_ip>"));
481        assert!(xml.contains("<disposition>none</disposition>"));
482        assert!(xml.contains("<disposition>reject</disposition>"));
483    }
484
485    #[test]
486    fn generate_report_xml_escapes_special_chars() {
487        let results = vec![DmarcResultRecord {
488            source_ip: "1.2.3.4".into(),
489            from_domain: "test&co.com".into(),
490            spf_result: "pass".into(),
491            dkim_result: "pass".into(),
492            dmarc_result: "pass".into(),
493            disposition: "none".into(),
494        }];
495
496        let xml = generate_dmarc_report_xml(
497            "O&G <Corp>",
498            "dmarc@test.com",
499            "rpt-002",
500            "test.com",
501            0,
502            86400,
503            &results,
504        );
505
506        assert!(xml.contains("O&amp;G &lt;Corp&gt;"));
507        assert!(xml.contains("test&amp;co.com"));
508    }
509
510    #[test]
511    fn extract_rua_mailto() {
512        assert_eq!(
513            extract_rua_from_dmarc_record("v=DMARC1; p=none; rua=mailto:dmarc@example.com"),
514            Some("dmarc@example.com".into())
515        );
516        assert_eq!(
517            extract_rua_from_dmarc_record("v=DMARC1; p=reject; rua=mailto:a@b.com, mailto:c@d.com"),
518            Some("a@b.com".into())
519        );
520        assert_eq!(extract_rua_from_dmarc_record("v=DMARC1; p=none"), None);
521    }
522
523    #[test]
524    fn generate_report_xml_empty_results() {
525        let xml = generate_dmarc_report_xml("Org", "a@b.com", "rpt-0", "b.com", 0, 86400, &[]);
526        assert!(xml.contains("<feedback>"));
527        assert!(xml.contains("</feedback>"));
528        assert!(!xml.contains("<record>"));
529    }
530
531    #[test]
532    fn escape_xml_all_special_chars() {
533        assert_eq!(escape_xml("a&b<c>d\"e"), "a&amp;b&lt;c&gt;d&quot;e");
534    }
535
536    #[test]
537    fn escape_xml_passthrough() {
538        assert_eq!(escape_xml("hello world"), "hello world");
539    }
540
541    #[test]
542    fn gzip_compress_roundtrip() {
543        let data = b"hello world test data";
544        let compressed = gzip_compress(data);
545        assert!(!compressed.is_empty());
546        assert!(compressed.len() < data.len() + 100); // gzip has overhead for small data
547    }
548
549    #[test]
550    fn extract_rua_no_mailto_prefix() {
551        assert_eq!(
552            extract_rua_from_dmarc_record("v=DMARC1; rua=https://example.com/dmarc"),
553            None
554        );
555    }
556
557    #[test]
558    fn extract_rua_empty_string() {
559        assert_eq!(extract_rua_from_dmarc_record(""), None);
560    }
561
562    #[test]
563    fn format_report_email_structure() {
564        let xml = "<feedback><record/></feedback>";
565        let email = format_report_email(
566            "dmarc@host.com",
567            "rua@example.com",
568            "example.com",
569            "rpt-001",
570            "2026-03-01",
571            xml,
572        );
573        let email_str = String::from_utf8_lossy(&email);
574        assert!(email_str.contains("From: dmarc@host.com"));
575        assert!(email_str.contains("To: rua@example.com"));
576        assert!(email_str.contains("Report domain: example.com"));
577        assert!(email_str.contains("Content-Type: multipart/mixed"));
578        assert!(email_str.contains("Content-Type: application/gzip"));
579        assert!(email_str.contains("Content-Transfer-Encoding: base64"));
580    }
581
582    // --- additional extract_rua_from_dmarc_record tests ---
583
584    #[test]
585    fn extract_rua_with_whitespace_around_mailto() {
586        // the value " mailto:report@example.com" is split by comma, then trimmed,
587        // so strip_prefix("mailto:") matches after trim
588        assert_eq!(
589            extract_rua_from_dmarc_record("v=DMARC1; p=none; rua= mailto:report@example.com"),
590            Some("report@example.com".into()),
591        );
592    }
593
594    #[test]
595    fn extract_rua_multiple_uris_first_non_mailto() {
596        // first uri is https, second is mailto — should return the mailto one
597        assert_eq!(
598            extract_rua_from_dmarc_record(
599                "v=DMARC1; p=none; rua=https://report.example.com, mailto:dmarc@example.com"
600            ),
601            Some("dmarc@example.com".into())
602        );
603    }
604
605    #[test]
606    fn extract_rua_just_rua_field() {
607        assert_eq!(
608            extract_rua_from_dmarc_record("rua=mailto:x@y.com"),
609            Some("x@y.com".into())
610        );
611    }
612
613    #[test]
614    fn extract_rua_ruf_not_rua() {
615        // ruf is for forensic reports, not aggregate — should return None
616        assert_eq!(
617            extract_rua_from_dmarc_record("v=DMARC1; p=none; ruf=mailto:forensic@example.com"),
618            None
619        );
620    }
621
622    #[test]
623    fn extract_rua_complex_real_world_record() {
624        let record = "v=DMARC1; p=quarantine; sp=reject; adkim=s; aspf=s; pct=100; rua=mailto:dmarc-agg@example.com; ruf=mailto:dmarc-forensic@example.com; fo=1";
625        assert_eq!(
626            extract_rua_from_dmarc_record(record),
627            Some("dmarc-agg@example.com".into())
628        );
629    }
630
631    #[test]
632    fn extract_rua_with_size_limit() {
633        // some DMARC records include size limits like mailto:rua@example.com!10m
634        assert_eq!(
635            extract_rua_from_dmarc_record("v=DMARC1; p=none; rua=mailto:rua@example.com!10m"),
636            Some("rua@example.com!10m".into())
637        );
638    }
639
640    #[test]
641    fn extract_rua_semicolon_only() {
642        assert_eq!(extract_rua_from_dmarc_record(";;;"), None);
643    }
644
645    // --- additional escape_xml tests ---
646
647    #[test]
648    fn escape_xml_empty_string() {
649        assert_eq!(escape_xml(""), "");
650    }
651
652    #[test]
653    fn escape_xml_only_special_chars() {
654        assert_eq!(escape_xml("&<>\""), "&amp;&lt;&gt;&quot;");
655    }
656
657    #[test]
658    fn escape_xml_single_quote_not_escaped() {
659        // xml escape in this impl does not handle single quotes
660        assert_eq!(escape_xml("it's"), "it's");
661    }
662
663    #[test]
664    fn escape_xml_repeated_ampersands() {
665        assert_eq!(escape_xml("&&&&"), "&amp;&amp;&amp;&amp;");
666    }
667
668    #[test]
669    fn escape_xml_unicode_passthrough() {
670        assert_eq!(escape_xml("日本語テスト"), "日本語テスト");
671    }
672
673    #[test]
674    fn escape_xml_mixed_content() {
675        assert_eq!(
676            escape_xml("Hello <world> & \"universe\""),
677            "Hello &lt;world&gt; &amp; &quot;universe&quot;"
678        );
679    }
680
681    // --- additional gzip_compress tests ---
682
683    #[test]
684    fn gzip_compress_empty_data() {
685        let compressed = gzip_compress(b"");
686        assert!(!compressed.is_empty()); // gzip header still present
687    }
688
689    #[test]
690    fn gzip_compress_decompresses_correctly() {
691        use std::io::Read;
692        let original = b"The quick brown fox jumps over the lazy dog";
693        let compressed = gzip_compress(original);
694        let mut decoder = flate2::read::GzDecoder::new(&compressed[..]);
695        let mut decompressed = Vec::new();
696        decoder.read_to_end(&mut decompressed).unwrap();
697        assert_eq!(decompressed, original);
698    }
699
700    #[test]
701    fn gzip_compress_large_repetitive_data() {
702        let data: Vec<u8> = "ABCDEFGHIJ".repeat(10000).into_bytes();
703        let compressed = gzip_compress(&data);
704        // repetitive data should compress well
705        assert!(compressed.len() < data.len() / 10);
706    }
707
708    // --- additional generate_dmarc_report_xml tests ---
709
710    #[test]
711    fn generate_report_xml_single_result() {
712        let results = vec![DmarcResultRecord {
713            source_ip: "10.0.0.1".into(),
714            from_domain: "single.com".into(),
715            spf_result: "pass".into(),
716            dkim_result: "fail".into(),
717            dmarc_result: "fail".into(),
718            disposition: "quarantine".into(),
719        }];
720        let xml = generate_dmarc_report_xml(
721            "Single Org", "s@s.com", "rpt-single", "single.com", 0, 86400, &results,
722        );
723        assert!(xml.contains("<count>1</count>"));
724        assert!(xml.contains("<source_ip>10.0.0.1</source_ip>"));
725        assert!(xml.contains("<disposition>quarantine</disposition>"));
726        assert!(xml.contains("<dkim>fail</dkim>"));
727        assert!(xml.contains("<spf>pass</spf>"));
728        assert!(xml.contains("<header_from>single.com</header_from>"));
729    }
730
731    #[test]
732    fn generate_report_xml_starts_with_xml_declaration() {
733        let xml = generate_dmarc_report_xml("O", "e@e.com", "r", "d.com", 0, 1, &[]);
734        assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>"));
735    }
736
737    #[test]
738    fn generate_report_xml_policy_published_defaults() {
739        let xml = generate_dmarc_report_xml("O", "e@e.com", "r", "d.com", 0, 1, &[]);
740        assert!(xml.contains("<adkim>r</adkim>"));
741        assert!(xml.contains("<aspf>r</aspf>"));
742        assert!(xml.contains("<p>none</p>"));
743        assert!(xml.contains("<sp>none</sp>"));
744        assert!(xml.contains("<pct>100</pct>"));
745    }
746
747    #[test]
748    fn generate_report_xml_aggregates_identical_keys() {
749        // 5 identical records should aggregate to count=5
750        let record = DmarcResultRecord {
751            source_ip: "9.9.9.9".into(),
752            from_domain: "agg.com".into(),
753            spf_result: "pass".into(),
754            dkim_result: "pass".into(),
755            dmarc_result: "pass".into(),
756            disposition: "none".into(),
757        };
758        let results: Vec<_> = (0..5).map(|_| record.clone()).collect();
759        let xml = generate_dmarc_report_xml(
760            "Org", "e@e.com", "r", "agg.com", 0, 86400, &results,
761        );
762        assert!(xml.contains("<count>5</count>"));
763        // only one <record> block since all aggregate to same key
764        assert_eq!(xml.matches("<record>").count(), 1);
765    }
766
767    #[test]
768    fn generate_report_xml_different_ips_separate_records() {
769        let results = vec![
770            DmarcResultRecord {
771                source_ip: "1.1.1.1".into(),
772                from_domain: "test.com".into(),
773                spf_result: "pass".into(),
774                dkim_result: "pass".into(),
775                dmarc_result: "pass".into(),
776                disposition: "none".into(),
777            },
778            DmarcResultRecord {
779                source_ip: "2.2.2.2".into(),
780                from_domain: "test.com".into(),
781                spf_result: "pass".into(),
782                dkim_result: "pass".into(),
783                dmarc_result: "pass".into(),
784                disposition: "none".into(),
785            },
786        ];
787        let xml = generate_dmarc_report_xml(
788            "Org", "e@e.com", "r", "test.com", 0, 86400, &results,
789        );
790        assert_eq!(xml.matches("<record>").count(), 2);
791        // records should be sorted by source_ip
792        let pos1 = xml.find("<source_ip>1.1.1.1</source_ip>").unwrap();
793        let pos2 = xml.find("<source_ip>2.2.2.2</source_ip>").unwrap();
794        assert!(pos1 < pos2, "records should be sorted by source_ip");
795    }
796
797    #[test]
798    fn generate_report_xml_different_dispositions_separate_records() {
799        let results = vec![
800            DmarcResultRecord {
801                source_ip: "1.1.1.1".into(),
802                from_domain: "test.com".into(),
803                spf_result: "pass".into(),
804                dkim_result: "pass".into(),
805                dmarc_result: "pass".into(),
806                disposition: "none".into(),
807            },
808            DmarcResultRecord {
809                source_ip: "1.1.1.1".into(),
810                from_domain: "test.com".into(),
811                spf_result: "fail".into(),
812                dkim_result: "fail".into(),
813                dmarc_result: "fail".into(),
814                disposition: "reject".into(),
815            },
816        ];
817        let xml = generate_dmarc_report_xml(
818            "Org", "e@e.com", "r", "test.com", 0, 86400, &results,
819        );
820        // same ip but different disposition/results = different agg keys
821        assert_eq!(xml.matches("<record>").count(), 2);
822    }
823
824    #[test]
825    fn generate_report_xml_domain_in_policy_published() {
826        let xml = generate_dmarc_report_xml(
827            "Org", "e@e.com", "r", "mydomain.org", 0, 86400, &[],
828        );
829        assert!(xml.contains("<domain>mydomain.org</domain>"));
830    }
831
832    #[test]
833    fn generate_report_xml_auth_results_section() {
834        let results = vec![DmarcResultRecord {
835            source_ip: "3.3.3.3".into(),
836            from_domain: "auth.com".into(),
837            spf_result: "softfail".into(),
838            dkim_result: "temperror".into(),
839            dmarc_result: "fail".into(),
840            disposition: "none".into(),
841        }];
842        let xml = generate_dmarc_report_xml(
843            "Org", "e@e.com", "r", "auth.com", 0, 86400, &results,
844        );
845        assert!(xml.contains("<auth_results>"));
846        assert!(xml.contains("<result>softfail</result>"));
847        assert!(xml.contains("<result>temperror</result>"));
848    }
849
850    #[test]
851    fn generate_report_xml_multiple_domains() {
852        let results = vec![
853            DmarcResultRecord {
854                source_ip: "1.1.1.1".into(),
855                from_domain: "a.com".into(),
856                spf_result: "pass".into(),
857                dkim_result: "pass".into(),
858                dmarc_result: "pass".into(),
859                disposition: "none".into(),
860            },
861            DmarcResultRecord {
862                source_ip: "1.1.1.1".into(),
863                from_domain: "b.com".into(),
864                spf_result: "fail".into(),
865                dkim_result: "fail".into(),
866                dmarc_result: "fail".into(),
867                disposition: "reject".into(),
868            },
869        ];
870        let xml = generate_dmarc_report_xml(
871            "Org", "e@e.com", "r", "test.com", 0, 86400, &results,
872        );
873        assert!(xml.contains("<header_from>a.com</header_from>"));
874        assert!(xml.contains("<header_from>b.com</header_from>"));
875    }
876
877    // --- additional format_report_email tests ---
878
879    #[test]
880    fn format_report_email_contains_boundary() {
881        let xml = "<feedback/>";
882        let email = format_report_email("f@f.com", "t@t.com", "d.com", "rpt-1", "2026-01-01", xml);
883        let email_str = String::from_utf8_lossy(&email);
884        assert!(email_str.contains("boundary=\"dmarc-report-rpt-1\""));
885        assert!(email_str.contains("--dmarc-report-rpt-1"));
886        assert!(email_str.contains("--dmarc-report-rpt-1--"));
887    }
888
889    #[test]
890    fn format_report_email_filename_format() {
891        let xml = "<feedback/>";
892        let email = format_report_email(
893            "f@f.com", "rua@target.com", "example.com", "rpt-42", "2026-03-01", xml,
894        );
895        let email_str = String::from_utf8_lossy(&email);
896        assert!(email_str.contains("filename=\"example.com!rua@target.com!2026-03-01!rpt-42.xml.gz\""));
897    }
898
899    #[test]
900    fn format_report_email_subject_contains_domain_and_report_id() {
901        let xml = "<feedback/>";
902        let email = format_report_email(
903            "dmarc@mx.com", "rua@dest.com", "sender.org", "RPT-99", "2026-02-28", xml,
904        );
905        let email_str = String::from_utf8_lossy(&email);
906        assert!(email_str.contains("Report domain: sender.org"));
907        assert!(email_str.contains("Report-ID: <RPT-99>"));
908    }
909
910    #[test]
911    fn format_report_email_mime_version() {
912        let email = format_report_email("f@f.com", "t@t.com", "d.com", "r", "2026-01-01", "<x/>");
913        let email_str = String::from_utf8_lossy(&email);
914        assert!(email_str.contains("MIME-Version: 1.0"));
915    }
916
917    #[test]
918    fn format_report_email_has_date_header() {
919        let email = format_report_email("f@f.com", "t@t.com", "d.com", "r", "2026-01-01", "<x/>");
920        let email_str = String::from_utf8_lossy(&email);
921        assert!(email_str.contains("Date: "));
922    }
923
924    #[test]
925    fn format_report_email_text_body_mentions_domain_and_date() {
926        let email = format_report_email(
927            "f@f.com", "t@t.com", "mydom.com", "r", "2026-03-05", "<x/>",
928        );
929        let email_str = String::from_utf8_lossy(&email);
930        assert!(email_str.contains("DMARC aggregate report for mydom.com (2026-03-05)"));
931    }
932
933    #[test]
934    fn format_report_email_base64_attachment_is_valid() {
935        use base64::Engine;
936        let xml = "<feedback><record>test</record></feedback>";
937        let email = format_report_email("f@f.com", "t@t.com", "d.com", "r", "2026-01-01", xml);
938        let email_str = String::from_utf8_lossy(&email);
939
940        // extract base64 content between the base64 header and the closing boundary
941        let b64_marker = "Content-Transfer-Encoding: base64\r\n\r\n";
942        let start = email_str.find(b64_marker).unwrap() + b64_marker.len();
943        let end = email_str[start..].find("--dmarc-report-r--").unwrap() + start;
944        let b64_content: String = email_str[start..end]
945            .lines()
946            .collect::<Vec<_>>()
947            .join("");
948        // should be valid base64
949        let decoded = base64::engine::general_purpose::STANDARD
950            .decode(b64_content.trim())
951            .expect("base64 should be valid");
952        assert!(!decoded.is_empty());
953
954        // should decompress back to original xml
955        use std::io::Read;
956        let mut decoder = flate2::read::GzDecoder::new(&decoded[..]);
957        let mut decompressed = String::new();
958        decoder.read_to_string(&mut decompressed).unwrap();
959        assert_eq!(decompressed, xml);
960    }
961
962    #[test]
963    fn format_report_email_base64_line_length() {
964        // base64 lines should be wrapped at 76 chars
965        let xml = "<feedback>".repeat(100); // large enough to produce multi-line base64
966        let email = format_report_email("f@f.com", "t@t.com", "d.com", "r", "2026-01-01", &xml);
967        let email_str = String::from_utf8_lossy(&email);
968        let b64_marker = "Content-Transfer-Encoding: base64\r\n\r\n";
969        let start = email_str.find(b64_marker).unwrap() + b64_marker.len();
970        let end = email_str[start..].find("--dmarc-report-r--").unwrap() + start;
971        for line in email_str[start..end].split("\r\n") {
972            if !line.is_empty() && !line.starts_with("--") {
973                assert!(line.len() <= 76, "base64 line too long: {} chars", line.len());
974            }
975        }
976    }
977
978    // --- DmarcResultRecord tests ---
979
980    #[test]
981    fn dmarc_result_record_clone() {
982        let record = DmarcResultRecord {
983            source_ip: "1.2.3.4".into(),
984            from_domain: "test.com".into(),
985            spf_result: "pass".into(),
986            dkim_result: "pass".into(),
987            dmarc_result: "pass".into(),
988            disposition: "none".into(),
989        };
990        let cloned = record.clone();
991        assert_eq!(cloned.source_ip, "1.2.3.4");
992        assert_eq!(cloned.from_domain, "test.com");
993        assert_eq!(cloned.disposition, "none");
994    }
995
996    #[test]
997    fn dmarc_result_record_debug() {
998        let record = DmarcResultRecord {
999            source_ip: "1.2.3.4".into(),
1000            from_domain: "test.com".into(),
1001            spf_result: "pass".into(),
1002            dkim_result: "fail".into(),
1003            dmarc_result: "fail".into(),
1004            disposition: "reject".into(),
1005        };
1006        let debug = format!("{:?}", record);
1007        assert!(debug.contains("DmarcResultRecord"));
1008        assert!(debug.contains("1.2.3.4"));
1009        assert!(debug.contains("reject"));
1010    }
1011
1012    // --- AggKey tests ---
1013
1014    #[test]
1015    fn agg_key_equality() {
1016        let key1 = AggKey {
1017            source_ip: "1.1.1.1".into(),
1018            from_domain: "a.com".into(),
1019            disposition: "none".into(),
1020            dkim_result: "pass".into(),
1021            spf_result: "pass".into(),
1022        };
1023        let key2 = key1.clone();
1024        assert_eq!(key1, key2);
1025    }
1026
1027    #[test]
1028    fn agg_key_inequality_on_spf() {
1029        let key1 = AggKey {
1030            source_ip: "1.1.1.1".into(),
1031            from_domain: "a.com".into(),
1032            disposition: "none".into(),
1033            dkim_result: "pass".into(),
1034            spf_result: "pass".into(),
1035        };
1036        let key2 = AggKey {
1037            spf_result: "fail".into(),
1038            ..key1.clone()
1039        };
1040        assert_ne!(key1, key2);
1041    }
1042
1043    #[test]
1044    fn agg_key_hash_consistency() {
1045        use std::collections::hash_map::DefaultHasher;
1046        use std::hash::{Hash, Hasher};
1047
1048        let key = AggKey {
1049            source_ip: "1.1.1.1".into(),
1050            from_domain: "a.com".into(),
1051            disposition: "none".into(),
1052            dkim_result: "pass".into(),
1053            spf_result: "pass".into(),
1054        };
1055        let mut h1 = DefaultHasher::new();
1056        let mut h2 = DefaultHasher::new();
1057        key.hash(&mut h1);
1058        key.clone().hash(&mut h2);
1059        assert_eq!(h1.finish(), h2.finish());
1060    }
1061
1062    // --- edge cases for xml generation ---
1063
1064    #[test]
1065    fn generate_report_xml_ipv6_source() {
1066        let results = vec![DmarcResultRecord {
1067            source_ip: "2001:db8::1".into(),
1068            from_domain: "ipv6.com".into(),
1069            spf_result: "pass".into(),
1070            dkim_result: "pass".into(),
1071            dmarc_result: "pass".into(),
1072            disposition: "none".into(),
1073        }];
1074        let xml = generate_dmarc_report_xml(
1075            "Org", "e@e.com", "r", "ipv6.com", 0, 86400, &results,
1076        );
1077        assert!(xml.contains("<source_ip>2001:db8::1</source_ip>"));
1078    }
1079
1080    #[test]
1081    fn generate_report_xml_large_timestamps() {
1082        let xml = generate_dmarc_report_xml(
1083            "Org", "e@e.com", "r", "d.com", i64::MAX - 1, i64::MAX, &[],
1084        );
1085        assert!(xml.contains(&format!("<begin>{}</begin>", i64::MAX - 1)));
1086        assert!(xml.contains(&format!("<end>{}</end>", i64::MAX)));
1087    }
1088
1089    #[test]
1090    fn generate_report_xml_special_chars_in_domain() {
1091        let xml = generate_dmarc_report_xml(
1092            "Org", "e@e.com", "r", "test&<>.com", 0, 86400, &[],
1093        );
1094        assert!(xml.contains("<domain>test&amp;&lt;&gt;.com</domain>"));
1095    }
1096}