fix(email): close IMAP socket when connect/login fails (#3174) (#3363)

* fix(email): close IMAP socket when connect/login fails (#3174)

_imap_connect opened a live socket via _open_imap_connection and then
called conn.login() with no try/finally, and _open_imap_connection called
conn.starttls() unguarded. When auth fails (e.g. an Office 365 app password
on an MFA-enabled tenant, #3174) or STARTTLS is rejected, the already-open
socket was orphaned. Every IMAP caller funnels through _imap_connect,
including the 30-minute _auto_summarize_poller, so a persistently
misconfigured account leaked one descriptor per pass toward FD exhaustion.

The previously merged leak fixes (#1325/#1330/#1423/#1530) only guard the
post-connect body and monkeypatch _imap_connect to succeed, so this
connect-time path was uncovered. Wrap login() and starttls() so a failure
calls conn.shutdown() (low-level close; logout() can't run pre-auth) before
re-raising. Adds two regression tests that fail without the guard.

* fix(email): guard MCP IMAP+SMTP connect-time leaks too (#3174)

Folds in the sibling connect-time leaks vdmkenny flagged on #3363, so the
whole connect-then-step leak class is closed in one place:

- mcp_servers/email_server.py::_imap_connect — guard starttls() and login();
  close pre-auth with conn.shutdown() before re-raising.
- mcp_servers/email_server.py::_smtp_connect — guard starttls() and login();
  SMTP has no shutdown(), so close with conn.close() (socket close, no QUIT).

Routes SMTP (_send_smtp_message) is already safe via 'with smtplib.SMTP(...)'.
Adds four regression tests (one per guard), verified to fail without the fix.
This commit is contained in:
nubs
2026-06-08 19:21:41 +00:00
committed by GitHub
parent a58f526992
commit 932b7f2446
3 changed files with 235 additions and 6 deletions
+39 -4
View File
@@ -245,10 +245,27 @@ def _imap_connect(account: str | None = None):
timeout=EMAIL_SOCKET_TIMEOUT,
)
if cfg["imap_starttls"]:
conn.starttls()
try:
conn.starttls()
except Exception:
# Don't leak the open plain socket on a rejected STARTTLS. (#3174)
try:
conn.shutdown()
except Exception:
pass
raise
if getattr(conn, "sock", None):
conn.sock.settimeout(EMAIL_SOCKET_TIMEOUT)
conn.login(cfg["imap_user"], cfg["imap_password"])
try:
conn.login(cfg["imap_user"], cfg["imap_password"])
except Exception:
# A failed login otherwise orphans the connected socket; close it
# before propagating (shutdown() is the pre-auth low-level close). (#3174)
try:
conn.shutdown()
except Exception:
pass
raise
return conn
@@ -778,7 +795,16 @@ def _smtp_connect(account=None, cfg=None):
port,
timeout=EMAIL_SOCKET_TIMEOUT,
)
conn.starttls()
try:
conn.starttls()
except Exception:
# Don't leak the open plain socket on a rejected STARTTLS. SMTP has
# no shutdown(); close() is the low-level socket close (no QUIT). (#3174)
try:
conn.close()
except Exception:
pass
raise
elif security == "ssl":
conn = smtplib.SMTP_SSL(
cfg["smtp_host"],
@@ -792,7 +818,16 @@ def _smtp_connect(account=None, cfg=None):
timeout=EMAIL_SOCKET_TIMEOUT,
)
if cfg["smtp_user"] and cfg["smtp_password"]:
conn.login(cfg["smtp_user"], cfg["smtp_password"])
try:
conn.login(cfg["smtp_user"], cfg["smtp_password"])
except Exception:
# A failed login otherwise orphans the connected socket; close it
# before propagating (SMTP has no shutdown(); close() = socket close). (#3174)
try:
conn.close()
except Exception:
pass
raise
return conn