feat: start mailbox evidence scanner

This commit is contained in:
2026-06-02 01:19:09 +02:00
parent 8292ffe41d
commit 8532583182
26 changed files with 1733 additions and 18 deletions

12
tests/fixtures/mailbox/hard_bounce.eml vendored Normal file
View File

@@ -0,0 +1,12 @@
From: Mail Delivery Subsystem <mailer-daemon@example.net>
To: sender@example.com
Subject: Delivery Status Notification (Failure)
Date: Tue, 02 Jun 2026 10:00:00 +0000
Message-ID: <hard-bounce@example.net>
Content-Type: text/plain; charset=utf-8
Delivery failure.
Final-Recipient: rfc822; missing@example.com
Action: failed
Status: 5.1.1
Diagnostic-Code: smtp; 550 5.1.1 User unknown

View File

@@ -0,0 +1,8 @@
From: Recipient <recipient@example.com>
To: sender@example.com
Subject: Re: Your notification
Date: Tue, 02 Jun 2026 10:03:00 +0000
Message-ID: <reply@example.com>
Content-Type: text/plain; charset=utf-8
Thanks, I received this and will review it today.

View File

@@ -0,0 +1,9 @@
From: Recipient <recipient@example.com>
To: sender@example.com
Subject: Auto-reply: Out of office
Date: Tue, 02 Jun 2026 10:02:00 +0000
Message-ID: <ooo@example.com>
Auto-Submitted: auto-replied
Content-Type: text/plain; charset=utf-8
I am out of office until next week.

12
tests/fixtures/mailbox/soft_bounce.eml vendored Normal file
View File

@@ -0,0 +1,12 @@
From: Mail Delivery Subsystem <mailer-daemon@example.net>
To: sender@example.com
Subject: Delivery temporarily delayed
Date: Tue, 02 Jun 2026 10:01:00 +0000
Message-ID: <soft-bounce@example.net>
Content-Type: text/plain; charset=utf-8
Delivery Status Notification.
Final-Recipient: rfc822; full@example.com
Action: delayed
Status: 4.2.2
Diagnostic-Code: smtp; 452 4.2.2 Mailbox full

43
tests/test_parser.py Normal file
View File

@@ -0,0 +1,43 @@
from __future__ import annotations
import unittest
from pathlib import Path
from email_connect.models import MessageClass
from email_connect.parser import parse_message_file
FIXTURES = Path(__file__).parent / "fixtures" / "mailbox"
class ParserTests(unittest.TestCase):
def test_hard_bounce_maps_to_permanent_rejection(self) -> None:
_inbound, parsed, candidate = parse_message_file(FIXTURES / "hard_bounce.eml", mailbox_id="test")
self.assertEqual(parsed.message_class, MessageClass.HARD_BOUNCE)
self.assertIsNotNone(candidate)
self.assertEqual(candidate.event_type, "notification.endpoint.rejected_permanent")
self.assertEqual(candidate.assessment_subclass, "fail.hard_bounce")
def test_soft_bounce_maps_to_temporary_rejection(self) -> None:
_inbound, parsed, candidate = parse_message_file(FIXTURES / "soft_bounce.eml", mailbox_id="test")
self.assertEqual(parsed.message_class, MessageClass.SOFT_BOUNCE)
self.assertIsNotNone(candidate)
self.assertEqual(candidate.event_type, "notification.endpoint.rejected_temporary")
def test_out_of_office_stays_undef(self) -> None:
_inbound, parsed, candidate = parse_message_file(FIXTURES / "out_of_office.eml", mailbox_id="test")
self.assertEqual(parsed.message_class, MessageClass.OUT_OF_OFFICE)
self.assertIsNotNone(candidate)
self.assertEqual(candidate.assessment_category.value, "undef")
self.assertEqual(candidate.event_type, "interaction.out_of_office_received")
def test_human_reply_is_email_channel_success_only(self) -> None:
_inbound, parsed, candidate = parse_message_file(FIXTURES / "human_reply.eml", mailbox_id="test")
self.assertEqual(parsed.message_class, MessageClass.HUMAN_REPLY)
self.assertIsNotNone(candidate)
self.assertEqual(candidate.event_type, "interaction.reply_received")
self.assertEqual(candidate.assessment_subclass, "success.reply_received")
if __name__ == "__main__":
unittest.main()

37
tests/test_scanner.py Normal file
View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from email_connect.config import AppConfig, MailboxConfig, ReportsConfig, ScanConfig, SourceConfig, StorageConfig
from email_connect.scanner import scan_mailbox
FIXTURES = Path(__file__).parent / "fixtures" / "mailbox"
class ScannerTests(unittest.TestCase):
def test_scan_fixture_directory_writes_report_and_deduplicates(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config = AppConfig(
mailbox=MailboxConfig(id="test-mailbox", protocol="fixture"),
scan=ScanConfig(),
storage=StorageConfig(path=str(root / "state.sqlite")),
reports=ReportsConfig(output_dir=str(root / "reports")),
source=SourceConfig(fixture_dir=str(FIXTURES)),
)
first = scan_mailbox(config)
second = scan_mailbox(config)
self.assertEqual(first.scan.messages_seen, 4)
self.assertEqual(first.scan.messages_new, 4)
self.assertGreaterEqual(first.scan.evidence_events_created, 4)
self.assertEqual(second.scan.messages_new, 0)
self.assertEqual(second.scan.evidence_events_created, 0)
self.assertTrue(first.report_path and first.report_path.exists())
if __name__ == "__main__":
unittest.main()