Files
odysseus/tests/test_email_send_only_no_inbox.py
T
Ricardo 3b4187e25d fix(email): don't probe IMAP for send-only (SMTP-only) accounts (#4830)
An account configured with SMTP only (no imap_host) has no inbox, but the
inbox list path still called _imap_connect, which handed an empty host to
imaplib. imaplib.IMAP4("", 993) silently dials localhost:993 and fails with
"[Errno 111] Connection refused", so the email panel's poll logged a
"Failed to list emails" ERROR every ~60s and surfaced a scary error in the UI.

_imap_connect now fails fast with a typed EmailNotConfiguredError (subclass of
RuntimeError, so existing broad handlers keep working) when no imap_host is set,
and the inbox list returns an empty result for that case instead of an error.
SMTP send is unaffected.
2026-06-27 21:52:26 +01:00

76 lines
2.6 KiB
Python

"""A send-only (SMTP-only) account has no inbox to read.
`_imap_connect` must fail fast with a clear, typed error instead of handing an
empty host to imaplib — `imaplib.IMAP4("", 993)` silently dials localhost:993
and surfaces a confusing "[Errno 111] Connection refused" on every inbox poll.
"""
import os
import tempfile
from pathlib import Path
import pytest
_tmp_data = Path(tempfile.mkdtemp(prefix="odysseus-email-send-only-test-"))
os.environ.setdefault("DATA_DIR", str(_tmp_data))
os.environ.setdefault("DATABASE_URL", f"sqlite:///{_tmp_data / 'app.db'}")
import routes.email_helpers as helpers
from routes.email_helpers import EmailNotConfiguredError, _imap_connect
_SEND_ONLY_CFG = {
"account_id": "acct-send-only",
"account_name": "send-only",
"smtp_host": "smtp.example.org",
"smtp_port": 465,
"smtp_user": "noreply@example.org",
"smtp_password": "secret",
"imap_host": "", # <- the send-only marker
"imap_port": 993,
"imap_user": "",
"imap_password": "",
"imap_starttls": True,
"from_address": "noreply@example.org",
}
def test_not_configured_error_is_runtime_error():
# Subclassing RuntimeError keeps existing broad `except Exception` handlers
# working while letting the inbox poll catch this case specifically.
assert issubclass(EmailNotConfiguredError, RuntimeError)
def test_imap_connect_send_only_raises_and_never_dials(monkeypatch):
monkeypatch.setattr(helpers, "_get_email_config", lambda *a, **k: dict(_SEND_ONLY_CFG))
def _boom(*a, **k): # opening a connection means we dialed an empty host
raise AssertionError("send-only account must not open an IMAP connection")
monkeypatch.setattr(helpers, "_open_imap_connection", _boom)
with pytest.raises(EmailNotConfiguredError):
_imap_connect("acct-send-only")
def test_imap_connect_with_host_still_connects(monkeypatch):
# Guard must not regress normal accounts: a configured imap_host still
# reaches _open_imap_connection.
cfg = dict(_SEND_ONLY_CFG, imap_host="imap.example.org", imap_user="u", imap_password="p")
monkeypatch.setattr(helpers, "_get_email_config", lambda *a, **k: cfg)
opened = {}
class _FakeConn:
def login(self, user, password):
opened["login"] = (user, password)
def _fake_open(host, port, *, starttls, timeout):
opened["host"] = host
return _FakeConn()
monkeypatch.setattr(helpers, "_open_imap_connection", _fake_open)
conn = _imap_connect("acct-with-imap")
assert opened["host"] == "imap.example.org"
assert isinstance(conn, _FakeConn)