diff --git a/kora_cli/clients/purelymail_client.py b/kora_cli/clients/purelymail_client.py index 86084820777b..a439d4fa7aef 100644 --- a/kora_cli/clients/purelymail_client.py +++ b/kora_cli/clients/purelymail_client.py @@ -246,6 +246,7 @@ async def send_email( body_html: Optional[str] = None, in_reply_to: Optional[str] = None, attachments: Optional[list[Attachment]] = None, + caller_actor_kind: Optional[str] = None, ) -> SendResult: """Send one email. Returns a :class:`SendResult` always — failures are surfaced via ``status="failed"`` + ``error`` @@ -287,6 +288,7 @@ async def send_email( subject=subject, in_reply_to=in_reply_to, result=result, + caller_actor_kind=caller_actor_kind, ) return result @@ -524,12 +526,19 @@ def _append_outbound_log( subject: str, in_reply_to: Optional[str], result: SendResult, + caller_actor_kind: Optional[str] = None, ) -> None: """Append one line to the outbound JSONL. Body NEVER logged. Fail-soft: a log write error must not crash a successful send (or mask a failed-send result). Logged at WARN if it ever happens. + + ``caller_actor_kind`` (KR-MCP-SEND-TOOLS): when a send is + driven by an MCP tool call, the caller's actor_kind appears + here for audit attribution. ``None`` for internal/runtime- + driven sends (e.g. KR-FEAT-AI-RESPONSE-LOOP email replies). + Backwards-compatible — consumers handle absence. """ entry = { "sent_at": result.sent_at.strftime("%Y-%m-%dT%H:%M:%SZ"), @@ -542,6 +551,7 @@ def _append_outbound_log( "smtp_code": result.smtp_code, "error": result.error, "retry_count": result.retry_count, + "caller_actor_kind": caller_actor_kind, } try: log_path = _outbound_log_path() @@ -571,6 +581,7 @@ async def send_email_internal( body_html: Optional[str] = None, in_reply_to: Optional[str] = None, attachments: Optional[list[Attachment]] = None, + caller_actor_kind: Optional[str] = None, ) -> SendResult: """One-shot send for callers inside Kora's runtime. @@ -580,8 +591,12 @@ async def send_email_internal( instantiate the client once and call ``send_email`` per message to avoid repeated env reads. - NOT exposed via ``/mcp`` — that's a separate bucket - (``kora__send_email`` MCP tool, follow-on). + KR-MCP-SEND-TOOLS update: ``caller_actor_kind`` propagates to + the JSONL audit log when the send is driven by an MCP tool + call. Daemon-coordinator-managed paths prefer the listener + accessor (``current_purelymail_client``) over this one-shot + helper to share a single client instance + reduce env-read + overhead. """ client = PurelymailClient() return await client.send_email( @@ -592,4 +607,5 @@ async def send_email_internal( body_html=body_html, in_reply_to=in_reply_to, attachments=attachments, + caller_actor_kind=caller_actor_kind, ) diff --git a/kora_cli/handlers/slack_dm_handler.py b/kora_cli/handlers/slack_dm_handler.py index 2e77cdb699aa..c1b9a2eed490 100644 --- a/kora_cli/handlers/slack_dm_handler.py +++ b/kora_cli/handlers/slack_dm_handler.py @@ -664,27 +664,60 @@ def _record_inference_to_cost_ladder( ) def _get_or_create_slack_client(self) -> Optional[Any]: - """Lazy SlackClient construction. - - Returns the cached client if one is set (test injection or - prior successful construction). Otherwise tries to construct - one; on ``SlackClientNotConfigured`` returns ``None`` so the - caller can record a failed-outbound entry without crashing. + """Get the SlackClient — daemon-coordinator-managed first; + lazy-create as fallback. + + Resolution order: + + 1. ``self._slack_client`` (test injection or prior cache) + 2. ``current_slack_client()`` from the daemon listener + (KR-MCP-SEND-TOOLS) — present once the daemon boots + with ``slack_client`` listener registered + 3. Lazy-construct a fresh SlackClient (legacy path — + keeps standalone-handler tests passing without daemon + listeners running) + + The lazy-construct fallback preserves the pre-KR-MCP-SEND- + TOOLS contract: a handler instantiated outside the daemon + (test fixtures, ad-hoc scripts) still acquires a client. + Production daemon paths get the shared listener instance. + + Returns ``None`` if every path fails (KORA_SLACK_BOT_TOKEN + unset). """ if self._slack_client is not None: return self._slack_client + + # Listener-managed singleton (KR-MCP-SEND-TOOLS). try: - from kora_cli.clients.slack_client import ( - SlackClient, - SlackClientNotConfigured, + from kora_cli.listeners.slack_client_listener import ( + current_slack_client, ) + shared = current_slack_client() + if shared is not None: + self._slack_client = shared + return self._slack_client + except Exception as exc: + # Import error / accessor blow-up — fall through to + # lazy-construct. Don't swallow silently; log so the + # operator can correlate. + logger.debug( + "[kora.slack_dm] current_slack_client lookup failed: %r — " + "falling back to lazy construct", + exc, + ) + + # Lazy-construct fallback (legacy / standalone-handler path). + try: + from kora_cli.clients.slack_client import SlackClient + self._slack_client = SlackClient() return self._slack_client except Exception as exc: # SlackClientNotConfigured is the expected failure when - # KORA_SLACK_BOT_TOKEN is unset. Log once + cache None so - # subsequent inbound events don't re-attempt. + # KORA_SLACK_BOT_TOKEN is unset. Log once + cache None + # so subsequent inbound events don't re-attempt. logger.warning( "[kora.slack_dm] SlackClient unavailable: %r — " "outbound replies disabled", @@ -710,6 +743,10 @@ def _append_outbound_log_entry( output_tokens: Optional[int] = None, reasoning_duration_ms: Optional[int] = None, reasoning_error: Optional[str] = None, + # KR-MCP-SEND-TOOLS — when a send is driven by an MCP tool + # call, the caller's actor_kind appears here for audit + # attribution. None (omitted) on handler-driven sends. + caller_actor_kind: Optional[str] = None, ) -> None: """Outbound-side JSONL entry. Distinct schema from inbound entries (``sent_at`` instead of ``received_at``) so operator @@ -726,6 +763,14 @@ def _append_outbound_log_entry( ``ResponseResult.error`` (``cost_ladder_halted`` / ``sdk_5xx`` / ``engine_unavailable`` / etc.) — None on successful reasoning calls + + ``caller_actor_kind`` (KR-MCP-SEND-TOOLS): when a send is + driven by an MCP tool call, the caller's actor_kind appears + here for audit attribution. ``None`` (omitted) for + handler-driven sends (echo replies + reasoning-engine + replies from CC#3's KR-FEAT-AI-RESPONSE-LOOP). Backwards- + compatible — consumers handle absence; existing entries + without the field keep parsing. """ entry: Dict[str, Any] = { "sent_at": _now_iso(), @@ -751,6 +796,8 @@ def _append_outbound_log_entry( entry["reasoning_duration_ms"] = int(reasoning_duration_ms) if reasoning_error is not None: entry["reasoning_error"] = reasoning_error + if caller_actor_kind is not None: + entry["caller_actor_kind"] = caller_actor_kind try: self._log_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/kora_cli/listeners/__init__.py b/kora_cli/listeners/__init__.py index e0fc40b274e5..d2a441cd8cb8 100644 --- a/kora_cli/listeners/__init__.py +++ b/kora_cli/listeners/__init__.py @@ -35,3 +35,11 @@ # aborts boot). Module-level `current_reasoning_engine()` accessor # mirrors `current_pool()` so SlackDMHandler reads cross-cuttingly. from kora_cli.listeners import reasoning_engine_listener # noqa: F401 +# KR-MCP-SEND-TOOLS — promote SlackClient + PurelymailClient from +# per-handler lazy construction to daemon-coordinator-managed +# singletons. Both fail-soft on missing auth env (Slack outbound / +# email outbound are capabilities, not gates — daemon boots +# without them). Imported AFTER mcp_consumption so the same lazy- +# constructed-fallback pattern is established. +from kora_cli.listeners import slack_client_listener # noqa: F401 +from kora_cli.listeners import purelymail_client_listener # noqa: F401 diff --git a/kora_cli/listeners/mcp_tools.py b/kora_cli/listeners/mcp_tools.py index 35cdcdb95976..159232b665a0 100644 --- a/kora_cli/listeners/mcp_tools.py +++ b/kora_cli/listeners/mcp_tools.py @@ -49,7 +49,7 @@ import logging import os -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Awaitable, Callable, Dict, List, Optional from pydantic import BaseModel, Field @@ -995,6 +995,321 @@ async def _dispatch_send_webhook_test_event( ) +# --------------------------------------------------------------------------- +# Tool 9: kora__send_slack_dm (KR-MCP-SEND-TOOLS) +# --------------------------------------------------------------------------- + + +SEND_SLACK_DM_TOOL: Dict[str, Any] = { + "name": "kora__send_slack_dm", + "description": ( + "Send a Slack DM via Kora's SlackClient. Restricted to DM " + "channels (channel_id must start with 'D' OR match " + "KORA_SLACK_JOSHUA_USER_ID to prevent accidental channel " + "broadcast). Bot identity is Kora; from-identity is NOT " + "caller-controllable. Caller must have kora__send_slack_dm " + "in allowed_caps. Args: channel_id, text (≤4000 chars), " + "thread_ts (optional). Returns slack_message_ts on success." + ), + "inputSchema": { + "type": "object", + "properties": { + "channel_id": {"type": "string", "minLength": 1}, + "text": {"type": "string", "minLength": 1, "maxLength": 4000}, + "thread_ts": {"type": ["string", "null"]}, + }, + "required": ["channel_id", "text"], + "additionalProperties": False, + }, + "requires_cap_gate": True, + "dev_only": False, +} + + +SLACK_DM_TEXT_MAX_LEN = 4000 +_JOSHUA_USER_ID_ENV = "KORA_SLACK_JOSHUA_USER_ID" + + +class SendSlackDmResult(BaseModel): + success: bool + slack_message_ts: Optional[str] = None + sent_at: str + caller_actor_kind: str + + +async def _execute_send_slack_dm( + *, + channel_id: str, + text: str, + thread_ts: Optional[str], + caller: Caller, +) -> SendSlackDmResult: + # Input validation — at the MCP layer so the error envelope is + # JSON-RPC-shaped (-32602 invalid_params) rather than client + # exception text. + if not isinstance(channel_id, str) or not channel_id.strip(): + raise _ST2_ToolInputError("channel_id is required (non-empty)") + if not isinstance(text, str) or not text.strip(): + raise _ST2_ToolInputError("text is required (non-empty)") + if len(text) > SLACK_DM_TEXT_MAX_LEN: + raise _ST2_ToolInputError( + f"text exceeds Slack's {SLACK_DM_TEXT_MAX_LEN}-char limit " + f"({len(text)} > {SLACK_DM_TEXT_MAX_LEN})" + ) + + # channel_id validation — DM channels only (D-prefix) OR + # Joshua's user ID (which Slack auto-resolves to DM channel + # on bot post). Reject U... user-IDs at the MCP layer (would + # require an extra Slack API call to resolve; operator should + # pre-resolve). Defense against accidental channel broadcast. + joshua_user_id = os.environ.get(_JOSHUA_USER_ID_ENV, "").strip() + if not ( + channel_id.startswith("D") + or (joshua_user_id and channel_id == joshua_user_id) + ): + raise _ST2_ToolInputError( + f"channel_id {channel_id!r} must start with 'D' (DM " + f"channel) or match KORA_SLACK_JOSHUA_USER_ID; " + f"non-DM channel sends are out of scope for this tool" + ) + + # Resolve the daemon-coordinator-managed SlackClient. + from kora_cli.listeners.slack_client_listener import ( + current_slack_client, + ) + + client = current_slack_client() + if client is None: + # Surface as -32001 (capability_denied) with a distinct + # error_code so callers can branch on availability vs. ACL. + raise _ST2_ToolInputError( + "slack_client_unavailable: SlackClient not registered " + "(KORA_SLACK_BOT_TOKEN unset or daemon not running with " + "slack_client listener)" + ) + + try: + response = await client.post_dm( + channel_id=channel_id, text=text, thread_ts=thread_ts + ) + except Exception as exc: + # Sanitize — never let the bot token leak in error text. + raise _ST2_ToolInputError( + f"slack_send_failed: {type(exc).__name__}" + ) + + # Audit log entry via a fresh handler instance (just for the + # outbound-log helper). The handler doesn't need an event payload + # — we're using its outbound-log writer to keep entries in one + # file with consistent shape. + sent_at_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + slack_ts = response.get("ts") if isinstance(response, dict) else None + + try: + from kora_cli.handlers.slack_dm_handler import SlackDMHandler + + SlackDMHandler()._append_outbound_log_entry( + channel_id=channel_id, + thread_ts=thread_ts, + text=text, + slack_message_ts=str(slack_ts) if slack_ts else None, + send_status="ok", + caller_actor_kind=caller.actor_kind, + ) + except Exception as log_exc: # pragma: no cover — log fail-soft + logger.warning( + "[kora.mcp.send_slack_dm] outbound log write failed: %r", + log_exc, + ) + + _emit_audit( + tool="kora__send_slack_dm", + caller=caller, + args={ + "channel_id": channel_id, + "thread_ts": thread_ts, + "text_len": len(text), + }, + result=f"slack_ts={slack_ts}", + ) + + return SendSlackDmResult( + success=True, + slack_message_ts=str(slack_ts) if slack_ts else None, + sent_at=sent_at_iso, + caller_actor_kind=caller.actor_kind, + ) + + +async def _dispatch_send_slack_dm( + params: Dict[str, Any], caller: Caller +) -> BaseModel: + return await _execute_send_slack_dm( + channel_id=params.get("channel_id", ""), + text=params.get("text", ""), + thread_ts=params.get("thread_ts"), + caller=caller, + ) + + +# --------------------------------------------------------------------------- +# Tool 10: kora__send_email (KR-MCP-SEND-TOOLS) +# --------------------------------------------------------------------------- + + +SEND_EMAIL_TOOL: Dict[str, Any] = { + "name": "kora__send_email", + "description": ( + "Send an email via Kora's PurelymailClient (SMTP). " + "from_addr is derived from KORA_PUREMAIL_SMTP_USERNAME and " + "is NOT caller-controllable (security: prevents sender " + "impersonation). Recipient cap (≤10) + from-domain " + "allowlist + 30s timeout + retry-on-transient enforced by " + "the underlying client. NO attachments via this tool — " + "deferred to KR-MCP-SEND-TOOLS-ATTACHMENTS. Caller must " + "have kora__send_email in allowed_caps." + ), + "inputSchema": { + "type": "object", + "properties": { + "to": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "minItems": 1, + "maxItems": 10, + }, + "subject": {"type": "string", "minLength": 1}, + "body_text": {"type": "string", "minLength": 1}, + "body_html": {"type": ["string", "null"]}, + "in_reply_to": {"type": ["string", "null"]}, + }, + "required": ["to", "subject", "body_text"], + "additionalProperties": False, + }, + "requires_cap_gate": True, + "dev_only": False, +} + + +class SendEmailResult(BaseModel): + success: bool + message_id: Optional[str] = None + smtp_code: Optional[int] = None + sent_at: str + caller_actor_kind: str + error: Optional[str] = None + + +async def _execute_send_email( + *, + to: List[str], + subject: str, + body_text: str, + body_html: Optional[str], + in_reply_to: Optional[str], + caller: Caller, +) -> SendEmailResult: + # MCP-layer validation. The PurelymailClient enforces its own + # caps (≤10 recipients; per-/total-attachment sizes; domain + # allowlist) but we surface JSON-RPC-shaped errors at this + # layer for malformed input + before any SMTP traffic. + if not isinstance(to, list) or not to: + raise _ST2_ToolInputError("to must be a non-empty list of strings") + if not isinstance(subject, str) or not subject.strip(): + raise _ST2_ToolInputError("subject is required (non-empty)") + if not isinstance(body_text, str) or not body_text.strip(): + raise _ST2_ToolInputError("body_text is required (non-empty)") + if len(to) > 10: + raise _ST2_ToolInputError( + f"to has {len(to)} addresses; max 10 per send " + "(defense against accidental mass-send)" + ) + for addr in to: + if not isinstance(addr, str) or "@" not in addr: + raise _ST2_ToolInputError( + f"recipient {addr!r} is malformed (must be a string " + "containing '@')" + ) + + # Resolve the daemon-coordinator-managed PurelymailClient. + from kora_cli.listeners.purelymail_client_listener import ( + current_purelymail_client, + ) + + client = current_purelymail_client() + if client is None: + raise _ST2_ToolInputError( + "purelymail_client_unavailable: PurelymailClient not " + "registered (SMTP auth env unset or daemon not running " + "with purelymail_client listener)" + ) + + # from_addr is the username env value — never caller-controllable. + from_addr = os.environ.get("KORA_PUREMAIL_SMTP_USERNAME", "").strip() + if not from_addr: + raise _ST2_ToolInputError( + "purelymail_client_unavailable: " + "KORA_PUREMAIL_SMTP_USERNAME env is unset" + ) + + try: + result = await client.send_email( + from_addr=from_addr, + to=to, + subject=subject, + body_text=body_text, + body_html=body_html, + in_reply_to=in_reply_to, + attachments=None, # NOT supported in this bucket + caller_actor_kind=caller.actor_kind, + ) + except Exception as exc: + # Sanitize — PurelymailClient already strips the password + # from any error string it raises; we add the type-name + # prefix without leaking caller-controlled content. + raise _ST2_ToolInputError( + f"email_send_failed: {type(exc).__name__}" + ) + + _emit_audit( + tool="kora__send_email", + caller=caller, + args={ + "to": to, + "subject_len": len(subject), + "body_text_len": len(body_text), + "has_html": body_html is not None, + "in_reply_to": in_reply_to, + }, + result=( + f"status={result.status} smtp_code={result.smtp_code} " + f"message_id={result.message_id}" + ), + ) + + return SendEmailResult( + success=result.status == "ok", + message_id=result.message_id, + smtp_code=result.smtp_code, + sent_at=result.sent_at.strftime("%Y-%m-%dT%H:%M:%SZ"), + caller_actor_kind=caller.actor_kind, + error=result.error, + ) + + +async def _dispatch_send_email( + params: Dict[str, Any], caller: Caller +) -> BaseModel: + return await _execute_send_email( + to=params.get("to", []), + subject=params.get("subject", ""), + body_text=params.get("body_text", ""), + body_html=params.get("body_html"), + in_reply_to=params.get("in_reply_to"), + caller=caller, + ) + + # --------------------------------------------------------------------------- # Public ST2 descriptor + dispatch tables # --------------------------------------------------------------------------- @@ -1013,6 +1328,9 @@ async def _dispatch_send_webhook_test_event( REQUEST_STATE_TRANSITION_TOOL, CREATE_SEA_TICKET_TOOL, SEND_WEBHOOK_TEST_EVENT_TOOL, + # KR-MCP-SEND-TOOLS additions + SEND_SLACK_DM_TOOL, + SEND_EMAIL_TOOL, ] @@ -1024,4 +1342,7 @@ async def _dispatch_send_webhook_test_event( "kora__request_state_transition": _dispatch_request_state_transition, "kora__create_sea_ticket": _dispatch_create_sea_ticket, "kora__send_webhook_test_event": _dispatch_send_webhook_test_event, + # KR-MCP-SEND-TOOLS additions + "kora__send_slack_dm": _dispatch_send_slack_dm, + "kora__send_email": _dispatch_send_email, } diff --git a/kora_cli/listeners/purelymail_client_listener.py b/kora_cli/listeners/purelymail_client_listener.py new file mode 100644 index 000000000000..0402cd576672 --- /dev/null +++ b/kora_cli/listeners/purelymail_client_listener.py @@ -0,0 +1,138 @@ +"""PurelymailClient daemon listener (KR-MCP-SEND-TOOLS). + +Promotes :class:`kora_cli.clients.purelymail_client.PurelymailClient` +from per-call construction (via the ``send_email_internal`` helper) +to a daemon-coordinator-managed singleton. The +``kora__send_email`` MCP tool consumes the singleton via +:func:`current_purelymail_client`. + +# Fail-soft startup + +If ``KORA_PUREMAIL_SMTP_USERNAME`` or +``KORA_PUREMAIL_SMTP_APP_PASSWORD`` is unset / empty, +:class:`PurelymailClient` raises :class:`PurelymailConfigError`. +The listener catches that + leaves the singleton as ``None`` — +daemon boot doesn't fail. The ``kora__send_email`` MCP tool +returns -32001 ``purelymail_client_unavailable`` when the +singleton is missing. + +# Why this listener doesn't validate the from-domain allowlist at startup + +``KORA_EMAIL_KORA_ALLOWED_FROM_DOMAINS`` is consulted PER SEND +inside :meth:`PurelymailClient.send_email` (not at construction). +Listener startup intentionally doesn't check it — operators can +set the allowlist after the daemon starts; the next send will +either succeed or raise :class:`PurelymailConfigError` with a +clear message. +""" + +from __future__ import annotations + +import logging +from typing import Optional + +from kora_cli.daemon import DEFAULT_SHUTDOWN_TIMEOUT, register_daemon_listener + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Module-level singleton + accessor +# --------------------------------------------------------------------------- + + +_purelymail_client_singleton: Optional["object"] = None + + +def _set_singleton(client: object) -> None: + global _purelymail_client_singleton + _purelymail_client_singleton = client + + +def _clear_singleton() -> None: + global _purelymail_client_singleton + _purelymail_client_singleton = None + + +def current_purelymail_client() -> Optional["object"]: + """Return the live :class:`PurelymailClient`, or ``None``. + + ``None`` cases: + - Daemon not running + - Listener registered but not yet started + - Listener started but SMTP auth env unset → construction + skipped + singleton stays ``None`` (fail-soft) + - Listener stopped (post-shutdown) + """ + return _purelymail_client_singleton + + +# --------------------------------------------------------------------------- +# Listener +# --------------------------------------------------------------------------- + + +class PurelymailClientListener: + """Holds the live :class:`PurelymailClient`.""" + + async def startup(self) -> None: + """Try to construct a PurelymailClient; fail-soft if env unset. + + The daemon boots regardless — outbound email is a capability, + not a gate. Operators can enable email later by setting the + SMTP auth envs + restarting. + """ + try: + from kora_cli.clients.purelymail_client import ( + PurelymailClient, + PurelymailConfigError, + ) + + try: + client = PurelymailClient() + except PurelymailConfigError as exc: + logger.info( + "[kora.purelymail_client_listener] SMTP auth env " + "unset — Purelymail outbound disabled (%s)", + exc, + ) + _clear_singleton() + return + except Exception as exc: + logger.warning( + "[kora.purelymail_client_listener] startup raised %r — " + "Purelymail outbound disabled", + exc, + ) + _clear_singleton() + return + + _set_singleton(client) + logger.info( + "[kora.purelymail_client_listener] PurelymailClient " + "constructed (host=%s port=%s); ready for outbound sends", + getattr(client, "_host", ""), + getattr(client, "_port", ""), + ) + + async def shutdown(self) -> None: + """Clear the singleton. PurelymailClient has no persistent + transport — each ``send_email`` opens a fresh SMTP + connection per the ST1 design.""" + _clear_singleton() + logger.info( + "[kora.purelymail_client_listener] PurelymailClient cleared" + ) + + +# --------------------------------------------------------------------------- +# Factory + registration (import-time side effect) +# --------------------------------------------------------------------------- + + +def _factory(): + listener = PurelymailClientListener() + return (listener.startup, listener.shutdown, DEFAULT_SHUTDOWN_TIMEOUT) + + +register_daemon_listener("purelymail_client", _factory) diff --git a/kora_cli/listeners/slack_client_listener.py b/kora_cli/listeners/slack_client_listener.py new file mode 100644 index 000000000000..9fea21a9bc19 --- /dev/null +++ b/kora_cli/listeners/slack_client_listener.py @@ -0,0 +1,142 @@ +"""SlackClient daemon listener (KR-MCP-SEND-TOOLS). + +Promotes :class:`kora_cli.clients.slack_client.SlackClient` from +per-handler lazy construction to a daemon-coordinator-managed +singleton. Both the Slack DM handler's reply path AND the +``kora__send_slack_dm`` MCP tool consume the same instance via +:func:`current_slack_client`. + +# Why promote + +Pre-KR-MCP-SEND-TOOLS, the Slack DM handler constructed its own +SlackClient lazily on the first reply. That worked when the handler +was the sole outbound caller. KR-MCP-SEND-TOOLS adds an MCP tool +that also needs to call ``post_dm``; rather than re-implementing +construction in the MCP layer, we centralize on a singleton. + +# Fail-soft startup + +If ``KORA_SLACK_BOT_TOKEN`` is unset (Slack outbound not yet +configured), :meth:`SlackClient.__init__` raises +``SlackClientNotConfigured``. The listener catches that + leaves +the singleton as ``None`` — daemon boot doesn't fail. Outbound +callers see ``current_slack_client() is None`` and act +accordingly (handler logs WARN + records failed-outbound entry; +MCP tool returns -32001 ``slack_client_unavailable``). + +# Backwards compat + +The Slack DM handler's :func:`_get_or_create_slack_client` checks +the listener accessor FIRST + falls back to lazy construct if the +listener isn't running. Standalone-handler test fixtures keep +working without daemon listeners. Production daemon paths get the +shared instance. +""" + +from __future__ import annotations + +import logging +from typing import Optional + +from kora_cli.daemon import DEFAULT_SHUTDOWN_TIMEOUT, register_daemon_listener + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Module-level singleton + accessor +# --------------------------------------------------------------------------- + + +_slack_client_singleton: Optional["object"] = None + + +def _set_singleton(client: object) -> None: + global _slack_client_singleton + _slack_client_singleton = client + + +def _clear_singleton() -> None: + global _slack_client_singleton + _slack_client_singleton = None + + +def current_slack_client() -> Optional["object"]: + """Return the live :class:`SlackClient` instance, or ``None``. + + ``None`` cases: + - Daemon not running + - Listener registered but not yet started + - Listener started but ``KORA_SLACK_BOT_TOKEN`` unset → + construction skipped + singleton stays ``None`` (fail-soft) + - Listener stopped (post-shutdown) + """ + return _slack_client_singleton + + +# --------------------------------------------------------------------------- +# Listener +# --------------------------------------------------------------------------- + + +class SlackClientListener: + """Holds the live :class:`SlackClient` for the daemon's lifetime.""" + + async def startup(self) -> None: + """Try to construct a SlackClient; fail-soft if env unset. + + The daemon boots regardless — Slack outbound is a capability, + not a gate. Operators can enable Slack later by setting + ``KORA_SLACK_BOT_TOKEN`` + restarting. + """ + try: + from kora_cli.clients.slack_client import ( + SlackClient, + SlackClientNotConfigured, + ) + + try: + client = SlackClient() + except SlackClientNotConfigured as exc: + logger.info( + "[kora.slack_client_listener] KORA_SLACK_BOT_TOKEN " + "unset — Slack outbound disabled (%s)", + exc, + ) + _clear_singleton() + return + except Exception as exc: + # Import-side error — log + leave singleton None. + logger.warning( + "[kora.slack_client_listener] startup raised %r — " + "Slack outbound disabled", + exc, + ) + _clear_singleton() + return + + _set_singleton(client) + logger.info( + "[kora.slack_client_listener] SlackClient constructed; " + "ready for outbound sends" + ) + + async def shutdown(self) -> None: + """Clear the singleton. SlackClient has no transport state + to close (httpx clients are created per-call inside + ``post_dm``).""" + _clear_singleton() + logger.info("[kora.slack_client_listener] SlackClient cleared") + + +# --------------------------------------------------------------------------- +# Factory + registration (import-time side effect) +# --------------------------------------------------------------------------- + + +def _factory(): + listener = SlackClientListener() + return (listener.startup, listener.shutdown, DEFAULT_SHUTDOWN_TIMEOUT) + + +register_daemon_listener("slack_client", _factory) diff --git a/tests/kora_cli/test_listeners/test_mcp_send_tools.py b/tests/kora_cli/test_listeners/test_mcp_send_tools.py new file mode 100644 index 000000000000..5b1d3e8bd04a --- /dev/null +++ b/tests/kora_cli/test_listeners/test_mcp_send_tools.py @@ -0,0 +1,500 @@ +"""Tests for the 2 new MCP send tools (KR-MCP-SEND-TOOLS). + +Covers: + - kora__send_slack_dm dispatcher: input validation, channel_id + constraints, SlackClient unavailable, successful call, JSONL + outbound entry with caller_actor_kind, token absence in error + - kora__send_email dispatcher: input validation, recipient cap, + PurelymailClient unavailable, successful call, JSONL outbound + entry with caller_actor_kind, password absence in error + - Both tools registered in ST2_TOOL_DESCRIPTORS + ST2_TOOL_DISPATCH + - Both tools have requires_cap_gate=True +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from kora_cli.clients.purelymail_types import SendResult +from kora_cli.listeners.mcp_caller_auth import Caller +from kora_cli.listeners.mcp_tools import ( + SEND_EMAIL_TOOL, + SEND_SLACK_DM_TOOL, + ST2_TOOL_DESCRIPTORS, + ST2_TOOL_DISPATCH, + SendEmailResult, + SendSlackDmResult, + _dispatch_send_email, + _dispatch_send_slack_dm, + _ST2_ToolInputError, +) + + +_TEST_SLACK_TOKEN = "xoxb-secret-bot-token-xyz" +_TEST_SMTP_PASSWORD = "very-secret-app-password-xyz" + + +@pytest.fixture(autouse=True) +def _isolate(tmp_path, monkeypatch): + """Reset env + singletons per test.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr( + "kora_constants.get_kora_home", lambda: tmp_path, raising=False + ) + # Clear all relevant envs; tests opt in. + for env in ( + "KORA_SLACK_BOT_TOKEN", + "KORA_SLACK_JOSHUA_USER_ID", + "KORA_PUREMAIL_SMTP_USERNAME", + "KORA_PUREMAIL_SMTP_APP_PASSWORD", + "KORA_PUREMAIL_SMTP_HOST", + "KORA_PUREMAIL_SMTP_PORT", + "KORA_EMAIL_KORA_ALLOWED_FROM_DOMAINS", + ): + monkeypatch.delenv(env, raising=False) + # Clear listener singletons. + from kora_cli.listeners.slack_client_listener import ( + _clear_singleton as _clear_slack, + ) + from kora_cli.listeners.purelymail_client_listener import ( + _clear_singleton as _clear_purelymail, + ) + + _clear_slack() + _clear_purelymail() + yield tmp_path + _clear_slack() + _clear_purelymail() + + +def _caller(*, actor_kind: str = "claude_pm_isokron", caps=None) -> Caller: + return Caller( + actor_kind=actor_kind, + allowed_caps=frozenset(caps or ()), + ) + + +# =========================================================================== +# Registry-side wiring +# =========================================================================== + + +def test_send_slack_dm_in_st2_descriptors(): + names = {d["name"] for d in ST2_TOOL_DESCRIPTORS} + assert "kora__send_slack_dm" in names + + +def test_send_email_in_st2_descriptors(): + names = {d["name"] for d in ST2_TOOL_DESCRIPTORS} + assert "kora__send_email" in names + + +def test_send_slack_dm_requires_cap_gate(): + assert SEND_SLACK_DM_TOOL["requires_cap_gate"] is True + + +def test_send_email_requires_cap_gate(): + assert SEND_EMAIL_TOOL["requires_cap_gate"] is True + + +def test_both_dispatchers_registered(): + assert "kora__send_slack_dm" in ST2_TOOL_DISPATCH + assert "kora__send_email" in ST2_TOOL_DISPATCH + + +def test_send_slack_dm_input_schema_has_4000_char_max(): + """Slack's per-message text cap is 4000 chars.""" + text_schema = SEND_SLACK_DM_TOOL["inputSchema"]["properties"]["text"] + assert text_schema["maxLength"] == 4000 + + +def test_send_email_input_schema_has_recipient_cap(): + """≤10 recipients per send.""" + to_schema = SEND_EMAIL_TOOL["inputSchema"]["properties"]["to"] + assert to_schema["maxItems"] == 10 + + +# =========================================================================== +# kora__send_slack_dm — input validation +# =========================================================================== + + +@pytest.mark.asyncio +async def test_slack_empty_channel_id_raises(): + with pytest.raises(_ST2_ToolInputError, match="channel_id"): + await _dispatch_send_slack_dm( + {"channel_id": "", "text": "hi"}, _caller() + ) + + +@pytest.mark.asyncio +async def test_slack_empty_text_raises(): + with pytest.raises(_ST2_ToolInputError, match="text"): + await _dispatch_send_slack_dm( + {"channel_id": "D123", "text": ""}, _caller() + ) + + +@pytest.mark.asyncio +async def test_slack_text_over_4000_raises(): + with pytest.raises(_ST2_ToolInputError, match="4000"): + await _dispatch_send_slack_dm( + {"channel_id": "D123", "text": "x" * 4001}, _caller() + ) + + +@pytest.mark.asyncio +async def test_slack_non_dm_channel_id_rejected(): + """C-prefix (channel) + U-prefix (user) rejected at MCP layer.""" + for bad in ("C12345", "U67890"): + with pytest.raises(_ST2_ToolInputError, match="DM"): + await _dispatch_send_slack_dm( + {"channel_id": bad, "text": "hi"}, _caller() + ) + + +@pytest.mark.asyncio +async def test_slack_joshua_user_id_match_allowed(monkeypatch): + """KORA_SLACK_JOSHUA_USER_ID env value matching channel_id IS + allowed (Slack auto-resolves to DM). Defense was against + arbitrary U... IDs.""" + monkeypatch.setenv("KORA_SLACK_JOSHUA_USER_ID", "UJOSHUA") + + fake_client = MagicMock() + fake_client.post_dm = AsyncMock(return_value={"ts": "1700.123"}) + + with patch( + "kora_cli.listeners.slack_client_listener.current_slack_client", + return_value=fake_client, + ): + result = await _dispatch_send_slack_dm( + {"channel_id": "UJOSHUA", "text": "hi"}, _caller() + ) + assert isinstance(result, SendSlackDmResult) + assert result.success is True + assert result.slack_message_ts == "1700.123" + + +# =========================================================================== +# kora__send_slack_dm — SlackClient unavailable +# =========================================================================== + + +@pytest.mark.asyncio +async def test_slack_client_unavailable_raises(): + """No SlackClient registered → -32001 with slack_client_unavailable + surface (raised as _ST2_ToolInputError which mcp.py maps to JSON-RPC + error envelope).""" + with patch( + "kora_cli.listeners.slack_client_listener.current_slack_client", + return_value=None, + ): + with pytest.raises( + _ST2_ToolInputError, match="slack_client_unavailable" + ): + await _dispatch_send_slack_dm( + {"channel_id": "D123", "text": "hi"}, _caller() + ) + + +# =========================================================================== +# kora__send_slack_dm — happy path + JSONL entry +# =========================================================================== + + +@pytest.mark.asyncio +async def test_slack_happy_path_returns_send_result(_isolate): + fake_client = MagicMock() + fake_client.post_dm = AsyncMock(return_value={"ts": "1700.456"}) + + with patch( + "kora_cli.listeners.slack_client_listener.current_slack_client", + return_value=fake_client, + ): + result = await _dispatch_send_slack_dm( + {"channel_id": "D123", "text": "hello", "thread_ts": None}, + _caller(actor_kind="claude_pm_isokron"), + ) + assert isinstance(result, SendSlackDmResult) + assert result.success is True + assert result.slack_message_ts == "1700.456" + assert result.caller_actor_kind == "claude_pm_isokron" + fake_client.post_dm.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_slack_outbound_jsonl_entry_includes_caller_actor_kind( + _isolate, +): + fake_client = MagicMock() + fake_client.post_dm = AsyncMock(return_value={"ts": "1700.789"}) + + with patch( + "kora_cli.listeners.slack_client_listener.current_slack_client", + return_value=fake_client, + ): + await _dispatch_send_slack_dm( + {"channel_id": "D123", "text": "audit me"}, + _caller(actor_kind="kora_drone_7"), + ) + + log_path = _isolate / "slack_dm_log.jsonl" + assert log_path.exists() + # Last line is the outbound entry from this MCP call + last = json.loads(log_path.read_text().splitlines()[-1]) + assert last["caller_actor_kind"] == "kora_drone_7" + assert last["channel_id"] == "D123" + assert last["text"] == "audit me" + assert last["send_status"] == "ok" + + +# =========================================================================== +# kora__send_slack_dm — token absence in error +# =========================================================================== + + +@pytest.mark.asyncio +async def test_slack_send_failure_does_not_leak_token(_isolate): + """When post_dm raises with text that might contain the token, + the dispatcher's error envelope strips it (sanitized to + type-name only).""" + fake_client = MagicMock() + fake_client.post_dm = AsyncMock( + side_effect=RuntimeError( + f"transport failed (token={_TEST_SLACK_TOKEN})" + ) + ) + + with patch( + "kora_cli.listeners.slack_client_listener.current_slack_client", + return_value=fake_client, + ): + with pytest.raises(_ST2_ToolInputError) as exc_info: + await _dispatch_send_slack_dm( + {"channel_id": "D123", "text": "hi"}, _caller() + ) + # Sanitization: only type-name surfaces; token absent + assert _TEST_SLACK_TOKEN not in str(exc_info.value) + assert "RuntimeError" in str(exc_info.value) + + +# =========================================================================== +# kora__send_email — input validation +# =========================================================================== + + +@pytest.mark.asyncio +async def test_email_empty_to_raises(): + with pytest.raises(_ST2_ToolInputError, match="to"): + await _dispatch_send_email( + {"to": [], "subject": "x", "body_text": "x"}, _caller() + ) + + +@pytest.mark.asyncio +async def test_email_empty_subject_raises(): + with pytest.raises(_ST2_ToolInputError, match="subject"): + await _dispatch_send_email( + {"to": ["a@b.com"], "subject": "", "body_text": "x"}, + _caller(), + ) + + +@pytest.mark.asyncio +async def test_email_too_many_recipients_raises(): + with pytest.raises(_ST2_ToolInputError, match="max 10"): + await _dispatch_send_email( + { + "to": [f"r{i}@example.com" for i in range(11)], + "subject": "x", + "body_text": "x", + }, + _caller(), + ) + + +@pytest.mark.asyncio +async def test_email_malformed_recipient_raises(): + with pytest.raises(_ST2_ToolInputError, match="malformed"): + await _dispatch_send_email( + { + "to": ["a@b.com", "not-an-email"], + "subject": "x", + "body_text": "x", + }, + _caller(), + ) + + +# =========================================================================== +# kora__send_email — PurelymailClient unavailable +# =========================================================================== + + +@pytest.mark.asyncio +async def test_email_client_unavailable_raises(): + with patch( + "kora_cli.listeners.purelymail_client_listener.current_purelymail_client", + return_value=None, + ): + with pytest.raises( + _ST2_ToolInputError, match="purelymail_client_unavailable" + ): + await _dispatch_send_email( + {"to": ["a@b.com"], "subject": "x", "body_text": "x"}, + _caller(), + ) + + +@pytest.mark.asyncio +async def test_email_missing_username_env_raises(monkeypatch): + """Even with a registered PurelymailClient, the MCP tool reads + KORA_PUREMAIL_SMTP_USERNAME to set from_addr — if it's unset + we reject before any send.""" + fake_client = MagicMock() + monkeypatch.delenv("KORA_PUREMAIL_SMTP_USERNAME", raising=False) + with patch( + "kora_cli.listeners.purelymail_client_listener.current_purelymail_client", + return_value=fake_client, + ): + with pytest.raises( + _ST2_ToolInputError, match="KORA_PUREMAIL_SMTP_USERNAME" + ): + await _dispatch_send_email( + {"to": ["a@b.com"], "subject": "x", "body_text": "x"}, + _caller(), + ) + + +# =========================================================================== +# kora__send_email — happy path +# =========================================================================== + + +@pytest.mark.asyncio +async def test_email_happy_path_returns_send_result(_isolate, monkeypatch): + monkeypatch.setenv( + "KORA_PUREMAIL_SMTP_USERNAME", "kora@stormhavenenterprises.com" + ) + + fake_client = MagicMock() + fake_send_result = SendResult( + status="ok", + message_id="", + error=None, + smtp_code=250, + sent_at=datetime.now(timezone.utc), + retry_count=0, + ) + fake_client.send_email = AsyncMock(return_value=fake_send_result) + + with patch( + "kora_cli.listeners.purelymail_client_listener.current_purelymail_client", + return_value=fake_client, + ): + result = await _dispatch_send_email( + { + "to": ["joshua@stormhavenenterprises.com"], + "subject": "hello", + "body_text": "hi", + }, + _caller(actor_kind="claude_pm_isokron"), + ) + assert isinstance(result, SendEmailResult) + assert result.success is True + assert result.smtp_code == 250 + assert result.caller_actor_kind == "claude_pm_isokron" + assert result.error is None + + # Verify the client was called with from_addr derived from env + # AND caller_actor_kind threaded through + call_kwargs = fake_client.send_email.await_args.kwargs + assert call_kwargs["from_addr"] == "kora@stormhavenenterprises.com" + assert call_kwargs["caller_actor_kind"] == "claude_pm_isokron" + # Attachments NOT supported in this bucket + assert call_kwargs.get("attachments") is None + + +# =========================================================================== +# kora__send_email — password absence in error +# =========================================================================== + + +@pytest.mark.asyncio +async def test_email_failure_does_not_leak_password(_isolate, monkeypatch): + monkeypatch.setenv( + "KORA_PUREMAIL_SMTP_USERNAME", "kora@stormhavenenterprises.com" + ) + + fake_client = MagicMock() + fake_client.send_email = AsyncMock( + side_effect=RuntimeError( + f"auth failed (password={_TEST_SMTP_PASSWORD})" + ) + ) + + with patch( + "kora_cli.listeners.purelymail_client_listener.current_purelymail_client", + return_value=fake_client, + ): + with pytest.raises(_ST2_ToolInputError) as exc_info: + await _dispatch_send_email( + { + "to": ["joshua@stormhavenenterprises.com"], + "subject": "x", + "body_text": "x", + }, + _caller(), + ) + # Sanitization: only type-name surfaces; password absent + assert _TEST_SMTP_PASSWORD not in str(exc_info.value) + assert "RuntimeError" in str(exc_info.value) + + +# =========================================================================== +# Defense-in-depth: caller_actor_kind propagation +# =========================================================================== + + +@pytest.mark.asyncio +async def test_email_caller_actor_kind_threaded_through_to_send_email( + _isolate, monkeypatch +): + """Verify the actor_kind from the caller object reaches + PurelymailClient.send_email's caller_actor_kind kwarg.""" + monkeypatch.setenv( + "KORA_PUREMAIL_SMTP_USERNAME", "kora@stormhavenenterprises.com" + ) + + fake_client = MagicMock() + fake_client.send_email = AsyncMock( + return_value=SendResult( + status="ok", + message_id="", + smtp_code=250, + sent_at=datetime.now(timezone.utc), + retry_count=0, + ) + ) + + with patch( + "kora_cli.listeners.purelymail_client_listener.current_purelymail_client", + return_value=fake_client, + ): + await _dispatch_send_email( + { + "to": ["joshua@stormhavenenterprises.com"], + "subject": "x", + "body_text": "x", + }, + _caller(actor_kind="kora_drone_42"), + ) + assert ( + fake_client.send_email.await_args.kwargs["caller_actor_kind"] + == "kora_drone_42" + ) diff --git a/tests/kora_cli/test_listeners/test_send_client_listeners.py b/tests/kora_cli/test_listeners/test_send_client_listeners.py new file mode 100644 index 000000000000..205db0ef1841 --- /dev/null +++ b/tests/kora_cli/test_listeners/test_send_client_listeners.py @@ -0,0 +1,206 @@ +"""Listener-lifecycle tests for the SlackClient + PurelymailClient +listeners (KR-MCP-SEND-TOOLS). + +Covers: + - SlackClient listener fail-soft on missing KORA_SLACK_BOT_TOKEN + - SlackClient listener constructs on KORA_SLACK_BOT_TOKEN set + - current_slack_client() lifecycle (None / live / None across + startup → shutdown) + - PurelymailClient listener fail-soft on missing SMTP env + - PurelymailClient listener constructs on full SMTP env + - current_purelymail_client() lifecycle + - Both listeners registered in LISTENER_REGISTRY at import time +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from kora_cli import daemon as daemon_mod +from kora_cli.listeners.purelymail_client_listener import ( + PurelymailClientListener, + _clear_singleton as _clear_purelymail_singleton, + current_purelymail_client, +) +from kora_cli.listeners.slack_client_listener import ( + SlackClientListener, + _clear_singleton as _clear_slack_singleton, + current_slack_client, +) + + +@pytest.fixture(autouse=True) +def _reset(monkeypatch): + _clear_slack_singleton() + _clear_purelymail_singleton() + # Wipe any leftover env from prior tests + for env in ( + "KORA_SLACK_BOT_TOKEN", + "KORA_PUREMAIL_SMTP_USERNAME", + "KORA_PUREMAIL_SMTP_APP_PASSWORD", + "KORA_PUREMAIL_SMTP_HOST", + "KORA_PUREMAIL_SMTP_PORT", + "KORA_EMAIL_KORA_ALLOWED_FROM_DOMAINS", + ): + monkeypatch.delenv(env, raising=False) + yield + _clear_slack_singleton() + _clear_purelymail_singleton() + + +# --------------------------------------------------------------------------- +# Registry-side wire-in +# --------------------------------------------------------------------------- + + +def test_slack_client_listener_registered_at_import_time(): + from kora_cli.listeners import slack_client_listener # noqa: F401 + + names = {name for name, _ in daemon_mod.LISTENER_REGISTRY} + assert "slack_client" in names + + +def test_purelymail_client_listener_registered_at_import_time(): + from kora_cli.listeners import purelymail_client_listener # noqa: F401 + + names = {name for name, _ in daemon_mod.LISTENER_REGISTRY} + assert "purelymail_client" in names + + +# --------------------------------------------------------------------------- +# SlackClient listener — fail-soft startup +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_slack_listener_fail_soft_when_token_unset(caplog): + """No KORA_SLACK_BOT_TOKEN → singleton stays None; daemon + continues. Outbound disabled, INFO log line.""" + import logging + + listener = SlackClientListener() + with caplog.at_level( + logging.INFO, logger="kora_cli.listeners.slack_client_listener" + ): + await listener.startup() + assert current_slack_client() is None + assert any( + "Slack outbound disabled" in r.message for r in caplog.records + ) + + +@pytest.mark.asyncio +async def test_slack_listener_constructs_when_token_set(monkeypatch): + monkeypatch.setenv("KORA_SLACK_BOT_TOKEN", "xoxb-test-token") + listener = SlackClientListener() + await listener.startup() + assert current_slack_client() is not None + # current_slack_client returns the SlackClient instance + from kora_cli.clients.slack_client import SlackClient + + assert isinstance(current_slack_client(), SlackClient) + + +@pytest.mark.asyncio +async def test_slack_listener_shutdown_clears_singleton(monkeypatch): + monkeypatch.setenv("KORA_SLACK_BOT_TOKEN", "xoxb-test-token") + listener = SlackClientListener() + await listener.startup() + assert current_slack_client() is not None + await listener.shutdown() + assert current_slack_client() is None + + +# --------------------------------------------------------------------------- +# PurelymailClient listener — fail-soft startup +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_purelymail_listener_fail_soft_when_username_unset(): + """No SMTP auth env → singleton stays None; daemon continues.""" + listener = PurelymailClientListener() + await listener.startup() + assert current_purelymail_client() is None + + +@pytest.mark.asyncio +async def test_purelymail_listener_fail_soft_when_password_unset(monkeypatch): + """Username set but password missing → still fail-soft.""" + monkeypatch.setenv( + "KORA_PUREMAIL_SMTP_USERNAME", "kora@stormhavenenterprises.com" + ) + listener = PurelymailClientListener() + await listener.startup() + assert current_purelymail_client() is None + + +@pytest.mark.asyncio +async def test_purelymail_listener_constructs_with_full_env(monkeypatch): + monkeypatch.setenv( + "KORA_PUREMAIL_SMTP_USERNAME", "kora@stormhavenenterprises.com" + ) + monkeypatch.setenv("KORA_PUREMAIL_SMTP_APP_PASSWORD", "test_password") + listener = PurelymailClientListener() + await listener.startup() + assert current_purelymail_client() is not None + from kora_cli.clients.purelymail_client import PurelymailClient + + assert isinstance(current_purelymail_client(), PurelymailClient) + + +@pytest.mark.asyncio +async def test_purelymail_listener_shutdown_clears_singleton(monkeypatch): + monkeypatch.setenv( + "KORA_PUREMAIL_SMTP_USERNAME", "kora@stormhavenenterprises.com" + ) + monkeypatch.setenv("KORA_PUREMAIL_SMTP_APP_PASSWORD", "test_password") + listener = PurelymailClientListener() + await listener.startup() + assert current_purelymail_client() is not None + await listener.shutdown() + assert current_purelymail_client() is None + + +# --------------------------------------------------------------------------- +# Listener startup-on-exception (defense-in-depth) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_slack_listener_handles_unexpected_construction_error(caplog): + """If SlackClient construction raises something OTHER than + SlackClientNotConfigured (e.g. import error from a future + refactor), the listener still fail-softs + logs WARN.""" + import logging + + with patch( + "kora_cli.clients.slack_client.SlackClient", + side_effect=RuntimeError("unexpected init failure"), + ): + listener = SlackClientListener() + with caplog.at_level( + logging.WARNING, + logger="kora_cli.listeners.slack_client_listener", + ): + await listener.startup() + assert current_slack_client() is None + + +@pytest.mark.asyncio +async def test_purelymail_listener_handles_unexpected_construction_error(caplog): + import logging + + with patch( + "kora_cli.clients.purelymail_client.PurelymailClient", + side_effect=RuntimeError("unexpected init failure"), + ): + listener = PurelymailClientListener() + with caplog.at_level( + logging.WARNING, + logger="kora_cli.listeners.purelymail_client_listener", + ): + await listener.startup() + assert current_purelymail_client() is None