mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 10:15:27 -04:00
fix(email): guarantee IMAP conn.logout() on all exception paths (#1530)
Three IMAP connection leaks were recently fixed via try/finally (#1325, #1330, #1423). This commit applies the same pattern to the remaining callsites that still used inline logout-only cleanup. routes/email_helpers.py: - _fetch_sender_thread_context: conn was uninitialized when the outer try/except returned early on connect failure, causing the finally block to crash on conn.close()/conn.logout(). Merged the two separate try blocks into one and added conn=None guard. - _pre_retrieve_context: ctx_conn.logout() was inside the loop body with no finally, so any exception in the folder/search loop leaked the socket. Moved cleanup into a finally block with ctx_conn=None guard. mcp_servers/email_server.py: - _list_emails: multiple inline conn.logout() calls on early-return paths; exception between them leaked the socket. Wrapped in try/finally. - _read_email: same pattern — four separate logout() calls replaced by a single finally block. - _reply_to_email: logout() called before the error check, so an exception in conn.select() leaked the socket. Wrapped in try/finally. - _download_attachment: same pattern as _reply_to_email. Also adds tests/test_imap_leak_fixes.py with 9 regression tests (one per function/failure-mode) that monkeypatch _imap_connect and assert conn.logout() is called exactly once even when IMAP operations raise.
This commit is contained in:
+24
-10
@@ -423,10 +423,11 @@ def _list_emails(folder="INBOX", max_results=20, unresponded_only=False,
|
|||||||
Pass unread_only=True and/or unresponded_only=True for attention scans.
|
Pass unread_only=True and/or unresponded_only=True for attention scans.
|
||||||
account selects mailbox (None = default).
|
account selects mailbox (None = default).
|
||||||
"""
|
"""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
conn = _imap_connect(account)
|
conn = _imap_connect(account)
|
||||||
select_status, _ = conn.select(_q(folder), readonly=True)
|
select_status, _ = conn.select(_q(folder), readonly=True)
|
||||||
if select_status != "OK":
|
if select_status != "OK":
|
||||||
conn.logout()
|
|
||||||
raise ValueError(f"IMAP folder not found: {folder}")
|
raise ValueError(f"IMAP folder not found: {folder}")
|
||||||
|
|
||||||
if unread_only and unresponded_only:
|
if unread_only and unresponded_only:
|
||||||
@@ -443,7 +444,6 @@ def _list_emails(folder="INBOX", max_results=20, unresponded_only=False,
|
|||||||
status, data = conn.uid("SEARCH", None, "ALL")
|
status, data = conn.uid("SEARCH", None, "ALL")
|
||||||
|
|
||||||
if status != "OK" or not data[0]:
|
if status != "OK" or not data[0]:
|
||||||
conn.logout()
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
uid_list = list(reversed(data[0].split()))[:max_results]
|
uid_list = list(reversed(data[0].split()))[:max_results]
|
||||||
@@ -483,8 +483,11 @@ def _list_emails(folder="INBOX", max_results=20, unresponded_only=False,
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
conn.logout()
|
|
||||||
return results
|
return results
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
try: conn.logout()
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
|
|
||||||
def _result_sort_time(result: dict) -> datetime:
|
def _result_sort_time(result: dict) -> datetime:
|
||||||
@@ -657,26 +660,24 @@ def _extract_attachment_to_disk(msg, index, target_dir):
|
|||||||
def _read_email(uid=None, message_id=None, folder="INBOX", account=None):
|
def _read_email(uid=None, message_id=None, folder="INBOX", account=None):
|
||||||
"""Read full email content by UID or message-ID. account = mailbox selector."""
|
"""Read full email content by UID or message-ID. account = mailbox selector."""
|
||||||
cfg = _load_config(account)
|
cfg = _load_config(account)
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
conn = _imap_connect(account)
|
conn = _imap_connect(account)
|
||||||
conn.select(_q(folder), readonly=True)
|
conn.select(_q(folder), readonly=True)
|
||||||
|
|
||||||
if message_id and not uid:
|
if message_id and not uid:
|
||||||
status, data = conn.uid("SEARCH", None, f'(HEADER Message-ID "{message_id}")')
|
status, data = conn.uid("SEARCH", None, f'(HEADER Message-ID "{message_id}")')
|
||||||
if status != "OK" or not data[0]:
|
if status != "OK" or not data[0]:
|
||||||
conn.logout()
|
|
||||||
return {"error": f"Email not found with Message-ID: {message_id}"}
|
return {"error": f"Email not found with Message-ID: {message_id}"}
|
||||||
uid = data[0].split()[-1]
|
uid = data[0].split()[-1]
|
||||||
|
|
||||||
if not uid:
|
if not uid:
|
||||||
conn.logout()
|
|
||||||
return {"error": "No UID or Message-ID provided"}
|
return {"error": "No UID or Message-ID provided"}
|
||||||
|
|
||||||
status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])")
|
status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])")
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
conn.logout()
|
|
||||||
return {"error": f"Failed to fetch email UID {uid}"}
|
return {"error": f"Failed to fetch email UID {uid}"}
|
||||||
if not msg_data or not msg_data[0] or not isinstance(msg_data[0], tuple) or len(msg_data[0]) < 2:
|
if not msg_data or not msg_data[0] or not isinstance(msg_data[0], tuple) or len(msg_data[0]) < 2:
|
||||||
conn.logout()
|
|
||||||
return {"error": f"Email not found with UID {uid}"}
|
return {"error": f"Email not found with UID {uid}"}
|
||||||
|
|
||||||
raw = msg_data[0][1]
|
raw = msg_data[0][1]
|
||||||
@@ -691,7 +692,6 @@ def _read_email(uid=None, message_id=None, folder="INBOX", account=None):
|
|||||||
|
|
||||||
sender_name, sender_addr = email.utils.parseaddr(sender)
|
sender_name, sender_addr = email.utils.parseaddr(sender)
|
||||||
|
|
||||||
conn.logout()
|
|
||||||
return {
|
return {
|
||||||
"uid": uid.decode() if isinstance(uid, bytes) else str(uid),
|
"uid": uid.decode() if isinstance(uid, bytes) else str(uid),
|
||||||
"account": cfg.get("account_name") or cfg.get("imap_user") or "default",
|
"account": cfg.get("account_name") or cfg.get("imap_user") or "default",
|
||||||
@@ -705,6 +705,10 @@ def _read_email(uid=None, message_id=None, folder="INBOX", account=None):
|
|||||||
"body": body[:8000],
|
"body": body[:8000],
|
||||||
"attachments": attachments,
|
"attachments": attachments,
|
||||||
}
|
}
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
try: conn.logout()
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
|
|
||||||
def _read_email_across_accounts(uid=None, message_id=None, folder="INBOX"):
|
def _read_email_across_accounts(uid=None, message_id=None, folder="INBOX"):
|
||||||
@@ -858,10 +862,15 @@ def _send_email(to, subject, body, in_reply_to=None, references=None, cc=None, b
|
|||||||
|
|
||||||
def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None):
|
def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None):
|
||||||
"""Reply to an existing email by UID. Threads via In-Reply-To/References."""
|
"""Reply to an existing email by UID. Threads via In-Reply-To/References."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
conn = _imap_connect(account)
|
conn = _imap_connect(account)
|
||||||
conn.select(_q(folder), readonly=True)
|
conn.select(_q(folder), readonly=True)
|
||||||
status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])")
|
status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])")
|
||||||
conn.logout()
|
finally:
|
||||||
|
if conn:
|
||||||
|
try: conn.logout()
|
||||||
|
except Exception: pass
|
||||||
if status != "OK" or not msg_data or not msg_data[0]:
|
if status != "OK" or not msg_data or not msg_data[0]:
|
||||||
return {"error": f"Failed to fetch email UID {uid}"}
|
return {"error": f"Failed to fetch email UID {uid}"}
|
||||||
raw = msg_data[0][1]
|
raw = msg_data[0][1]
|
||||||
@@ -1038,10 +1047,15 @@ def _archive_email(uid, folder="INBOX", account=None):
|
|||||||
|
|
||||||
def _download_attachment(uid, index, folder="INBOX", account=None):
|
def _download_attachment(uid, index, folder="INBOX", account=None):
|
||||||
"""Extract a specific attachment to disk and return its local path."""
|
"""Extract a specific attachment to disk and return its local path."""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
conn = _imap_connect(account)
|
conn = _imap_connect(account)
|
||||||
conn.select(_q(folder), readonly=True)
|
conn.select(_q(folder), readonly=True)
|
||||||
status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])")
|
status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])")
|
||||||
conn.logout()
|
finally:
|
||||||
|
if conn:
|
||||||
|
try: conn.logout()
|
||||||
|
except Exception: pass
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
return {"error": f"Failed to fetch email UID {uid}"}
|
return {"error": f"Failed to fetch email UID {uid}"}
|
||||||
raw = msg_data[0][1]
|
raw = msg_data[0][1]
|
||||||
|
|||||||
@@ -1140,13 +1140,9 @@ def _fetch_sender_thread_context(sender_addr: str,
|
|||||||
if exclude_uid:
|
if exclude_uid:
|
||||||
seen_uids.add((exclude_folder or "INBOX", str(exclude_uid)))
|
seen_uids.add((exclude_folder or "INBOX", str(exclude_uid)))
|
||||||
|
|
||||||
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = _imap_connect(account_id, owner=owner)
|
conn = _imap_connect(account_id, owner=owner)
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"sender-thread-context: imap connect failed: {e}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
for folder in ["INBOX", "Sent", "Archive", "Drafts"]:
|
for folder in ["INBOX", "Sent", "Archive", "Drafts"]:
|
||||||
if len(blocks) >= limit:
|
if len(blocks) >= limit:
|
||||||
break
|
break
|
||||||
@@ -1213,7 +1209,10 @@ def _fetch_sender_thread_context(sender_addr: str,
|
|||||||
if atts_text:
|
if atts_text:
|
||||||
lines.append(atts_text)
|
lines.append(atts_text)
|
||||||
blocks.append("\n".join(lines))
|
blocks.append("\n".join(lines))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"sender-thread-context: imap failed: {e}")
|
||||||
finally:
|
finally:
|
||||||
|
if conn:
|
||||||
try: conn.close()
|
try: conn.close()
|
||||||
except Exception: pass
|
except Exception: pass
|
||||||
try: conn.logout()
|
try: conn.logout()
|
||||||
@@ -1320,6 +1319,7 @@ def _pre_retrieve_context(
|
|||||||
if not terms_list:
|
if not terms_list:
|
||||||
return context_snippets, terms_list
|
return context_snippets, terms_list
|
||||||
|
|
||||||
|
ctx_conn = None
|
||||||
try:
|
try:
|
||||||
ctx_conn = _imap_connect(account_id, owner=owner)
|
ctx_conn = _imap_connect(account_id, owner=owner)
|
||||||
for folder in ["INBOX", "Sent", "Archive", "Drafts"]:
|
for folder in ["INBOX", "Sent", "Archive", "Drafts"]:
|
||||||
@@ -1356,12 +1356,12 @@ def _pre_retrieve_context(
|
|||||||
except Exception as _e:
|
except Exception as _e:
|
||||||
logger.warning(f" search {folder} {term!r} failed: {_e}")
|
logger.warning(f" search {folder} {term!r} failed: {_e}")
|
||||||
continue
|
continue
|
||||||
try:
|
|
||||||
ctx_conn.logout()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception as _e:
|
except Exception as _e:
|
||||||
logger.warning(f"IMAP context search failed: {_e}")
|
logger.warning(f"IMAP context search failed: {_e}")
|
||||||
|
finally:
|
||||||
|
if ctx_conn:
|
||||||
|
try: ctx_conn.logout()
|
||||||
|
except Exception: pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from routes.contacts_routes import _fetch_contacts
|
from routes.contacts_routes import _fetch_contacts
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""Regression tests for IMAP connection leak fixes.
|
||||||
|
|
||||||
|
Each test forces an exception after _imap_connect() succeeds and asserts
|
||||||
|
that conn.logout() is still called exactly once (guaranteed by try/finally).
|
||||||
|
|
||||||
|
Functions covered:
|
||||||
|
- routes/email_helpers.py: _fetch_sender_thread_context, _pre_retrieve_context
|
||||||
|
- mcp_servers/email_server.py: _list_emails, _read_email, _reply_to_email,
|
||||||
|
_download_attachment
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
_TMP = Path(tempfile.mkdtemp(prefix="odysseus-imap-leak-fixes-"))
|
||||||
|
os.environ.setdefault("DATA_DIR", str(_TMP))
|
||||||
|
os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TMP / 'app.db'}")
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_failing_conn(captured, *, raises_on="select"):
|
||||||
|
"""Return a mock IMAP connection that raises on the first call to `raises_on`."""
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.logout = MagicMock(side_effect=lambda: captured.__setitem__(
|
||||||
|
"logout_calls", captured.get("logout_calls", 0) + 1
|
||||||
|
))
|
||||||
|
|
||||||
|
def _raise(*a, **kw):
|
||||||
|
raise RuntimeError("simulated IMAP failure")
|
||||||
|
|
||||||
|
getattr(conn, raises_on).side_effect = _raise
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
# ── email_helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_fetch_sender_thread_context_logs_out_on_select_failure(monkeypatch):
|
||||||
|
import routes.email_helpers as helpers
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
conn = _make_failing_conn(captured, raises_on="select")
|
||||||
|
monkeypatch.setattr(helpers, "_imap_connect", lambda *a, **kw: conn)
|
||||||
|
|
||||||
|
result = helpers._fetch_sender_thread_context("user@example.com")
|
||||||
|
|
||||||
|
assert captured.get("logout_calls", 0) == 1, (
|
||||||
|
f"conn.logout() must be called on select failure. "
|
||||||
|
f"Got logout_calls={captured.get('logout_calls')}"
|
||||||
|
)
|
||||||
|
assert result == "", "Should return empty string on failure"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_sender_thread_context_logs_out_on_connect_failure(monkeypatch):
|
||||||
|
"""If _imap_connect itself raises, conn is None — no logout, no crash."""
|
||||||
|
import routes.email_helpers as helpers
|
||||||
|
|
||||||
|
def _fail(*a, **kw):
|
||||||
|
raise ConnectionRefusedError("cannot connect")
|
||||||
|
|
||||||
|
monkeypatch.setattr(helpers, "_imap_connect", _fail)
|
||||||
|
result = helpers._fetch_sender_thread_context("user@example.com")
|
||||||
|
assert result == "", "Should return empty string when connect fails"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pre_retrieve_context_logs_out_on_search_failure(monkeypatch):
|
||||||
|
import routes.email_helpers as helpers
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.select.return_value = ("OK", [])
|
||||||
|
conn.logout = MagicMock(side_effect=lambda: captured.__setitem__(
|
||||||
|
"logout_calls", captured.get("logout_calls", 0) + 1
|
||||||
|
))
|
||||||
|
conn.search.side_effect = RuntimeError("simulated search failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(helpers, "_imap_connect", lambda *a, **kw: conn)
|
||||||
|
|
||||||
|
# Bypass the known-sender check and term extraction so we reach the IMAP block
|
||||||
|
monkeypatch.setattr(helpers, "_imap", MagicMock(
|
||||||
|
return_value=MagicMock(
|
||||||
|
__enter__=MagicMock(return_value=MagicMock(
|
||||||
|
select=MagicMock(return_value=("OK", [])),
|
||||||
|
search=MagicMock(return_value=("OK", [b"1"])),
|
||||||
|
)),
|
||||||
|
__exit__=MagicMock(return_value=False),
|
||||||
|
)
|
||||||
|
))
|
||||||
|
|
||||||
|
# Provide a body with a capitalised term so terms_list is non-empty
|
||||||
|
snippets, terms = helpers._pre_retrieve_context(
|
||||||
|
body="Project Alpha update",
|
||||||
|
sender="Known Sender <known@example.com>",
|
||||||
|
)
|
||||||
|
|
||||||
|
# The function is best-effort and never raises; logout must have been called
|
||||||
|
assert captured.get("logout_calls", 0) == 1, (
|
||||||
|
f"ctx_conn.logout() must be called even when search raises. "
|
||||||
|
f"Got logout_calls={captured.get('logout_calls')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── email_server ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_mcp_list_emails_logs_out_on_select_failure(monkeypatch):
|
||||||
|
import mcp_servers.email_server as srv
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
conn = _make_failing_conn(captured, raises_on="select")
|
||||||
|
monkeypatch.setattr(srv, "_imap_connect", lambda *a, **kw: conn)
|
||||||
|
|
||||||
|
try:
|
||||||
|
srv._list_emails()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert captured.get("logout_calls", 0) == 1, (
|
||||||
|
f"conn.logout() must be called after select raises. "
|
||||||
|
f"Got logout_calls={captured.get('logout_calls')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_list_emails_logs_out_on_search_failure(monkeypatch):
|
||||||
|
import mcp_servers.email_server as srv
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.select.return_value = ("OK", [])
|
||||||
|
conn.uid.side_effect = RuntimeError("simulated search failure")
|
||||||
|
conn.logout = MagicMock(side_effect=lambda: captured.__setitem__(
|
||||||
|
"logout_calls", captured.get("logout_calls", 0) + 1
|
||||||
|
))
|
||||||
|
monkeypatch.setattr(srv, "_imap_connect", lambda *a, **kw: conn)
|
||||||
|
|
||||||
|
try:
|
||||||
|
srv._list_emails()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert captured.get("logout_calls", 0) == 1, (
|
||||||
|
f"conn.logout() must be called after uid search raises. "
|
||||||
|
f"Got logout_calls={captured.get('logout_calls')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_read_email_logs_out_on_select_failure(monkeypatch):
|
||||||
|
import mcp_servers.email_server as srv
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
conn = _make_failing_conn(captured, raises_on="select")
|
||||||
|
monkeypatch.setattr(srv, "_imap_connect", lambda *a, **kw: conn)
|
||||||
|
monkeypatch.setattr(srv, "_load_config", lambda *a, **kw: {})
|
||||||
|
|
||||||
|
# The exception propagates out of _read_email (no outer catch in this fn);
|
||||||
|
# what matters is that logout was still called via finally before it did.
|
||||||
|
try:
|
||||||
|
srv._read_email(uid="1")
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert captured.get("logout_calls", 0) == 1, (
|
||||||
|
f"conn.logout() must be called after select raises. "
|
||||||
|
f"Got logout_calls={captured.get('logout_calls')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_read_email_logs_out_on_fetch_failure(monkeypatch):
|
||||||
|
import mcp_servers.email_server as srv
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.select.return_value = ("OK", [])
|
||||||
|
conn.uid.side_effect = RuntimeError("simulated fetch failure")
|
||||||
|
conn.logout = MagicMock(side_effect=lambda: captured.__setitem__(
|
||||||
|
"logout_calls", captured.get("logout_calls", 0) + 1
|
||||||
|
))
|
||||||
|
monkeypatch.setattr(srv, "_imap_connect", lambda *a, **kw: conn)
|
||||||
|
monkeypatch.setattr(srv, "_load_config", lambda *a, **kw: {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
srv._read_email(uid="1")
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert captured.get("logout_calls", 0) == 1, (
|
||||||
|
f"conn.logout() must be called after uid fetch raises. "
|
||||||
|
f"Got logout_calls={captured.get('logout_calls')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_reply_to_email_logs_out_on_select_failure(monkeypatch):
|
||||||
|
import mcp_servers.email_server as srv
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
conn = _make_failing_conn(captured, raises_on="select")
|
||||||
|
monkeypatch.setattr(srv, "_imap_connect", lambda *a, **kw: conn)
|
||||||
|
|
||||||
|
# Exception propagates; the finally still runs before it does.
|
||||||
|
try:
|
||||||
|
srv._reply_to_email(uid="1", body="hi")
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert captured.get("logout_calls", 0) == 1, (
|
||||||
|
f"conn.logout() must be called after select raises in _reply_to_email. "
|
||||||
|
f"Got logout_calls={captured.get('logout_calls')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_download_attachment_logs_out_on_select_failure(monkeypatch):
|
||||||
|
import mcp_servers.email_server as srv
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
conn = _make_failing_conn(captured, raises_on="select")
|
||||||
|
monkeypatch.setattr(srv, "_imap_connect", lambda *a, **kw: conn)
|
||||||
|
|
||||||
|
try:
|
||||||
|
srv._download_attachment(uid="1", index=0)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert captured.get("logout_calls", 0) == 1, (
|
||||||
|
f"conn.logout() must be called after select raises in _download_attachment. "
|
||||||
|
f"Got logout_calls={captured.get('logout_calls')}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user