{"repo":"NousResearch/hermes-agent","public_id":"49736","display_label":"Email adapter IMAP/SMTP host resolution and validation before connecting","summary":"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.","subsystem":"gateway","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50368,"merged_at":"2026-06-21T20:33:53+00:00"},"resolution":{"state":"resolved","by":"pr-50368"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T20:33:53+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":49736,"pr_number":50368,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-21T20:33:53+00:00"}],"members":[{"kind":"issue","number":49736,"title":"mail adapter fails with `nodename nor servname` despite valid IMAP host/credentials","url":null,"state":"closed"},{"kind":"pr","number":49805,"title":"fix(email): resolve IMAP/SMTP host from config and validate before connecting","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b7f6cb9c8","number":0},{"kind":"commit","value":"f79e0a706","number":0},{"kind":"pr","value":"#50368","number":50368}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":49810,"title":"fix(email): normalize EMAIL_IMAP_HOST/EMAIL_SMTP_HOST before connect (#49736)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#49736","number":49736},{"kind":"pr","value":"#50368","number":50368},{"kind":"pr","value":"#49805","number":49805}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":50368,"title":"fix(email): resolve IMAP/SMTP host from config.extra and validate before connecting (#49736)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/email/adapter.py","scripts/release.py","tests/gateway/test_email.py"],"fix_hunks":[{"file":"plugins/platforms/email/adapter.py","hunks":["@@ -159,14 +159,16 @@ def _is_automated_sender(address: str, headers: dict) -> bool:\n     return False\n     \n def check_email_requirements() -> bool:\n-    \"\"\"Check if email platform dependencies are available.\"\"\"\n-    addr = os.getenv(\"EMAIL_ADDRESS\")\n-    pwd = os.getenv(\"EMAIL_PASSWORD\")\n-    imap = os.getenv(\"EMAIL_IMAP_HOST\")\n-    smtp = os.getenv(\"EMAIL_SMTP_HOST\")\n-    if not all([addr, pwd, imap, smtp]):\n-        return False\n-    return True\n+    \"\"\"Check if email platform settings are available and non-blank.\n+\n+    Treats blank/whitespace-only values as missing so an abandoned setup that\n+    left empty ``EMAIL_*`` keys in ``.env`` does not enable the platform (#40715).\n+    \"\"\"\n+    addr = os.getenv(\"EMAIL_ADDRESS\", \"\").strip()\n+    pwd = os.getenv(\"EMAIL_PASSWORD\", \"\").strip()\n+    imap = os.getenv(\"EMAIL_IMAP_HOST\", \"\").strip()\n+    smtp = os.getenv(\"EMAIL_SMTP_HOST\", \"\").strip()\n+    return all([addr, pwd, imap, smtp])\n \n \n def _decode_header_value(raw: str) -> str:","@@ -307,11 +309,20 @@ class EmailAdapter(BasePlatformAdapter):\n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.EMAIL)\n \n-        self._address = os.getenv(\"EMAIL_ADDRESS\", \"\")\n+        # Resolve connection settings from the env vars first, then fall back to\n+        # PlatformConfig.extra (address/imap_host/smtp_host) — the canonical dict\n+        # gateway.config populates and that the \"connected\" check, the\n+        # send-helper, and `hermes config show` already read. Without the\n+        # fallback a config.yaml-only setup left these empty. Host/address values\n+        # are stripped: a stray space or newline made IMAP4_SSL raise the\n+        # misleading ``[Errno 8] nodename nor servname`` (an unresolvable name)\n+        # instead of an obvious \"host not set\" error.\n+        extra = config.extra or {}\n+        self._address = (os.getenv(\"EMAIL_ADDRESS\", \"\") or extra.get(\"address\", \"\")).strip()\n         self._password = os.getenv(\"EMAIL_PASSWORD\", \"\")\n-        self._imap_host = os.getenv(\"EMAIL_IMAP_HOST\", \"\")\n+        self._imap_host = (os.getenv(\"EMAIL_IMAP_HOST\", \"\") or extra.get(\"imap_host\", \"\")).strip()\n         self._imap_port = env_int(\"EMAIL_IMAP_PORT\", 993)\n-        self._smtp_host = os.getenv(\"EMAIL_SMTP_HOST\", \"\")\n+        self._smtp_host = (os.getenv(\"EMAIL_SMTP_HOST\", \"\") or extra.get(\"smtp_host\", \"\")).strip()\n         self._smtp_port = env_int(\"EMAIL_SMTP_PORT\", 587)\n         self._poll_interval = env_int(\"EMAIL_POLL_INTERVAL\", 15)\n ","@@ -319,7 +330,6 @@ def __init__(self, config: PlatformConfig):\n         #   platforms:\n         #     email:\n         #       skip_attachments: true\n-        extra = config.extra or {}\n         self._skip_attachments = extra.get(\"skip_attachments\", False)\n \n         # Track message IDs we've already processed to avoid duplicates","@@ -396,6 +406,36 @@ def _connect(*, ipv4_only: bool = False) -> smtplib.SMTP:\n \n     async def connect(self) -> bool:\n         \"\"\"Connect to the IMAP server and start polling for new messages.\"\"\"\n+        # Validate up front so a missing host surfaces as an actionable config\n+        # error instead of IMAP4_SSL(\"\") raising the cryptic\n+        # ``[Errno 8] nodename nor servname provided, or not known``.\n+        missing = [\n+            name\n+            for name, value in (\n+                (\"EMAIL_ADDRESS\", self._address),\n+                (\"EMAIL_PASSWORD\", self._password),\n+                (\"EMAIL_IMAP_HOST\", self._imap_host),\n+                (\"EMAIL_SMTP_HOST\", self._smtp_host),\n+            )\n+            if not value\n+        ]\n+        if missing:\n+            message = (\n+                \"Not configured — missing \"\n+                + \", \".join(missing)\n+                + \". Set it via `hermes gateway setup` (env) or platforms.email \"\n+                \"in config.yaml.\"\n+            )\n+            logger.error(\"[Email] %s\", message)\n+            # Mark non-retryable so the gateway does NOT keep reconnecting against\n+            # an empty host. A blank-but-present env var (e.g. ``EMAIL_IMAP_HOST=``)\n+            # used to slip past the startup gate and drive an indefinite retry\n+            # loop that leaked memory until the host OOM-killed (#40715).\n+            self._set_fatal_error(\n+                \"email_missing_configuration\", message, retryable=False\n+            )\n+            return False\n+\n         try:\n             # Test IMAP connection\n             imap = imaplib.IMAP4_SSL(self._imap_host, self._imap_port, timeout=30)"]},{"file":"scripts/release.py","hunks":["@@ -120,6 +120,7 @@\n     \"290859878+synapsesx@users.noreply.github.com\": \"synapsesx\",\n     \"157689911+itsflownium@users.noreply.github.com\": \"itsflownium\",\n     \"dirtyren@users.noreply.github.com\": \"dirtyren\",\n+    \"[redacted:email]\": \"devorun\",\n     \"[redacted:email]\": \"x7peeps\",\n     \"[redacted:email]\": \"andressommerhoff\",\n     \"[redacted:email]\": \"x7peeps\","]},{"file":"tests/gateway/test_email.py","hunks":["@@ -1392,5 +1392,95 @@ def test_ipv4_connection_does_not_mutate_global_resolver(self):\n         self.assertIs(_socket.getaddrinfo, original_getaddrinfo)\n \n \n+class TestConnectionConfigResolution(unittest.TestCase):\n+    \"\"\"Host/address resolution and pre-connect validation (#49736).\"\"\"\n+\n+    def test_host_and_address_whitespace_stripped(self):\n+        \"\"\"A stray space/newline must not reach IMAP4_SSL as part of the host.\n+\n+        Whitespace in the host produced the misleading\n+        ``[Errno 8] nodename nor servname`` (unresolvable name) instead of a\n+        successful connection.\n+        \"\"\"\n+        from gateway.config import PlatformConfig\n+        from plugins.platforms.email.adapter import EmailAdapter\n+        with patch.dict(os.environ, {\n+            \"EMAIL_ADDRESS\": \"  [redacted:email]\\n\",\n+            \"EMAIL_PASSWORD\": \"[redacted:secret-assignment]\",\n+            \"EMAIL_IMAP_HOST\": \" imap.test.com \",\n+            \"EMAIL_SMTP_HOST\": \"smtp.test.com\\n\",\n+        }, clear=False):\n+            adapter = EmailAdapter(PlatformConfig(enabled=True))\n+        self.assertEqual(adapter._imap_host, \"imap.test.com\")\n+        self.assertEqual(adapter._smtp_host, \"smtp.test.com\")\n+        self.assertEqual(adapter._address, \"[redacted:email]\")\n+\n+    def test_falls_back_to_platform_config_extra(self):\n+        \"\"\"When env vars are absent, settings come from PlatformConfig.extra —\n+        the same dict gateway.config populates and `hermes config show` reads.\"\"\"\n+        from gateway.config import PlatformConfig\n+        from plugins.platforms.email.adapter import EmailAdapter\n+        cfg = PlatformConfig(enabled=True)\n+        cfg.extra.update({\n+            \"address\": \"[redacted:email]\",\n+            \"imap_host\": \"imap.test.com\",\n+            \"smtp_host\": \"smtp.test.com\",\n+        })\n+        with patch.dict(os.environ, {\n+            \"EMAIL_ADDRESS\": \"\", \"EMAIL_IMAP_HOST\": \"\", \"EMAIL_SMTP_HOST\": \"\",\n+            \"EMAIL_PASSWORD\": \"[redacted:secret-assignment]\",\n+        }, clear=False):\n+            adapter = EmailAdapter(cfg)\n+        self.assertEqual(adapter._imap_host, \"imap.test.com\")\n+        self.assertEqual(adapter._smtp_host, \"smtp.test.com\")\n+        self.assertEqual(adapter._address, \"[redacted:email]\")\n+\n+    def test_connect_aborts_without_attempting_imap_when_host_missing(self):\n+        \"\"\"A missing host returns False without the cryptic DNS error, and marks\n+        the failure non-retryable so the gateway stops reconnecting (#40715).\"\"\"\n+        import asyncio\n+        from gateway.config import PlatformConfig\n+        from plugins.platforms.email.adapter import EmailAdapter\n+        with patch.dict(os.environ, {\n+            \"EMAIL_ADDRESS\": \"[redacted:email]\",\n+            \"EMAIL_PASSWORD\": \"[redacted:secret-assignment]\",\n+            \"EMAIL_IMAP_HOST\": \"\",\n+            \"EMAIL_SMTP_HOST\": \"smtp.test.com\",\n+        }, clear=False):\n+            adapter = EmailAdapter(PlatformConfig(enabled=True))\n+\n+        with patch(\"imaplib.IMAP4_SSL\") as mock_imap:\n+            result = asyncio.run(adapter.connect())\n+\n+        self.assertFalse(result)\n+        mock_imap.assert_not_called()\n+        # The OOM fix (#40715): a blank host must NOT leave the platform in the\n+        # retryable reconnect loop — it is a permanent config error.\n+        self.assertTrue(adapter.has_fatal_error)\n+        self.assertEqual(adapter.fatal_error_code, \"email_missing_configuration\")\n+        self.assertFalse(adapter.fatal_error_retryable)\n+        self.assertIn(\"EMAIL_IMAP_HOST\", adapter.fatal_error_message or \"\")\n+\n+    def test_blank_present_env_vars_are_not_required(self):\n+        \"\"\"Blank/whitespace EMAIL_* values must read as missing (#40715) — an\n+        abandoned setup with empty keys must not enable the platform.\"\"\"\n+        from plugins.platforms.email.adapter import check_email_requirements\n+        for blank in (\"\", \"   \", \"\\n\"):\n+            with patch.dict(os.environ, {\n+                \"EMAIL_ADDRESS\": blank, \"EMAIL_PASSWORD\": [redacted:secret-assignment]\n+                \"EMAIL_IMAP_HOST\": blank, \"EMAIL_SMTP_HOST\": blank,\n+            }, clear=False):\n+                self.assertFalse(check_email_requirements())\n+\n+    def test_all_settings_present_satisfies_requirements(self):\n+        \"\"\"The connected check passes only when all four settings are non-blank.\"\"\"\n+        from plugins.platforms.email.adapter import check_email_requirements\n+        with patch.dict(os.environ, {\n+            \"EMAIL_ADDRESS\": \"[redacted:email]\", \"EMAIL_PASSWORD\": \"[redacted:secret-assignment]\",\n+            \"EMAIL_IMAP_HOST\": \"imap.test.com\", \"EMAIL_SMTP_HOST\": \"smtp.test.com\",\n+        }, clear=False):\n+            self.assertTrue(check_email_requirements())\n+\n+\n if __name__ == \"__main__\":\n     unittest.main()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}