fix: honor configured IMAP source folder
This commit is contained in:
committed by
GitHub
parent
bc0a64c108
commit
f626675609
@@ -282,6 +282,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()
|
||||||
@@ -520,6 +521,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()
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,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
|
||||||
|
|||||||
@@ -51,6 +51,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()
|
||||||
@@ -530,6 +531,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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user