From bc0a64c108f929558acd2f2da7baf23c8f87ef21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 20:02:04 +0000 Subject: [PATCH 1/2] Initial plan From f626675609e1332e791687f8a444021410f03175 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 20:06:11 +0000 Subject: [PATCH 2/2] fix: honor configured IMAP source folder --- .../app/api/api_v1/endpoints/mail_sources.py | 2 + backend/app/core/config.py | 1 + backend/app/main.py | 2 + backend/app/services/imap_client.py | 16 ++++-- backend/app/tests/test_imap_client.py | 54 +++++++++++++++++++ backend/app/tests/test_main_polling.py | 42 +++++++++++++++ 6 files changed, 114 insertions(+), 3 deletions(-) diff --git a/backend/app/api/api_v1/endpoints/mail_sources.py b/backend/app/api/api_v1/endpoints/mail_sources.py index 785b093..667e2dc 100644 --- a/backend/app/api/api_v1/endpoints/mail_sources.py +++ b/backend/app/api/api_v1/endpoints/mail_sources.py @@ -282,6 +282,7 @@ def _fetch_imap_source(source: MailSource, db: Session, days: int) -> Dict[str, username=source.username, password=source.password, delete_emails=False, + folder=source.folder, db=db, ) started_at = datetime.utcnow() @@ -520,6 +521,7 @@ async def test_stored_mail_source( port=source.port or 993, username=source.username, password=source.password, + folder=source.folder, ) success, message, stats = imap_client.test_connection() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 93ee9a9..49e9049 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -40,6 +40,7 @@ class Settings(BaseSettings): IMAP_PORT: int = 993 IMAP_USERNAME: Optional[str] = None IMAP_PASSWORD: Optional[str] = None + IMAP_FOLDER: str = "INBOX" # Admin User FIRST_SUPERUSER: Optional[EmailStr] = None diff --git a/backend/app/main.py b/backend/app/main.py index a7f8424..7694777 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -51,6 +51,7 @@ def _poll_single_imap_source(source: MailSource) -> None: username=poll_source.username, password=poll_source.password, delete_emails=False, + folder=poll_source.folder, db=db, ) started_at = datetime.utcnow() @@ -530,6 +531,7 @@ def _trigger_poll_imap_source(source: MailSource, db) -> dict: username=source.username, password=source.password, delete_emails=False, + folder=source.folder, db=db, ) started_at = datetime.utcnow() diff --git a/backend/app/services/imap_client.py b/backend/app/services/imap_client.py index 71f3222..ea6aa3c 100644 --- a/backend/app/services/imap_client.py +++ b/backend/app/services/imap_client.py @@ -26,6 +26,7 @@ class IMAPClient: username: str = None, password: str = None, delete_emails: bool = False, + folder: str = None, db: Any = None, ): """ @@ -37,15 +38,20 @@ class IMAPClient: username: IMAP username (if None, uses settings) password: IMAP password (if None, uses settings) delete_emails: Whether to delete emails after processing (default: False) + folder: IMAP mailbox folder to read (if None, uses settings or INBOX) db: Optional SQLAlchemy session used to persist imported reports """ settings = get_settings() + settings_folder = getattr(settings, "IMAP_FOLDER", None) + if not isinstance(settings_folder, str): + settings_folder = None self.server = server or settings.IMAP_SERVER self.port = port or settings.IMAP_PORT self.username = username or settings.IMAP_USERNAME self.password = password or settings.IMAP_PASSWORD self.delete_emails = delete_emails + self.folder = folder or settings_folder or "INBOX" self.db = db self.report_store = ReportStore.get_instance() @@ -53,6 +59,10 @@ class IMAPClient: if not all([self.server, self.username, self.password]): logger.warning("IMAP credentials not fully configured") + def _quoted_folder(self) -> str: + escaped = self.folder.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + def _list_mailboxes(self, mailbox_data: list) -> list: """Parse the raw IMAP LIST response into a list of mailbox name strings.""" available_mailboxes = [] @@ -100,8 +110,8 @@ class IMAPClient: status, mailbox_list = mail.list() available_mailboxes = self._list_mailboxes(mailbox_list) if status == "OK" else [] - # Select inbox and get message count - status, data = mail.select("INBOX") + # Select configured mailbox and get message count + status, data = mail.select(self._quoted_folder()) message_count = 0 unread_count = 0 @@ -206,7 +216,7 @@ class IMAPClient: # Connect to the mail server mail = imaplib.IMAP4_SSL(self.server, self.port) mail.login(self.username, self.password) - mail.select("INBOX") + mail.select(self._quoted_folder()) # Calculate the date range for search date_since = (datetime.now() - timedelta(days=days)).strftime("%d-%b-%Y") diff --git a/backend/app/tests/test_imap_client.py b/backend/app/tests/test_imap_client.py index 61a5e19..f1928c8 100644 --- a/backend/app/tests/test_imap_client.py +++ b/backend/app/tests/test_imap_client.py @@ -11,6 +11,7 @@ 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 @@ -138,6 +139,19 @@ class TestIMAPClientInit: assert client.password == "secret" 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: @@ -242,6 +256,26 @@ class TestTestConnection: 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")): @@ -647,6 +681,26 @@ class TestFetchReports: 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( diff --git a/backend/app/tests/test_main_polling.py b/backend/app/tests/test_main_polling.py index 9ed7a04..a46c26f 100644 --- a/backend/app/tests/test_main_polling.py +++ b/backend/app/tests/test_main_polling.py @@ -45,6 +45,48 @@ class TestNextSleepSeconds: assert _next_sleep_seconds() == 3600 +def test_poll_single_imap_source_passes_configured_folder(): + from app.main import _poll_single_imap_source + + source = SimpleNamespace( + id=1, + server="imap.example.com", + port=993, + username="u", + password="p", + folder="Junk Mail", + ) + db = MagicMock() + db.query.return_value.get.return_value = source + results = { + "success": True, + "processed": 0, + "reports_found": 0, + "new_domains": [], + } + + with ( + patch("app.main.SessionLocal", return_value=db), + patch("app.main.IMAPClient") as mock_client_cls, + patch("app.main.record_import_attempt"), + ): + mock_client_cls.return_value.fetch_reports.return_value = results + + _poll_single_imap_source(source) + + mock_client_cls.assert_called_once_with( + server="imap.example.com", + port=993, + username="u", + password="p", + delete_emails=False, + folder="Junk Mail", + db=db, + ) + db.commit.assert_called_once() + db.close.assert_called_once() + + @pytest.mark.asyncio async def test_scheduled_imap_polling_sleep_exception_falls_back_then_cancels(): from app.main import scheduled_imap_polling