1#![deny(missing_docs)]
2#![deny(rustdoc::broken_intra_doc_links)]
3
4use 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#[derive(Debug, Clone, Default)]
101pub struct DmarcResultRecord {
102 pub source_ip: String,
104 pub from_domain: String,
106 pub spf_result: String,
108 pub dkim_result: String,
110 pub dmarc_result: String,
112 pub disposition: String,
114}
115
116impl DmarcResultRecord {
117 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#[async_trait]
147pub trait DmarcStore: Send + Sync {
148 type Error: std::fmt::Debug + Send;
150
151 async fn record_result(&self, record: &DmarcResultRecord) -> Result<(), Self::Error>;
153
154 async fn get_results_for_date(
157 &self,
158 date: &str,
159 ) -> Result<Vec<DmarcResultRecord>, Self::Error>;
160
161 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 pub struct PgDmarcStore {
190 pool: PgPool,
191 }
192
193 impl PgDmarcStore {
194 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#[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
267pub 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('&', "&")
358 .replace('<', "<")
359 .replace('>', ">")
360 .replace('"', """)
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
369pub 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
414pub 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>")); 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&G <Corp>"));
507 assert!(xml.contains("test&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&b<c>d"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); }
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 #[test]
585 fn extract_rua_with_whitespace_around_mailto() {
586 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 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 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 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 #[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("&<>\""), "&<>"");
655 }
656
657 #[test]
658 fn escape_xml_single_quote_not_escaped() {
659 assert_eq!(escape_xml("it's"), "it's");
661 }
662
663 #[test]
664 fn escape_xml_repeated_ampersands() {
665 assert_eq!(escape_xml("&&&&"), "&&&&");
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 <world> & "universe""
678 );
679 }
680
681 #[test]
684 fn gzip_compress_empty_data() {
685 let compressed = gzip_compress(b"");
686 assert!(!compressed.is_empty()); }
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 assert!(compressed.len() < data.len() / 10);
706 }
707
708 #[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 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 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 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 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 #[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 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 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 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 let xml = "<feedback>".repeat(100); 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 #[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 #[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 #[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&<>.com</domain>"));
1095 }
1096}