import email from unittest.mock import MagicMock import pytest from app.services import forensic_parser as forensic_parser_module from app.services.forensic_parser import ( ForensicParser, MAX_FORENSIC_REPORT_SIZE, _coerce_text, _domain_from_address, _message_part_payload, _payload_text, ) from app.services.forensic_redaction import ForensicRedactionPolicy SAMPLE_FORENSIC_EMAIL = b"""\ From: DMARC Reporter To: postmaster@example.com Subject: DMARC Failure Report for example.com Message-ID: MIME-Version: 1.0 Content-Type: multipart/report; report-type=feedback-report; boundary="ruf-boundary" --ruf-boundary Content-Type: text/plain; charset=utf-8 This is a DMARC failure report. --ruf-boundary Content-Type: message/feedback-report Feedback-Type: auth-failure User-Agent: Example Reporter Version: 1 Original-Mail-From: alice@example.com Arrival-Date: Fri, 22 May 2026 10:15:00 +0000 Source-IP: 203.0.113.8 Reported-Domain: example.com Authentication-Results: mx.example.net; dkim=fail header.d=example.com; spf=pass Auth-Failure: dkim Delivery-Result: reject --ruf-boundary Content-Type: text/rfc822-headers From: Alice Sender To: Bob Receiver Subject: Customer renewal token abcdefghijklmnopqrstuvwxyz123456 Message-ID: Date: Fri, 22 May 2026 10:14:55 +0000 --ruf-boundary-- """ def test_detects_forensic_report_email(): msg = email.message_from_bytes(SAMPLE_FORENSIC_EMAIL) assert ForensicParser.is_forensic_report(msg) is True def test_parse_forensic_email_redacts_and_extracts_failure_fields(): parsed = ForensicParser.parse_bytes(SAMPLE_FORENSIC_EMAIL) assert parsed["report_id"].startswith("ruf-") assert parsed["reported_domain"] == "example.com" assert parsed["source_ip"] == "203.0.113.8" assert parsed["auth_failure"] == "dkim" assert parsed["delivery_result"] == "reject" assert parsed["arrival_date"].year == 2026 assert parsed["original_mail_from"] == "al***@example.com" assert "al***@example.com" in parsed["original_from"] assert "bo***@example.net" in parsed["original_to"] assert "[redacted-token]" in parsed["original_subject"] assert parsed["original_message_id"] assert "original-message@example.com" not in parsed["original_message_id"] def test_parse_forensic_email_supports_stricter_redaction_policies(): domain_only = ForensicParser.parse_bytes( SAMPLE_FORENSIC_EMAIL, redaction_policy=ForensicRedactionPolicy(mode="domain_only"), ) strict = ForensicParser.parse_bytes( SAMPLE_FORENSIC_EMAIL, redaction_policy=ForensicRedactionPolicy(mode="strict"), ) token_visible = ForensicParser.parse_bytes( SAMPLE_FORENSIC_EMAIL, redaction_policy=ForensicRedactionPolicy(redact_long_tokens=False), ) assert domain_only["original_mail_from"] == "***@example.com" assert "***@example.com" in domain_only["original_from"] assert domain_only["source_email"] == "DMARC Reporter <***@example.net>" assert strict["original_mail_from"] == "[redacted-email]" assert strict["source_email"] == "DMARC Reporter <[redacted-email]>" assert "abcdefghijklmnopqrstuvwxyz123456" in token_visible["original_subject"] def test_non_forensic_email_is_rejected(): content = b"From: sender@example.com\r\nSubject: hello\r\n\r\nplain email" with pytest.raises(ValueError, match="forensic"): ForensicParser.parse_bytes(content) def test_parse_forensic_email_uses_explicit_message_hint(): parsed = ForensicParser.parse_bytes(SAMPLE_FORENSIC_EMAIL, message_id_hint="gmail-message-1") assert parsed["report_id"] == "ruf-gmail-message-1" def test_parse_forensic_email_handles_invalid_dates(): content = SAMPLE_FORENSIC_EMAIL.replace( b"Arrival-Date: Fri, 22 May 2026 10:15:00 +0000", b"Arrival-Date: not a real date", ) parsed = ForensicParser.parse_bytes(content) assert parsed["arrival_date"] is None def test_parse_forensic_email_normalizes_offset_dates_to_utc(): content = SAMPLE_FORENSIC_EMAIL.replace( b"Arrival-Date: Fri, 22 May 2026 10:15:00 +0000", b"Arrival-Date: Fri, 22 May 2026 12:15:00 +0200", ) parsed = ForensicParser.parse_bytes(content) assert parsed["arrival_date"].hour == 10 assert parsed["arrival_date"].tzinfo is None def test_parse_forensic_email_falls_back_to_dkim_domain_and_content_hash(): content = SAMPLE_FORENSIC_EMAIL.replace(b"Message-ID: \n", b"") content = content.replace(b"Reported-Domain: example.com\n", b"DKIM-Domain: fallback.test\n") content = content.replace(b"Message-ID: \n", b"") parsed = ForensicParser.parse_bytes(content) assert parsed["reported_domain"] == "fallback.test" assert parsed["report_id"].startswith("ruf-") assert parsed["original_message_id"] == "" assert parsed["feedback_headers"] def test_parse_forensic_email_extracts_message_rfc822_headers(): content = b"""\ From: DMARC Reporter Subject: DMARC forensic report MIME-Version: 1.0 Content-Type: multipart/report; report-type=feedback-report; boundary="ruf-boundary" --ruf-boundary Content-Type: message/feedback-report Feedback-Type: auth-failure Original-Mail-From: sender@fallback.test Source-IP: 203.0.113.9 --ruf-boundary Content-Type: message/rfc822 From: Carol To: Dave Subject: Forwarded failure sample Message-ID: body is ignored --ruf-boundary-- """ parsed = ForensicParser.parse_bytes(content) assert parsed["reported_domain"] == "fallback.test" assert "ca***@fallback.test" in parsed["original_from"] assert parsed["original_subject"] == "Forwarded failure sample" def test_is_forensic_report_detects_headers_and_subject_fallbacks(): header_only = email.message_from_bytes( b"""\ Subject: Delivery report MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="b" --b Content-Type: text/rfc822-headers Authentication-Results: mx; dmarc=fail --b-- """ ) subject_only = email.message_from_bytes(b"Subject: DMARC RUF failure\r\n\r\nbody") assert ForensicParser.is_forensic_report(header_only) is True assert ForensicParser.is_forensic_report(subject_only) is True def test_parse_forensic_email_rejects_empty_and_oversized_payloads(): with pytest.raises(ValueError, match="empty"): ForensicParser.parse_bytes(b"") with pytest.raises(ValueError, match="too large"): ForensicParser.parse_bytes(b"x" * (MAX_FORENSIC_REPORT_SIZE + 1)) def test_forensic_parser_helper_fallbacks(monkeypatch): multipart = email.message_from_bytes( b"Subject: container\r\nContent-Type: multipart/mixed; boundary=x\r\n\r\n--x--" ) message = email.message_from_bytes(b"Subject: plain\r\n\r\nbody") assert _coerce_text(b"hello") == "hello" assert _payload_text(multipart) == "" assert _message_part_payload(message) is None assert _domain_from_address("not-an-address") == "" part = MagicMock() part.get_payload.side_effect = [None, ["nested"]] assert _payload_text(part) == "" part = MagicMock() part.get_payload.side_effect = [None, "plain text"] assert _payload_text(part) == "plain text" monkeypatch.setattr(forensic_parser_module, "parsedate_to_datetime", lambda _value: None) assert forensic_parser_module._parse_datetime("Fri, 22 May 2026 10:15:00 +0000") is None def test_is_forensic_report_detects_feedback_part_without_report_container(): msg = email.message_from_bytes( b"""\ Subject: Delivery notice MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="b" --b Content-Type: message/feedback-report Feedback-Type: auth-failure --b-- """ ) assert ForensicParser.is_forensic_report(msg) is True def test_is_forensic_report_ignores_non_dmarc_header_parts(): msg = email.message_from_bytes( b"""\ Subject: DMARC forensic notice MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="b" --b Content-Type: text/rfc822-headers Authentication-Results: mx; spf=pass --b-- """ ) assert ForensicParser.is_forensic_report(msg) is True