{"repo":"NousResearch/hermes-agent","public_id":"5729","display_label":"Gateway transient network failure resilience and recovery","summary":"These issues and fixes address how the gateway handles transient DNS and network failures across adapters (primarily Telegram and email), covering auto-pause/auto-resume behavior, startup retry logic, boot-time DNS races, and related configuration improvements. Multiple adapters and deployment environments (launchd, systemd, Tailscale) are represented.","subsystem":"agent","counts":{"members":21,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":10,"merged_prs":3,"attempt_prs":8},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":10947},"resolution":{"state":"partially_resolved","by":"pr-35387,pr-50368"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T20:33:53+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.07599999999999998},"fix_edges":[{"issue_number":5729,"pr_number":10947,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":12607,"pr_number":35387,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":35284,"pr_number":35387,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":35936,"pr_number":35387,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":49736,"pr_number":50368,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":5729,"title":"Telegram resolver failure caching + missing degraded-state detection after cold-boot resolver exhaustion","url":null,"state":"closed"},{"kind":"issue","number":9719,"title":"Telegram startup should stay alive and retry after transient network failures","url":null,"state":"closed"},{"kind":"issue","number":32574,"title":"Gateway needs periodic platform liveness watchdog to catch zombie connections across all adapters","url":null,"state":"closed"},{"kind":"issue","number":35284,"title":"[Bug] Telegram adapter auto-pause never auto-recovers after transient DNS failure","url":null,"state":"closed"},{"kind":"issue","number":35936,"title":"feat: configurable auto-resume delay for paused gateway platforms","url":null,"state":"closed"},{"kind":"issue","number":49111,"title":"LLM provider DNS stale: asyncio event loop caches failed getaddrinfo() permanently — gateway alive but all providers unreachable","url":null,"state":"closed"},{"kind":"issue","number":49526,"title":"Tailscale MagicDNS (100.100.100.100) causes Telegram gateway to go permanently silent after transient DNS failure; --replace restart hangs","url":null,"state":"closed"},{"kind":"issue","number":49736,"title":"mail adapter fails with `nodename nor servname` despite valid IMAP host/credentials","url":null,"state":"closed"},{"kind":"issue","number":59290,"title":"Gateway systemd unit races DNS at boot — transient name-resolution failures for Telegram and HTTP MCP servers","url":null,"state":"closed"},{"kind":"issue","number":64839,"title":"Gateway crashes/restarts in a loop when Telegram API connection degrades (macOS launchd KeepAlive)","url":null,"state":"closed"},{"kind":"pr","number":5770,"title":"fix(telegram): increase cold-boot retry budget and refresh fallback IPs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10947","number":10947}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":10947,"title":"fix(telegram): send resilience — retry transient tool sends + increase cold-boot budget (#9101, #5770)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/telegram.py","tests/tools/test_send_message_tool.py","tools/send_message_tool.py"],"fix_hunks":[{"file":"gateway/platforms/telegram.py","hunks":["@@ -665,14 +665,14 @@ def _env_float(name: str, default: float) -> float:\n                 from telegram.error import NetworkError, TimedOut\n             except ImportError:\n                 NetworkError = TimedOut = OSError  # type: ignore[misc,assignment]\n-            _max_connect = 3\n+            _max_connect = 8\n             for _attempt in range(_max_connect):\n                 try:\n                     await self._app.initialize()\n                     break\n                 except (NetworkError, TimedOut, OSError) as init_err:\n                     if _attempt < _max_connect - 1:\n-                        wait = 2 ** _attempt\n+                        wait = min(2 ** _attempt, 15)\n                         logger.warning(\n                             \"[%s] Connect attempt %d/%d failed: %s — retrying in %ds\",\n                             self.name, _attempt + 1, _max_connect, init_err, wait,"]},{"file":"tests/tools/test_send_message_tool.py","hunks":["@@ -816,6 +816,23 @@ def test_html_parse_failure_falls_back_to_plain(self, monkeypatch):\n         second_call = bot.send_message.await_args_list[1].kwargs\n         assert second_call[\"parse_mode\"] is None\n \n+    def test_transient_bad_gateway_retries_text_send(self, monkeypatch):\n+        bot = self._make_bot()\n+        bot.send_message = AsyncMock(\n+            side_effect=[\n+                Exception(\"502 Bad Gateway\"),\n+                SimpleNamespace(message_id=2),\n+            ]\n+        )\n+        _install_telegram_mock(monkeypatch, bot)\n+\n+        with patch(\"asyncio.sleep\", new=AsyncMock()) as sleep_mock:\n+            result = asyncio.run(_send_telegram(\"tok\", \"123\", \"hello\"))\n+\n+        assert result[\"success\"] is True\n+        assert bot.send_message.await_count == 2\n+        sleep_mock.assert_awaited_once()\n+\n \n # ---------------------------------------------------------------------------\n # Tests for Discord thread_id support"]},{"file":"tools/send_message_tool.py","hunks":["@@ -5,6 +5,7 @@\n human-friendly channel names to IDs. Works in both CLI and gateway contexts.\n \"\"\"\n \n+import asyncio\n import json\n import logging\n import os","@@ -48,6 +49,49 @@ def _error(message: str) -> dict:\n     return {\"error\": _sanitize_error_text(message)}\n \n \n+def _telegram_retry_delay(exc: Exception, attempt: int) -> float | None:\n+    retry_after = getattr(exc, \"retry_after\", None)\n+    if retry_after is not None:\n+        try:\n+            return max(float(retry_after), 0.0)\n+        except (TypeError, ValueError):\n+            return 1.0\n+\n+    text = str(exc).lower()\n+    if \"timed out\" in text or \"timeout\" in text:\n+        return None\n+    if (\n+        \"bad gateway\" in text\n+        or \"502\" in text\n+        or \"too many requests\" in text\n+        or \"429\" in text\n+        or \"service unavailable\" in text\n+        or \"503\" in text\n+        or \"gateway timeout\" in text\n+        or \"504\" in text\n+    ):\n+        return float(2 ** attempt)\n+    return None\n+\n+\n+async def _send_telegram_message_with_retry(bot, *, attempts: int = 3, **kwargs):\n+    for attempt in range(attempts):\n+        try:\n+            return await bot.send_message(**kwargs)\n+        except Exception as exc:\n+            delay = _telegram_retry_delay(exc, attempt)\n+            if delay is None or attempt >= attempts - 1:\n+                raise\n+            logger.warning(\n+                \"Transient Telegram send failure (attempt %d/%d), retrying in %.1fs: %s\",\n+                attempt + 1,\n+                attempts,\n+                delay,\n+                _sanitize_error_text(exc),\n+            )\n+            await asyncio.sleep(delay)\n+\n+\n SEND_MESSAGE_SCHEMA = {\n     \"name\": \"send_message\",\n     \"description\": (","@@ -530,7 +574,8 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No\n \n         if formatted.strip():\n             try:\n-                last_msg = await bot.send_message(\n+                last_msg = await _send_telegram_message_with_retry(\n+                    bot,\n                     chat_id=int_chat_id, text=formatted,\n                     parse_mode=send_parse_mode, **thread_kwargs\n                 )","@@ -550,7 +595,8 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No\n                             plain = message\n                     else:\n                         plain = message\n-                    last_msg = await bot.send_message(\n+                    last_msg = await _send_telegram_message_with_retry(\n+                        bot,\n                         chat_id=int_chat_id, text=plain,\n                         parse_mode=None, **thread_kwargs\n                     )\n"]}]},{"kind":"pr","number":17984,"title":"fix(gateway): keep startup retry failures alive for reconnect recovery","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"518f39557b6753a5dc766a05dd14dd5cf2b9edeb","number":0},{"kind":"pr","value":"#9719","number":9719},{"kind":"pr","value":"#26600","number":26600}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":21548,"title":"fix(gateway): detect Telegram polling-pool wedges via correct pool","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52744","number":52744},{"kind":"pr","value":"#48496","number":48496},{"kind":"pr","value":"#48495","number":48495}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":35290,"title":"fix(gateway): auto-recover auto-paused platforms after transient failures","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"45465b0d5d8c7b2db7df6d9e466589cdef9136c0","number":0},{"kind":"pr","value":"#35387","number":35387},{"kind":"pr","value":"#35284","number":35284}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":35387,"title":"fix(gateway): never auto-pause platforms on transient network/DNS failures","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_platform_reconnect.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -1734,9 +1734,9 @@ def __init__(self, config: Optional[GatewayConfig] = None):\n         # as a fallback when a fresh config read transiently returns an empty\n         # model (e.g. an mtime-keyed config-cache miss during a post-interrupt\n         # recovery turn). Without this, the agent is built with model=\"\" and\n-        # every API call fails HTTP 400 \"No models provided\" — the session\n-        # goes silent until the user manually re-sends. See #35314. The \"*\"\n-        # key holds a process-wide last-known-good for first-seen sessions.\n+        # every API call fails HTTP 400 \"No models provided\" — the session goes\n+        # silent until the user manually re-sends. See #35314. ``\"*\"`` holds a\n+        # process-wide last-known-good for sessions seen for the first time.\n         self._last_resolved_model: Dict[str, str] = {}\n         # Overflow buffer for explicit /queue commands.  The adapter-level\n         # _pending_messages dict is a single slot per session (designed for","@@ -2502,8 +2502,8 @@ def _resolve_session_agent_runtime(\n         # successfully resolved for this session (or, failing that, the most\n         # recent one resolved process-wide). Building an agent with model=\"\"\n         # makes every API call fail HTTP 400 \"No models provided\" and the\n-        # session goes silent until the user manually re-sends. getattr guards\n-        # against bare test runners built via object.__new__.\n+        # session goes silent until the user manually re-sends. ``getattr``\n+        # guards against bare test runners built via ``object.__new__``.\n         _last_good = getattr(self, \"_last_resolved_model\", None)\n         if _last_good is not None:\n             if not model:","@@ -2818,10 +2818,12 @@ def _pause_failed_platform(self, platform, *, reason: str = \"\") -> None:\n         \"\"\"Mark a queued platform as paused — keep it in ``_failed_platforms``\n         but stop the reconnect watcher from hammering it.\n \n-        Used by the circuit breaker after ``_PAUSE_AFTER_FAILURES`` consecutive\n-        retryable failures, and by ``/platform pause <name>`` for manual\n-        intervention.  Paused platforms are surfaced in ``/platform list``\n-        and resumed with ``/platform resume <name>``.\n+        Used by ``/platform pause <name>`` for manual operator intervention.\n+        Paused platforms are surfaced in ``/platform list`` and resumed with\n+        ``/platform resume <name>``.  Note: the reconnect watcher does NOT\n+        auto-pause — retryable (network/DNS) failures keep retrying at the\n+        backoff cap indefinitely so a transient outage self-heals without\n+        manual intervention.\n         \"\"\"\n         info = getattr(self, \"_failed_platforms\", {}).get(platform)\n         if info is None:","@@ -5899,15 +5901,17 @@ async def _platform_reconnect_watcher(self) -> None:\n         \"\"\"Background task that periodically retries connecting failed platforms.\n \n         Uses exponential backoff: 30s → 60s → 120s → 240s → 300s (cap).\n-        Retryable failures keep retrying at the backoff cap indefinitely\n-        — but if a platform fails ``_PAUSE_AFTER_FAILURES`` times in a row\n-        without ever succeeding, it is *paused*: kept in the retry queue\n-        but no longer hammered.  The user surfaces it with ``/platform list``\n-        and resumes it with ``/platform resume <name>``.  Non-retryable\n-        failures (bad auth, etc.) still drop out of the queue immediately.\n+        Retryable failures (network/DNS blips) keep retrying at the backoff\n+        cap indefinitely — they self-heal once connectivity returns, so a\n+        transient outage never requires manual intervention. Non-retryable\n+        failures (bad auth, etc.) drop out of the queue immediately. The\n+        circuit breaker (``_pause_failed_platform`` / ``/platform pause``)\n+        remains available for manual operator control via ``/platform list``\n+        and ``/platform resume <name>``, but is no longer triggered\n+        automatically — auto-pausing a recovered platform was the cause of\n+        bots silently staying dead after a transient DNS failure.\n         \"\"\"\n         _BACKOFF_CAP = 300  # 5 minutes max between retries\n-        _PAUSE_AFTER_FAILURES = 10  # circuit-breaker threshold\n \n         await asyncio.sleep(10)  # initial delay — let startup finish\n         while self._running:","@@ -6002,14 +6006,14 @@ async def _platform_reconnect_watcher(self) -> None:\n                             \"Reconnect %s failed, next retry in %ds\",\n                             platform.value, backoff,\n                         )\n-                        if attempt >= _PAUSE_AFTER_FAILURES:\n-                            self._pause_failed_platform(\n-                                platform,\n-                                reason=(\n-                                    adapter.fatal_error_message\n-                                    or \"failed to reconnect\"\n-                                ),\n-                            )\n+                        # Retryable failures (network/DNS blips) keep retrying\n+                        # at the backoff cap indefinitely — they self-heal once\n+                        # connectivity returns. We do NOT auto-pause them: a\n+                        # transient outage must never require manual `/platform\n+                        # resume` to recover. Non-retryable failures (bad auth,\n+                        # etc.) already drop out of the queue via the\n+                        # `not fatal_error_retryable` branch above, so anything\n+                        # reaching here is by definition retryable.\n                 except Exception as e:\n                     self._update_platform_runtime_status(\n                         platform.value,","@@ -6024,8 +6028,9 @@ async def _platform_reconnect_watcher(self) -> None:\n                         \"Reconnect %s error: %s, next retry in %ds\",\n                         platform.value, e, backoff,\n                     )\n-                    if attempt >= _PAUSE_AFTER_FAILURES:\n-                        self._pause_failed_platform(platform, reason=str(e))\n+                    # A raised exception during reconnect (connect timeout, DNS\n+                    # resolution failure, etc.) is inherently transient — keep\n+                    # retrying at the backoff cap rather than auto-pausing.\n \n             # Check every 10 seconds for platforms that need reconnection\n             for _ in range(10):"]},{"file":"tests/gateway/test_platform_reconnect.py","hunks":["@@ -294,19 +294,20 @@ async def fake_sleep(n):\n         assert runner._failed_platforms[Platform.TELEGRAM][\"attempts\"] == 2\n \n     @pytest.mark.asyncio\n-    async def test_reconnect_pauses_after_circuit_breaker_threshold(self):\n-        \"\"\"After enough consecutive retryable failures, the watcher should\n-        *pause* the platform (keep it in the queue but stop hammering it),\n-        not drop it. The user resumes via /platform resume.\n+    async def test_reconnect_never_auto_pauses_retryable_failures(self):\n+        \"\"\"Retryable failures (network/DNS) must keep retrying indefinitely —\n+        the watcher must NOT auto-pause them. Auto-pausing a transiently-failed\n+        platform left bots silently dead after a DNS blip (#35284). The pause\n+        circuit breaker remains available for manual /platform pause only.\n         \"\"\"\n         runner = _make_runner()\n \n         platform_config = PlatformConfig(enabled=True, token=\"test\")\n-        # 9 prior attempts — the next failure will be the 10th and should\n-        # trip the circuit breaker.\n+        # Far past the old circuit-breaker threshold (10): even after many\n+        # consecutive retryable failures the platform must stay unpaused.\n         runner._failed_platforms[Platform.TELEGRAM] = {\n             \"config\": platform_config,\n-            \"attempts\": 9,\n+            \"attempts\": 25,\n             \"next_retry\": time.monotonic() - 1,\n         }\n ","@@ -332,12 +333,15 @@ async def fake_sleep(n):\n \n             await run_one_iteration()\n \n-        # Platform stays in queue — paused, not dropped\n+        # Platform stays in queue and keeps retrying — never auto-paused.\n         assert Platform.TELEGRAM in runner._failed_platforms\n         info = runner._failed_platforms[Platform.TELEGRAM]\n-        assert info[\"paused\"] is True\n-        assert info[\"attempts\"] == 10\n-        assert \"pause_reason\" in info\n+        assert info.get(\"paused\") is not True\n+        assert \"pause_reason\" not in info\n+        assert info[\"attempts\"] == 26\n+        # next_retry is pushed out by the backoff (capped at 300s), not inf.\n+        assert info[\"next_retry\"] != float(\"inf\")\n+        assert info[\"next_retry\"] > time.monotonic()\n \n     @pytest.mark.asyncio\n     async def test_reconnect_skips_paused_platforms(self):\n"]}]},{"kind":"pr","number":35938,"title":"feat(gateway): configurable auto-resume delay for paused platforms","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"45465b0d5d8c7b2db7df6d9e466589cdef9136c0","number":0},{"kind":"pr","value":"#35387","number":35387}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"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","implication":"superseded"},"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 = [redacted:secret-assignment]\"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     \"[redacted:email]\": \"synapsesx\",\n     \"[redacted:email]\": \"itsflownium\",\n     \"[redacted:email]\": \"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\": \"secret\",\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\": \"secret\",\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\": \"secret\",\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\": blank,\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\": \"secret\",\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"]}]},{"kind":"pr","number":65012,"title":"fix(launchd): add ThrottleInterval=30 to gateway plist","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}