Merge pull request #113 from christianlouis/copilot/fix-imap-client-folder-issue

Honor configured IMAP source folder
This commit is contained in:
Christian Krakau-Louis
2026-05-22 22:19:01 +02:00
committed by GitHub
6 changed files with 114 additions and 3 deletions
@@ -288,6 +288,7 @@ def _fetch_imap_source(source: MailSource, db: Session, days: int) -> Dict[str,
username=source.username, username=source.username,
password=source.password, password=source.password,
delete_emails=False, delete_emails=False,
folder=source.folder,
db=db, db=db,
) )
started_at = datetime.utcnow() started_at = datetime.utcnow()
@@ -526,6 +527,7 @@ async def test_stored_mail_source(
port=source.port or 993, port=source.port or 993,
username=source.username, username=source.username,
password=source.password, password=source.password,
folder=source.folder,
) )
success, message, stats = imap_client.test_connection() success, message, stats = imap_client.test_connection()
+1
View File
@@ -41,6 +41,7 @@ class Settings(BaseSettings):
IMAP_PORT: int = 993 IMAP_PORT: int = 993
IMAP_USERNAME: Optional[str] = None IMAP_USERNAME: Optional[str] = None
IMAP_PASSWORD: Optional[str] = None IMAP_PASSWORD: Optional[str] = None
IMAP_FOLDER: str = "INBOX"
# Admin User # Admin User
FIRST_SUPERUSER: Optional[EmailStr] = None FIRST_SUPERUSER: Optional[EmailStr] = None
+2
View File
@@ -52,6 +52,7 @@ def _poll_single_imap_source(source: MailSource) -> None:
username=poll_source.username, username=poll_source.username,
password=poll_source.password, password=poll_source.password,
delete_emails=False, delete_emails=False,
folder=poll_source.folder,
db=db, db=db,
) )
started_at = datetime.utcnow() started_at = datetime.utcnow()
@@ -533,6 +534,7 @@ def _trigger_poll_imap_source(source: MailSource, db) -> dict:
username=source.username, username=source.username,
password=source.password, password=source.password,
delete_emails=False, delete_emails=False,
folder=source.folder,
db=db, db=db,
) )
started_at = datetime.utcnow() started_at = datetime.utcnow()
+13 -3
View File
@@ -26,6 +26,7 @@ class IMAPClient:
username: str = None, username: str = None,
password: str = None, password: str = None,
delete_emails: bool = False, delete_emails: bool = False,
folder: str = None,
db: Any = None, db: Any = None,
): ):
""" """
@@ -37,15 +38,20 @@ class IMAPClient:
username: IMAP username (if None, uses settings) username: IMAP username (if None, uses settings)
password: IMAP password (if None, uses settings) password: IMAP password (if None, uses settings)
delete_emails: Whether to delete emails after processing (default: False) 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 db: Optional SQLAlchemy session used to persist imported reports
""" """
settings = get_settings() 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.server = server or settings.IMAP_SERVER
self.port = port or settings.IMAP_PORT self.port = port or settings.IMAP_PORT
self.username = username or settings.IMAP_USERNAME self.username = username or settings.IMAP_USERNAME
self.password = password or settings.IMAP_PASSWORD self.password = password or settings.IMAP_PASSWORD
self.delete_emails = delete_emails self.delete_emails = delete_emails
self.folder = folder or settings_folder or "INBOX"
self.db = db self.db = db
self.report_store = ReportStore.get_instance() self.report_store = ReportStore.get_instance()
@@ -53,6 +59,10 @@ class IMAPClient:
if not all([self.server, self.username, self.password]): if not all([self.server, self.username, self.password]):
logger.warning("IMAP credentials not fully configured") 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: def _list_mailboxes(self, mailbox_data: list) -> list:
"""Parse the raw IMAP LIST response into a list of mailbox name strings.""" """Parse the raw IMAP LIST response into a list of mailbox name strings."""
available_mailboxes = [] available_mailboxes = []
@@ -100,8 +110,8 @@ class IMAPClient:
status, mailbox_list = mail.list() status, mailbox_list = mail.list()
available_mailboxes = self._list_mailboxes(mailbox_list) if status == "OK" else [] available_mailboxes = self._list_mailboxes(mailbox_list) if status == "OK" else []
# Select inbox and get message count # Select configured mailbox and get message count
status, data = mail.select("INBOX") status, data = mail.select(self._quoted_folder())
message_count = 0 message_count = 0
unread_count = 0 unread_count = 0
@@ -206,7 +216,7 @@ class IMAPClient:
# Connect to the mail server # Connect to the mail server
mail = imaplib.IMAP4_SSL(self.server, self.port) mail = imaplib.IMAP4_SSL(self.server, self.port)
mail.login(self.username, self.password) mail.login(self.username, self.password)
mail.select("INBOX") mail.select(self._quoted_folder())
# Calculate the date range for search # Calculate the date range for search
date_since = (datetime.now() - timedelta(days=days)).strftime("%d-%b-%Y") date_since = (datetime.now() - timedelta(days=days)).strftime("%d-%b-%Y")
+54
View File
@@ -11,6 +11,7 @@ from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from io import BytesIO from io import BytesIO
from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from zipfile import ZipFile from zipfile import ZipFile
@@ -138,6 +139,19 @@ class TestIMAPClientInit:
assert client.password == "secret" assert client.password == "secret"
assert client.delete_emails is True 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): def test_report_store_assigned(self):
"""IMAPClient stores a reference to the ReportStore singleton.""" """IMAPClient stores a reference to the ReportStore singleton."""
with patch("app.services.imap_client.get_settings") as mock_settings: with patch("app.services.imap_client.get_settings") as mock_settings:
@@ -242,6 +256,26 @@ class TestTestConnection:
assert stats["message_count"] == 10 assert stats["message_count"] == 10
assert "INBOX" in stats["available_mailboxes"] 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): def test_connection_exception_returns_false(self):
client = self._make_client() client = self._make_client()
with patch("imaplib.IMAP4_SSL", side_effect=ConnectionRefusedError("refused")): with patch("imaplib.IMAP4_SSL", side_effect=ConnectionRefusedError("refused")):
@@ -647,6 +681,26 @@ class TestFetchReports:
assert result["success"] is False 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): def test_successful_fetch_with_email(self):
client = self._make_client() client = self._make_client()
raw = _make_email_with_attachment( raw = _make_email_with_attachment(
+42
View File
@@ -45,6 +45,48 @@ class TestNextSleepSeconds:
assert _next_sleep_seconds() == 3600 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 @pytest.mark.asyncio
async def test_scheduled_imap_polling_sleep_exception_falls_back_then_cancels(): async def test_scheduled_imap_polling_sleep_exception_falls_back_then_cancels():
from app.main import scheduled_imap_polling from app.main import scheduled_imap_polling