""" Tests for IMAPClient service. Covers connection testing, mailbox listing, email processing, attachment parsing, and report fetching with mocked IMAP connections. """ import email import imaplib from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from io import BytesIO from types import SimpleNamespace from unittest.mock import MagicMock, patch from zipfile import ZipFile import pytest from app.models.report import DMARCReport, ForensicReport from app.models.setting import Setting from app.services.imap_client import IMAPClient from app.services.report_store import ReportStore from app.tests.test_forensic_parser import SAMPLE_FORENSIC_EMAIL # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- MINIMAL_DMARC_XML = b"""\ Test Org noreply@example.com abc-123 1609459200 1609545600 example.com r r

none

none 100
1.2.3.4 1 none pass pass example.com example.com pass example.com pass
""" def _make_zip_content(xml_bytes: bytes, filename: str = "report.xml") -> bytes: buf = BytesIO() with ZipFile(buf, "w") as zf: zf.writestr(filename, xml_bytes) return buf.getvalue() def _make_email_with_attachment( filename: str = "dmarc-report.xml", content: bytes = MINIMAL_DMARC_XML, content_type: str = "application/xml", subject: str = "DMARC Report", from_addr: str = "noreply@example.com", ) -> bytes: msg = MIMEMultipart() msg["Subject"] = subject msg["From"] = from_addr msg.attach(MIMEText("DMARC report attached.")) part = MIMEApplication(content, Name=filename) part["Content-Disposition"] = f'attachment; filename="{filename}"' part.set_type(content_type) msg.attach(part) return msg.as_bytes() # --------------------------------------------------------------------------- # TestIMAPClientInit # --------------------------------------------------------------------------- class TestIMAPClientInit: def test_default_construction_with_missing_credentials(self): """Client can be instantiated even without configured settings.""" with patch("app.services.imap_client.get_settings") as mock_settings: mock_settings.return_value = MagicMock( IMAP_SERVER=None, IMAP_PORT=993, IMAP_USERNAME=None, IMAP_PASSWORD=None, ) client = IMAPClient() assert client.server is None assert client.username is None def test_explicit_credentials_override_settings(self): """Explicit constructor arguments take precedence over settings.""" with patch("app.services.imap_client.get_settings") as mock_settings: mock_settings.return_value = MagicMock( IMAP_SERVER="default.example.com", IMAP_PORT=993, IMAP_USERNAME="default@example.com", IMAP_PASSWORD="default-password", ) client = IMAPClient( server="custom.example.com", port=143, username="user@example.com", password="secret", delete_emails=True, ) assert client.server == "custom.example.com" assert client.port == 143 assert client.username == "user@example.com" assert client.password == "secret" assert client.delete_emails is True def test_delete_emails_defaults_to_settings(self): settings = SimpleNamespace( IMAP_SERVER="imap.example.com", IMAP_PORT=993, IMAP_USERNAME="u", IMAP_PASSWORD="p", DELETE_IMPORTED_EMAILS=True, ) with patch("app.services.imap_client.get_settings", return_value=settings): client = IMAPClient() assert client.delete_emails is True def test_folder_uses_explicit_value_or_settings_default(self): """Folder defaults to settings and can be overridden explicitly.""" settings = SimpleNamespace( IMAP_SERVER="imap.example.com", IMAP_PORT=993, IMAP_USERNAME="u", IMAP_PASSWORD="p", IMAP_FOLDER="Archive", ) with patch("app.services.imap_client.get_settings", return_value=settings): assert IMAPClient().folder == "Archive" assert IMAPClient(folder="Junk Mail").folder == "Junk Mail" def test_report_store_assigned(self): """IMAPClient stores a reference to the ReportStore singleton.""" with patch("app.services.imap_client.get_settings") as mock_settings: mock_settings.return_value = MagicMock( IMAP_SERVER="imap.example.com", IMAP_PORT=993, IMAP_USERNAME="u", IMAP_PASSWORD="p", ) client = IMAPClient() assert client.report_store is ReportStore.get_instance() # --------------------------------------------------------------------------- # TestListMailboxes # --------------------------------------------------------------------------- class TestListMailboxes: def _make_client(self): with patch("app.services.imap_client.get_settings") as mock_settings: mock_settings.return_value = MagicMock( IMAP_SERVER="imap.example.com", IMAP_PORT=993, IMAP_USERNAME="u", IMAP_PASSWORD="p", ) return IMAPClient() def test_parses_standard_mailbox_entry(self): client = self._make_client() raw = [b'(\\HasNoChildren) "/" INBOX'] result = client._list_mailboxes(raw) assert "INBOX" in result def test_skips_non_bytes_entries(self): client = self._make_client() result = client._list_mailboxes(["not bytes", None]) # type: ignore[list-item] assert result == [] def test_handles_malformed_bytes(self): client = self._make_client() # bytes that can't be decoded normally should be silently skipped result = client._list_mailboxes([b"short"]) # Should not raise; may return empty or partial result assert isinstance(result, list) def test_multiple_mailboxes(self): client = self._make_client() raw = [ b'(\\HasNoChildren) "/" INBOX', b'(\\HasNoChildren) "/" Sent', b'(\\HasNoChildren) "/" Trash', ] result = client._list_mailboxes(raw) assert len(result) == 3 # --------------------------------------------------------------------------- # TestTestConnection # --------------------------------------------------------------------------- class TestTestConnection: def _make_client(self, server="imap.example.com", username="u", password="p"): with patch("app.services.imap_client.get_settings") as mock_settings: mock_settings.return_value = MagicMock( IMAP_SERVER=server, IMAP_PORT=993, IMAP_USERNAME=username, IMAP_PASSWORD=password, DELETE_IMPORTED_EMAILS=False, ) return IMAPClient() def test_returns_false_when_missing_credentials(self): with patch("app.services.imap_client.get_settings") as mock_settings: mock_settings.return_value = MagicMock( IMAP_SERVER=None, IMAP_PORT=993, IMAP_USERNAME=None, IMAP_PASSWORD=None, DELETE_IMPORTED_EMAILS=False, ) client = IMAPClient() success, message, stats = client.test_connection() assert success is False assert "not fully configured" in message assert stats["diagnostic_detail"] == "missing server, username, or password" def test_successful_connection(self): client = self._make_client() mock_mail = MagicMock() mock_mail.login.return_value = None mock_mail.list.return_value = ("OK", [b'(\\HasNoChildren) "/" INBOX']) mock_mail.select.return_value = ("OK", [b"10"]) mock_mail.search.return_value = ("OK", [b"1 2 3"]) with patch("imaplib.IMAP4_SSL", return_value=mock_mail): success, message, stats = client.test_connection() assert success is True assert "successful" in message.lower() assert stats["message_count"] == 10 assert "INBOX" in stats["available_mailboxes"] def test_connection_selects_configured_folder_with_quotes(self): client = IMAPClient( server="imap.example.com", port=993, username="u", password="p", folder="Junk Mail", ) mock_mail = MagicMock() mock_mail.login.return_value = None mock_mail.list.return_value = ("OK", []) mock_mail.select.return_value = ("OK", [b"0"]) mock_mail.search.return_value = ("OK", [b""]) with patch("imaplib.IMAP4_SSL", return_value=mock_mail): success, _, _ = client.test_connection() assert success is True mock_mail.select.assert_called_once_with('"Junk Mail"') def test_connection_exception_returns_false(self): client = self._make_client() with patch("imaplib.IMAP4_SSL", side_effect=ConnectionRefusedError("refused")): success, message, stats = client.test_connection() assert success is False assert "IMAP server" in message assert stats["diagnostic_detail"] == "refused" def test_list_status_not_ok_returns_empty_mailboxes(self): client = self._make_client() mock_mail = MagicMock() mock_mail.login.return_value = None mock_mail.list.return_value = ("NO", []) mock_mail.select.return_value = ("OK", [b"0"]) mock_mail.search.return_value = ("OK", [b""]) with patch("imaplib.IMAP4_SSL", return_value=mock_mail): success, message, stats = client.test_connection() assert success is True assert stats["available_mailboxes"] == [] def test_select_not_ok_returns_mailbox_diagnostic(self): client = self._make_client() mock_mail = MagicMock() mock_mail.login.return_value = None mock_mail.list.return_value = ("OK", [b'(\\HasNoChildren) "/" INBOX']) mock_mail.select.return_value = ("NO", []) mock_mail.search.return_value = ("OK", [b""]) with patch("imaplib.IMAP4_SSL", return_value=mock_mail): success, message, stats = client.test_connection() assert success is False assert "folder" in message assert stats["available_mailboxes"] == ["INBOX"] assert "select failed" in stats["diagnostic_detail"] # --------------------------------------------------------------------------- # TestIsDmarcReportEmail # --------------------------------------------------------------------------- class TestIsDmarcReportEmail: def _make_client(self): with patch("app.services.imap_client.get_settings") as mock_settings: mock_settings.return_value = MagicMock( IMAP_SERVER="imap.example.com", IMAP_PORT=993, IMAP_USERNAME="u", IMAP_PASSWORD="p", ) return IMAPClient() def _make_msg(self, subject="", from_addr="", has_xml_attachment=False): msg = MIMEMultipart() msg["Subject"] = subject msg["From"] = from_addr if has_xml_attachment: part = MIMEApplication(b"", Name="report.xml") part["Content-Disposition"] = 'attachment; filename="report.xml"' msg.attach(part) return msg def test_dmarc_keyword_in_subject(self): client = self._make_client() msg = self._make_msg(subject="DMARC Aggregate Report for example.com") assert client._is_dmarc_report_email(msg) is True def test_no_keywords_no_attachments(self): client = self._make_client() msg = self._make_msg(subject="Hello World", from_addr="friend@example.com") assert client._is_dmarc_report_email(msg) is False def test_dmarc_sender_matches(self): client = self._make_client() msg = self._make_msg(subject="Weekly report", from_addr="noreply@google.com") assert client._is_dmarc_report_email(msg) is True def test_xml_attachment_matches(self): client = self._make_client() msg = self._make_msg(has_xml_attachment=True) assert client._is_dmarc_report_email(msg) is True # --------------------------------------------------------------------------- # TestDecodeEmailHeader # --------------------------------------------------------------------------- class TestDecodeEmailHeader: def _make_client(self): with patch("app.services.imap_client.get_settings") as mock_settings: mock_settings.return_value = MagicMock( IMAP_SERVER="imap.example.com", IMAP_PORT=993, IMAP_USERNAME="u", IMAP_PASSWORD="p", ) return IMAPClient() def test_plain_ascii_header(self): client = self._make_client() assert client._decode_email_header("Hello World") == "Hello World" def test_encoded_utf8_header(self): client = self._make_client() # "=?utf-8?b?..." encoded header encoded = "=?utf-8?b?RFNIQVJDIG9yZyBuYW1l?=" result = client._decode_email_header(encoded) assert isinstance(result, str) # --------------------------------------------------------------------------- # TestHasDmarcAttachments # --------------------------------------------------------------------------- class TestHasDmarcAttachments: def _make_client(self): with patch("app.services.imap_client.get_settings") as mock_settings: mock_settings.return_value = MagicMock( IMAP_SERVER="imap.example.com", IMAP_PORT=993, IMAP_USERNAME="u", IMAP_PASSWORD="p", ) return IMAPClient() @pytest.mark.parametrize( "filename", ["report.xml", "dmarc.zip", "report.gz", "report.gzip"], ) def test_dmarc_filename_extensions(self, filename): client = self._make_client() msg = MIMEMultipart() part = MIMEApplication(b"data", Name=filename) part["Content-Disposition"] = f'attachment; filename="{filename}"' msg.attach(part) assert client._has_dmarc_attachments(msg) is True @pytest.mark.parametrize( "content_type", [ "application/zip", "application/gzip", "application/x-gzip", "application/xml", "text/xml", ], ) def test_dmarc_content_types(self, content_type): client = self._make_client() msg = MIMEMultipart() part = MIMEApplication(b"data") part["Content-Disposition"] = "attachment" part.set_type(content_type) msg.attach(part) assert client._has_dmarc_attachments(msg) is True def test_no_attachments_returns_false(self): client = self._make_client() msg = MIMEText("plain text body") assert client._has_dmarc_attachments(msg) is False # --------------------------------------------------------------------------- # TestProcessAttachments # --------------------------------------------------------------------------- class TestProcessAttachments: def _make_client(self, db=None): with patch("app.services.imap_client.get_settings") as mock_settings: mock_settings.return_value = MagicMock( IMAP_SERVER="imap.example.com", IMAP_PORT=993, IMAP_USERNAME="u", IMAP_PASSWORD="p", ) return IMAPClient(db=db) def test_processes_xml_attachment(self): client = self._make_client() msg = email.message_from_bytes( _make_email_with_attachment("report.xml", MINIMAL_DMARC_XML, "application/xml") ) stats = {"processed": 0, "reports_found": 0, "errors": []} count = client._process_attachments(msg, stats, message_id="1") assert count == 1 assert stats["details"][0]["status"] == "imported" assert stats["details"][0]["message_id"] == "1" assert stats["details"][0]["report_id"] == "abc-123" def test_processes_zip_attachment(self): client = self._make_client() zip_content = _make_zip_content(MINIMAL_DMARC_XML, "report.xml") msg = email.message_from_bytes( _make_email_with_attachment("report.zip", zip_content, "application/zip") ) count = client._process_attachments(msg) assert count == 1 def test_processes_xml_attachment_persists_report(self, db_session): client = self._make_client(db=db_session) msg = email.message_from_bytes( _make_email_with_attachment("report.xml", MINIMAL_DMARC_XML, "application/xml") ) count = client._process_attachments(msg) assert count == 1 assert db_session.query(DMARCReport).filter_by(report_id="abc-123").count() == 1 def test_duplicate_report_adds_detail(self): client = self._make_client() msg = email.message_from_bytes( _make_email_with_attachment("report.xml", MINIMAL_DMARC_XML, "application/xml") ) first_stats = {"processed": 0, "reports_found": 0, "errors": []} second_stats = {"processed": 0, "reports_found": 0, "errors": []} assert client._process_attachments(msg, first_stats) == 1 assert client._process_attachments(msg, second_stats) == 0 assert second_stats["details"][0]["status"] == "duplicate" assert second_stats["details"][0]["report_id"] == "abc-123" def test_bad_attachment_does_not_raise(self): client = self._make_client() msg = email.message_from_bytes(_make_email_with_attachment("report.xml", b"not xml at all")) # Should not raise; just returns 0 stats = {"processed": 0, "reports_found": 0, "errors": []} count = client._process_attachments(msg, stats) assert count == 0 assert stats["errors"] assert stats["details"][0]["status"] == "error" assert stats["details"][0]["filename"] == "report.xml" def test_empty_attachment_adds_detail(self): client = self._make_client() msg = email.message_from_bytes(_make_email_with_attachment("report.xml", b"")) stats = {"processed": 0, "reports_found": 0, "errors": []} count = client._process_attachments(msg, stats) assert count == 0 assert stats["details"][0]["status"] == "skipped" assert stats["details"][0]["reason"] == "empty_attachment" def test_unsupported_attachment_adds_detail(self): client = self._make_client() msg = email.message_from_bytes( _make_email_with_attachment("notes.txt", b"not xml", "text/plain") ) stats = {"processed": 0, "reports_found": 0, "errors": []} count = client._process_attachments(msg, stats, message_id="2") assert count == 0 assert stats["details"] == [ { "status": "skipped", "reason": "unsupported_attachment", "message_id": "2", "filename": "notes.txt", } ] def test_attachment_without_filename_is_skipped(self): client = self._make_client() msg = MIMEMultipart() msg.attach(MIMEText("body")) part = MIMEApplication(MINIMAL_DMARC_XML) part["Content-Disposition"] = "attachment" msg.attach(part) stats = {"processed": 0, "reports_found": 0, "errors": []} count = client._process_attachments(msg, stats) assert count == 0 assert stats.get("details") is None def test_no_attachments_returns_zero(self): client = self._make_client() msg = MIMEText("Just text, no attachments.") count = client._process_attachments(msg) assert count == 0 # --------------------------------------------------------------------------- # TestProcessSingleEmail # --------------------------------------------------------------------------- class TestProcessSingleEmail: def _make_client(self, db=None): with patch("app.services.imap_client.get_settings") as mock_settings: mock_settings.return_value = MagicMock( IMAP_SERVER="imap.example.com", IMAP_PORT=993, IMAP_USERNAME="u", IMAP_PASSWORD="p", ) return IMAPClient(db=db) def test_processes_valid_dmarc_email(self): client = self._make_client() raw = _make_email_with_attachment( "report.xml", MINIMAL_DMARC_XML, "application/xml", subject="DMARC Report", ) mock_mail = MagicMock() mock_mail.fetch.return_value = ("OK", [(b"1", raw)]) mock_mail.store.return_value = ("OK", None) stats = {"processed": 0, "reports_found": 0, "errors": []} client._process_single_email(mock_mail, b"1", stats) assert stats["processed"] == 1 assert stats["reports_found"] == 1 assert stats["details"][0]["status"] == "imported" def test_processes_forensic_report_without_aggregate_count(self, db_session): ReportStore.get_instance().clear() client = self._make_client(db=db_session) mock_mail = MagicMock() mock_mail.fetch.return_value = ("OK", [(b"1", SAMPLE_FORENSIC_EMAIL)]) mock_mail.store.return_value = ("OK", None) stats = { "processed": 0, "reports_found": 0, "forensic_reports_found": 0, "deleted": 0, "errors": [], } client._process_single_email(mock_mail, b"1", stats) assert stats["processed"] == 1 assert stats["reports_found"] == 0 assert stats["forensic_reports_found"] == 1 assert stats["details"][0]["reason"] == "forensic_report" assert db_session.query(DMARCReport).count() == 0 assert db_session.query(ForensicReport).count() == 1 assert ReportStore.get_instance().get_domains() == [] def test_forensic_report_uses_configured_redaction_policy(self, db_session): setting = ( db_session.query(Setting).filter(Setting.key == "forensics.redaction_mode").first() ) if setting is None: setting = Setting( key="forensics.redaction_mode", value_type="string", category="forensics", ) db_session.add(setting) setting.value = "domain_only" db_session.commit() client = self._make_client(db=db_session) stats = {"forensic_reports_found": 0, "duplicate_forensic_reports": 0, "errors": []} assert client._process_forensic_email(SAMPLE_FORENSIC_EMAIL, stats=stats, message_id="1") report = db_session.query(ForensicReport).one() assert report.original_mail_from == "***@example.com" def test_forensic_report_without_database_is_skipped(self): client = self._make_client() stats = {"processed": 0, "reports_found": 0, "errors": []} imported = client._process_forensic_email( SAMPLE_FORENSIC_EMAIL, stats=stats, message_id="msg-1", ) assert imported is False assert stats["details"][0]["reason"] == "forensic_report_requires_database" def test_duplicate_forensic_report_adds_detail(self, db_session): client = self._make_client(db=db_session) stats = {"forensic_reports_found": 0, "duplicate_forensic_reports": 0, "errors": []} assert client._process_forensic_email(SAMPLE_FORENSIC_EMAIL, stats=stats, message_id="1") imported = client._process_forensic_email( SAMPLE_FORENSIC_EMAIL, stats=stats, message_id="2" ) assert imported is False assert stats["duplicate_forensic_reports"] == 1 assert stats["details"][1]["status"] == "duplicate" def test_forensic_save_duplicate_result_adds_detail(self, db_session): client = self._make_client(db=db_session) stats = {"forensic_reports_found": 0, "duplicate_forensic_reports": 0, "errors": []} with ( patch("app.services.imap_client.forensic_report_exists", return_value=False), patch("app.services.imap_client.save_forensic_report", return_value=(None, False)), ): imported = client._process_forensic_email( SAMPLE_FORENSIC_EMAIL, stats=stats, message_id="msg-1", ) assert imported is False assert stats["duplicate_forensic_reports"] == 1 assert stats["details"][0]["status"] == "duplicate" def test_forensic_parse_error_adds_error_detail(self, db_session): client = self._make_client(db=db_session) stats = {"errors": []} imported = client._process_forensic_email( b"Subject: not forensic\r\n\r\nbody", stats=stats, message_id="bad", ) assert imported is False assert stats["errors"] assert stats["details"][0]["reason"] == "forensic_parse_failed" def test_fetch_error_skips_email(self): client = self._make_client() mock_mail = MagicMock() mock_mail.fetch.return_value = ("NO", []) stats = {"processed": 0, "reports_found": 0, "errors": []} client._process_single_email(mock_mail, b"1", stats) assert stats["processed"] == 0 assert stats["details"][0]["status"] == "error" assert stats["details"][0]["reason"] == "message_fetch_failed" def test_exception_adds_to_errors(self): client = self._make_client() mock_mail = MagicMock() mock_mail.fetch.side_effect = RuntimeError("unexpected error") stats = {"processed": 0, "reports_found": 0, "errors": []} client._process_single_email(mock_mail, b"1", stats) assert len(stats["errors"]) == 1 assert stats["details"][0]["reason"] == "message_processing_failed" def test_marks_deleted_when_flag_set(self): client = self._make_client() client.delete_emails = True raw = _make_email_with_attachment( "report.xml", MINIMAL_DMARC_XML, "application/xml", subject="DMARC Report", ) mock_mail = MagicMock() mock_mail.fetch.return_value = ("OK", [(b"1", raw)]) mock_mail.store.return_value = ("OK", None) stats = {"processed": 0, "reports_found": 0, "deleted": 0, "errors": []} client._process_single_email(mock_mail, b"1", stats) # store should have been called twice: once for \\Seen, once for \\Deleted assert mock_mail.store.call_count >= 2 assert stats["deleted"] == 1 def test_does_not_delete_when_no_report_imported(self): client = self._make_client() client.delete_emails = True raw = _make_email_with_attachment( "not-a-report.txt", b"not a report", "text/plain", subject="DMARC Report", ) mock_mail = MagicMock() mock_mail.fetch.return_value = ("OK", [(b"1", raw)]) mock_mail.store.return_value = ("OK", None) stats = {"processed": 0, "reports_found": 0, "deleted": 0, "errors": []} client._process_single_email(mock_mail, b"1", stats) mock_mail.store.assert_called_once_with(b"1", "+FLAGS", "\\Seen") assert stats["deleted"] == 0 # --------------------------------------------------------------------------- # TestFetchReports # --------------------------------------------------------------------------- class TestFetchReports: def _make_client(self, server="imap.example.com", username="u", password="p"): with patch("app.services.imap_client.get_settings") as mock_settings: mock_settings.return_value = MagicMock( IMAP_SERVER=server, IMAP_PORT=993, IMAP_USERNAME=username, IMAP_PASSWORD=password, ) return IMAPClient() def test_returns_error_when_no_credentials(self): with patch("app.services.imap_client.get_settings") as mock_settings: mock_settings.return_value = MagicMock( IMAP_SERVER=None, IMAP_PORT=993, IMAP_USERNAME=None, IMAP_PASSWORD=None, ) client = IMAPClient() result = client.fetch_reports() assert result["success"] is False def test_search_failure_returns_error(self): client = self._make_client() mock_mail = MagicMock() mock_mail.login.return_value = None mock_mail.select.return_value = ("OK", [b"0"]) mock_mail.search.return_value = ("NO", []) with patch("imaplib.IMAP4_SSL", return_value=mock_mail): result = client.fetch_reports(days=7) assert result["success"] is False def test_fetch_reports_selects_configured_folder_with_quotes(self): client = IMAPClient( server="imap.example.com", port=993, username="u", password="p", folder="Junk Mail", ) mock_mail = MagicMock() mock_mail.login.return_value = None mock_mail.select.return_value = ("OK", [b"0"]) mock_mail.search.return_value = ("OK", [b""]) mock_mail.logout.return_value = None with patch("imaplib.IMAP4_SSL", return_value=mock_mail): result = client.fetch_reports(days=7) assert result["success"] is True mock_mail.select.assert_called_once_with('"Junk Mail"') def test_successful_fetch_with_email(self): client = self._make_client() raw = _make_email_with_attachment( "report.xml", MINIMAL_DMARC_XML, "application/xml", subject="DMARC Report", ) mock_mail = MagicMock() mock_mail.login.return_value = None mock_mail.select.return_value = ("OK", [b"1"]) mock_mail.search.return_value = ("OK", [b"1"]) mock_mail.fetch.return_value = ("OK", [(b"1", raw)]) mock_mail.store.return_value = ("OK", None) mock_mail.logout.return_value = None with patch("imaplib.IMAP4_SSL", return_value=mock_mail): result = client.fetch_reports(days=7) assert result["success"] is True assert result["reports_found"] >= 1 assert result["details"][0]["status"] == "imported" def test_connection_error_returns_failure(self): client = self._make_client() with patch("imaplib.IMAP4_SSL", side_effect=imaplib.IMAP4.error("connection error")): result = client.fetch_reports(days=7) assert result["success"] is False def test_delete_emails_calls_expunge(self): client = self._make_client() client.delete_emails = True mock_mail = MagicMock() mock_mail.login.return_value = None mock_mail.select.return_value = ("OK", [b"0"]) mock_mail.search.return_value = ("OK", [b"1"]) mock_mail.fetch.return_value = ( "OK", [ ( b"1", _make_email_with_attachment( "report.xml", MINIMAL_DMARC_XML, "application/xml", subject="DMARC Report", ), ) ], ) mock_mail.store.return_value = ("OK", None) mock_mail.logout.return_value = None with patch("imaplib.IMAP4_SSL", return_value=mock_mail): result = client.fetch_reports(days=3) mock_mail.expunge.assert_called_once() assert result["success"] is True assert result["deleted"] == 1 def test_delete_emails_skips_expunge_when_nothing_deleted(self): client = self._make_client() client.delete_emails = True mock_mail = MagicMock() mock_mail.login.return_value = None mock_mail.select.return_value = ("OK", [b"0"]) mock_mail.search.return_value = ("OK", [b""]) mock_mail.logout.return_value = None with patch("imaplib.IMAP4_SSL", return_value=mock_mail): result = client.fetch_reports(days=3) mock_mail.expunge.assert_not_called() assert result["success"] is True assert result["deleted"] == 0