Email adapter IMAP/SMTP host resolution and validation before connecting
Cluster 49736 · gateway. Data as of
Nothing open on GitHub. Fix merged in #50368 on 21 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #50368. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #50368. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Members center on the email adapter's IMAP/SMTP host handling: three titles propose normalizing or resolving the host from config (including config.extra) and validating it before connecting, referencing #49736. One issue reports a nodename nor servname error despite a seemingly valid IMAP host and credentials; its cause and any link to the proposed changes are not established by the titles alone.
Open issues0
Open PRs0
Fixes on record
-
#50368 fix(email): resolve IMAP/SMTP host from config.extra and validate before connecting (#49736)
files:
plugins/platforms/email/adapter.py,scripts/release.py,tests/gateway/test_email.pyShow the diff
-
plugins/platforms/email/adapter.py@@ -159,14 +159,16 @@ def _is_automated_sender(address: str, headers: dict) -> bool: return False def check_email_requirements() -> bool: - """Check if email platform dependencies are available.""" - addr = os.getenv("EMAIL_ADDRESS") - pwd = os.getenv("EMAIL_PASSWORD") - imap = os.getenv("EMAIL_IMAP_HOST") - smtp = os.getenv("EMAIL_SMTP_HOST") - if not all([addr, pwd, imap, smtp]): - return False - return True + """Check if email platform settings are available and non-blank. + + Treats blank/whitespace-only values as missing so an abandoned setup that + left empty ``EMAIL_*`` keys in ``.env`` does not enable the platform (#40715). + """ + addr = os.getenv("EMAIL_ADDRESS", "").strip() + pwd = os.getenv("EMAIL_PASSWORD", "").strip() + imap = os.getenv("EMAIL_IMAP_HOST", "").strip() + smtp = os.getenv("EMAIL_SMTP_HOST", "").strip() + return all([addr, pwd, imap, smtp]) def _decode_header_value(raw: str) -> str: @@ -307,11 +309,20 @@ class EmailAdapter(BasePlatformAdapter): def __init__(self, config: PlatformConfig): super().__init__(config, Platform.EMAIL) - self._address = os.getenv("EMAIL_ADDRESS", "") + # Resolve connection settings from the env vars first, then fall back to + # PlatformConfig.extra (address/imap_host/smtp_host) — the canonical dict + # gateway.config populates and that the "connected" check, the + # send-helper, and `hermes config show` already read. Without the + # fallback a config.yaml-only setup left these empty. Host/address values + # are stripped: a stray space or newline made IMAP4_SSL raise the + # misleading ``[Errno 8] nodename nor servname`` (an unresolvable name) + # instead of an obvious "host not set" error. + extra = config.extra or {} + self._address = (os.getenv("EMAIL_ADDRESS", "") or extra.get("address", "")).strip() self._password = os.getenv("EMAIL_PASSWORD", "") - self._imap_host = os.getenv("EMAIL_IMAP_HOST", "") + self._imap_host = (os.getenv("EMAIL_IMAP_HOST", "") or extra.get("imap_host", "")).strip() self._imap_port = env_int("EMAIL_IMAP_PORT", 993) - self._smtp_host = os.getenv("EMAIL_SMTP_HOST", "") + self._smtp_host = (os.getenv("EMAIL_SMTP_HOST", "") or extra.get("smtp_host", "")).strip() self._smtp_port = env_int("EMAIL_SMTP_PORT", 587) self._poll_interval = env_int("EMAIL_POLL_INTERVAL", 15) @@ -319,7 +330,6 @@ def __init__(self, config: PlatformConfig): # platforms: # email: # skip_attachments: true - extra = config.extra or {} self._skip_attachments = extra.get("skip_attachments", False) # Track message IDs we've already processed to avoid duplicates @@ -396,6 +406,36 @@ def _connect(*, ipv4_only: bool = False) -> smtplib.SMTP: async def connect(self) -> bool: """Connect to the IMAP server and start polling for new messages.""" + # Validate up front so a missing host surfaces as an actionable config + # error instead of IMAP4_SSL("") raising the cryptic + # ``[Errno 8] nodename nor servname provided, or not known``. + missing = [ + name + for name, value in ( + ("EMAIL_ADDRESS", self._address), + ("EMAIL_PASSWORD", self._password), + ("EMAIL_IMAP_HOST", self._imap_host), + ("EMAIL_SMTP_HOST", self._smtp_host), + ) + if not value + ] + if missing: + message = ( + "Not configured — missing " + + ", ".join(missing) + + ". Set it via `hermes gateway setup` (env) or platforms.email " + "in config.yaml." + ) + logger.error("[Email] %s", message) + # Mark non-retryable so the gateway does NOT keep reconnecting against + # an empty host. A blank-but-present env var (e.g. ``EMAIL_IMAP_HOST=``) + # used to slip past the startup gate and drive an indefinite retry + # loop that leaked memory until the host OOM-killed (#40715). + self._set_fatal_error( + "email_missing_configuration", message, retryable=False + ) + return False + try: # Test IMAP connection imap = imaplib.IMAP4_SSL(self._imap_host, self._imap_port, timeout=30) -
scripts/release.py@@ -120,6 +120,7 @@ "[email protected]": "synapsesx", "[email protected]": "itsflownium", "[email protected]": "dirtyren", + "[redacted:email]": "devorun", "[redacted:email]": "x7peeps", "[redacted:email]": "andressommerhoff", "[redacted:email]": "x7peeps", -
tests/gateway/test_email.py@@ -1392,5 +1392,95 @@ def test_ipv4_connection_does_not_mutate_global_resolver(self): self.assertIs(_socket.getaddrinfo, original_getaddrinfo) +class TestConnectionConfigResolution(unittest.TestCase): + """Host/address resolution and pre-connect validation (#49736).""" + + def test_host_and_address_whitespace_stripped(self): + """A stray space/newline must not reach IMAP4_SSL as part of the host. + + Whitespace in the host produced the misleading + ``[Errno 8] nodename nor servname`` (unresolvable name) instead of a + successful connection. + """ + from gateway.config import PlatformConfig + from plugins.platforms.email.adapter import EmailAdapter + with patch.dict(os.environ, { + "EMAIL_ADDRESS": " [redacted:email]\n", + "EMAIL_PASSWORD": "[redacted:secret-assignment]", + "EMAIL_IMAP_HOST": " imap.test.com ", + "EMAIL_SMTP_HOST": "smtp.test.com\n", + }, clear=False): + adapter = EmailAdapter(PlatformConfig(enabled=True)) + self.assertEqual(adapter._imap_host, "imap.test.com") + self.assertEqual(adapter._smtp_host, "smtp.test.com") + self.assertEqual(adapter._address, "[redacted:email]") + + def test_falls_back_to_platform_config_extra(self): + """When env vars are absent, settings come from PlatformConfig.extra — + the same dict gateway.config populates and `hermes config show` reads.""" + from gateway.config import PlatformConfig + from plugins.platforms.email.adapter import EmailAdapter + cfg = PlatformConfig(enabled=True) + cfg.extra.update({ + "address": "[redacted:email]", + "imap_host": "imap.test.com", + "smtp_host": "smtp.test.com", + }) + with patch.dict(os.environ, { + "EMAIL_ADDRESS": "", "EMAIL_IMAP_HOST": "", "EMAIL_SMTP_HOST": "", + "EMAIL_PASSWORD": "[redacted:secret-assignment]", + }, clear=False): + adapter = EmailAdapter(cfg) + self.assertEqual(adapter._imap_host, "imap.test.com") + self.assertEqual(adapter._smtp_host, "smtp.test.com") + self.assertEqual(adapter._address, "[redacted:email]") + + def test_connect_aborts_without_attempting_imap_when_host_missing(self): + """A missing host returns False without the cryptic DNS error, and marks + the failure non-retryable so the gateway stops reconnecting (#40715).""" + import asyncio + from gateway.config import PlatformConfig + from plugins.platforms.email.adapter import EmailAdapter + with patch.dict(os.environ, { + "EMAIL_ADDRESS": "[redacted:email]", + "EMAIL_PASSWORD": "[redacted:secret-assignment]", + "EMAIL_IMAP_HOST": "", + "EMAIL_SMTP_HOST": "smtp.test.com", + }, clear=False): + adapter = EmailAdapter(PlatformConfig(enabled=True)) + + with patch("imaplib.IMAP4_SSL") as mock_imap: + result = asyncio.run(adapter.connect()) + + self.assertFalse(result) + mock_imap.assert_not_called() + # The OOM fix (#40715): a blank host must NOT leave the platform in the + # retryable reconnect loop — it is a permanent config error. + self.assertTrue(adapter.has_fatal_error) + self.assertEqual(adapter.fatal_error_code, "email_missing_configuration") + self.assertFalse(adapter.fatal_error_retryable) + self.assertIn("EMAIL_IMAP_HOST", adapter.fatal_error_message or "") + + def test_blank_present_env_vars_are_not_required(self): + """Blank/whitespace EMAIL_* values must read as missing (#40715) — an + abandoned setup with empty keys must not enable the platform.""" + from plugins.platforms.email.adapter import check_email_requirements + for blank in ("", " ", "\n"): + with patch.dict(os.environ, { + "EMAIL_ADDRESS": blank, "EMAIL_PASSWORD": [redacted:secret-assignment] + "EMAIL_IMAP_HOST": blank, "EMAIL_SMTP_HOST": blank, + }, clear=False): + self.assertFalse(check_email_requirements()) + + def test_all_settings_present_satisfies_requirements(self): + """The connected check passes only when all four settings are non-blank.""" + from plugins.platforms.email.adapter import check_email_requirements + with patch.dict(os.environ, { + "EMAIL_ADDRESS": "[redacted:email]", "EMAIL_PASSWORD": "[redacted:secret-assignment]", + "EMAIL_IMAP_HOST": "imap.test.com", "EMAIL_SMTP_HOST": "smtp.test.com", + }, clear=False): + self.assertTrue(check_email_requirements()) + + if __name__ == "__main__": unittest.main()
-
2 PRs closed without merging
From the close comments: done elsewhere 1 · merged via another change 1
Closed issues · 1
- Closed#49736mail adapter fails with `nodename nor servname` despite valid IMAP host/credentials
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #50368
- Closed issues
- 1
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 21 Jun 2026