This repository was archived by the owner on May 26, 2026. It is now read-only.
feat(kora): KR-FEAT-EMAIL ST1 — Purelymail outbound via SMTP#124
Merged
Merged
Conversation
Phase 2 Feature 3 backend (outbound-only, post double-descope). Same external send_email surface as the original spec; transport is SMTP via aiosmtplib targeting smtp.purelymail.com:465 (SSL) by default. # Why SMTP (not REST) Verified twice during the bucket's STOP-ASK cycle: Purelymail has no inbound webhooks (1st STOP-ASK) and no outbound REST API (2nd STOP-ASK). SMTP is the only documented outbound mechanism per https://purelymail.com/docs/setup/technical. The send_email surface is transport-agnostic — swap is local to this client if Purelymail ships REST later. # Module layout kora_cli/clients/purelymail_types.py: - Attachment frozen dataclass (filename, content bytes, maintype, subtype) - SendResult Pydantic (status / message_id / error / smtp_code / sent_at / retry_count); extra="forbid" kora_cli/clients/purelymail_client.py: - PurelymailClient class - send_email_internal() module-level convenience - 3 error subclasses: PurelymailClientError (base), PurelymailConfigError (operator config gap), PurelymailRejectError (client-side validation) - _sanitize_error + _parse_smtp_code helpers # Construction (fail-CLOSED) Auth read from env at __init__: - KORA_PUREMAIL_SMTP_USERNAME (full email) - KORA_PUREMAIL_SMTP_APP_PASSWORD (App Password — assumes 2FA) - KORA_PUREMAIL_SMTP_HOST (default smtp.purelymail.com) - KORA_PUREMAIL_SMTP_PORT (default 465 SSL; 587 STARTTLS) Missing username OR password → raises PurelymailConfigError at construction, not on first send. Empty/whitespace treated as unset. Invalid port (non-int, out of range) also raises. # Pre-send validation (no SMTP traffic until passed) - from-domain allowlist via KORA_EMAIL_KORA_ALLOWED_FROM_DOMAINS (comma-separated). Unset = operator-config error (PurelymailConfigError), NOT silent allow-all. Defense against accidentally wide-open sends. - Recipient cap: len(to) ≤ 10 (defense against mass-send) - Recipient sanity: each must contain "@" - Per-attachment ≤ 10 MiB; total batch ≤ 25 MiB # SMTP send + retry Up to 2 attempts. Retry once on: - SMTP transient codes 421 / 450 / 451 / 452 - SMTPConnectError / SMTPServerDisconnected / asyncio.TimeoutError NO retry on: - SMTP 5xx permanent codes (550/552/553/554/...) - SMTPAuthenticationError - Other SMTPException subclasses Per-call timeout: 30s (covers connect + auth + DATA + QUIT). # Message-ID generation SMTP doesn't return a server-assigned ID. We generate locally via email.utils.make_msgid(domain=<from_addr's domain>) and set the Message-ID header on the outgoing message. Threading via In-Reply-To + References uses the same value. # Outbound JSONL log ${HERMES_HOME}/email_outbound_log.jsonl (via get_kora_home()): - sent_at + from + to + subject + in_reply_to + send_status + message_id + smtp_code + error + retry_count - Body NOT included (could carry sensitive operator content; operator pulls from Purelymail sent folder if needed) - Fail-soft on write error (WARN log; doesn't mask send result) # Security contract Password kept in private _password attr; never logged; never in repr/error/JSONL. _sanitize_error helper strips password value from any error string before exposing (defense against SMTP servers that echo credentials in error responses). # Dep addition aiosmtplib==4.0.1 added to pyproject.toml RUNTIME dependencies (NOT under an extra). Same lesson as task NousResearch#265 (slowapi was wrongly under [web] extra while webhooks listener imported unconditionally). # Tests 40 tests in tests/kora_cli/clients/test_purelymail_client.py: - 9 construction tests (fail-CLOSED on missing/empty/whitespace username/password; host/port defaults + overrides; non-int / out-of-range port rejection) - 8 validation tests (allowlist disallowed; unset allowlist fails config; malformed from; empty recipients; >10 recipients; malformed recipient; oversized single attachment; oversized total batch) - 3 happy-path tests (SendResult shape; outbound JSONL entry; in_reply_to threading preserved) - 8 retry-policy tests (421 transient retries once; 450/451/452 all retry; 550/552/553/554 permanent no retry; connection error retries once; auth error no retry) - 5 security tests (password absent from JSONL after success; password redacted in error when server echoes it; diverse-failure-mode battery; password absent from client repr; sanitize_error helper directly) - 7 helper tests (sanitize_error edges; parse_smtp_code happy + malformed; send_email_internal module convenience) 40/40 pass; 270/270 cross-bucket regression (test_listeners/ + test_heartbeat_probes/ + test_clients/). Ruff clean. # After ST2 Operator runbook (ST2) walks through App Password mint + Doppler secret setup + smoke test. Then Feature 3 outbound ships. Inbound remains in the deferred KR-FEAT-EMAIL-INBOUND-IMAP bucket. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 tasks
rafe-walker
added a commit
that referenced
this pull request
May 23, 2026
Closes BOTH task NousResearch#265 (slowapi placement) AND task NousResearch#269 (mcp_clients test failures — confirmed downstream of same import-chain issue per CC#2 investigation in PR #125). - pyproject.toml: slowapi==0.1.9 moves from [web] optional-deps to runtime deps (next to aiosmtplib pin that set the precedent in PR #124). - uv.lock regenerated. - Exact-equals pin per the security policy in the dependencies-block header (2026-05-12 Mini Shai-Hulud response). Base-install verification (uv sync --frozen --extra dev, NO --extra web): mcp_clients 19/19 pass (was 13 fail + 5 err); full admin-panel regression 271/271 across 23 suites.
4 tasks
rafe-walker
pushed a commit
that referenced
this pull request
May 23, 2026
Bridges CC#3's SlackClient (#122) + CC#1's PurelymailClient (#124) onto the agent-facing /mcp surface. Other agents (other PMs, drone tickets, future Kora-driven multi-agent workflows) can now drive Kora-initiated sends. `kora__send_slack_dm`: - Args: channel_id (D-prefix or KORA_SLACK_JOSHUA_USER_ID), text (≤4000 chars), thread_ts (optional) - requires_cap_gate=True (-32001 capability_denied if absent) - Channel-broadcast defense: U-prefix + C-prefix rejected at MCP layer (operator pre-resolves to DM channel) - SlackClient-unavailable surface: -32001 with "slack_client_unavailable" prefix (KORA_SLACK_BOT_TOKEN unset or daemon not running with slack_client listener) - Bot identity = Kora (NOT caller-controllable) `kora__send_email`: - Args: to (≤10), subject, body_text, body_html (optional), in_reply_to (optional) - requires_cap_gate=True - from_addr derived from KORA_PUREMAIL_SMTP_USERNAME — NOT caller-controllable (prevents sender impersonation) - NO attachments in this bucket (deferred to KR-MCP-SEND-TOOLS-ATTACHMENTS; file-upload semantics aren't trivially MCP-compatible) - PurelymailClient-unavailable surface: -32001 with "purelymail_client_unavailable" prefix Both tools added to ST2_TOOL_DESCRIPTORS + ST2_TOOL_DISPATCH; schema validation surfaces -32602 invalid_params for malformed inputs before any client invocation. `kora_cli/listeners/slack_client_listener.py` (NEW): - SlackClientListener: startup constructs SlackClient if KORA_SLACK_BOT_TOKEN set; FAIL-SOFT if unset (daemon boot continues; outbound disabled with INFO log) - current_slack_client() module-level accessor mirroring the current_pool() / current_coordinator() pattern `kora_cli/listeners/purelymail_client_listener.py` (NEW): - PurelymailClientListener: startup constructs PurelymailClient if SMTP envs set; FAIL-SOFT if missing - current_purelymail_client() accessor Both registered at module-import time via the listeners package __init__; both fail-soft so outbound capabilities never block daemon boot. `kora_cli/handlers/slack_dm_handler.py:_get_or_create_slack_client` updated: - Order: cached client → listener accessor → lazy-construct - Lazy-construct fallback PRESERVED so standalone-handler tests (no daemon listeners) keep passing - Production daemon paths get the shared listener instance - Existing reasoning-engine reply path (CC#3 KR-FEAT-AI-RESPONSE- LOOP) goes through this helper → automatically picks up the listener instance once daemon's running `caller_actor_kind` field added to outbound entries in BOTH log files: - slack_dm_log.jsonl: handler's `_append_outbound_log_entry` gains optional caller_actor_kind kwarg (omitted when None to preserve back-compat with consumers expecting absence on handler-driven sends) - email_outbound_log.jsonl: PurelymailClient's send_email gains caller_actor_kind kwarg, propagated to `_append_outbound_log` MCP tool dispatchers pass caller.actor_kind through; handler / runtime-driven sends omit the field (None). Tool 1 rejects channel_ids that don't start with `D` OR match KORA_SLACK_JOSHUA_USER_ID. U... user-IDs are rejected at MCP layer (would require an extra Slack API call to resolve to DM channel; operator/agent must pre-resolve). C... public/private channels also rejected — channel broadcast is a separate threat model + would need its own bucket. - SlackClient bot token never in error envelope or JSONL (sanitized via type-name-only re-raise on exception) - PurelymailClient SMTP password never in error envelope or JSONL (sanitized via same pattern + existing PurelymailClient _sanitize_error) - Diverse-failure-mode tests pin both invariants 36 new tests across 2 files: `tests/kora_cli/test_listeners/test_send_client_listeners.py` (11): - Both listeners registered at import time - Slack listener fail-soft on missing token + WARN log - Slack listener constructs cleanly when token set - PurelymailClient listener fail-soft on missing username OR password - PurelymailClient listener constructs with full env - Both listeners clear singleton on shutdown - Both listeners handle unexpected construction errors `tests/kora_cli/test_listeners/test_mcp_send_tools.py` (25): - 6 registry-side wiring tests (descriptors present, requires_cap_gate=True, dispatch registered, schema caps) - 5 slack input validation tests (empty channel/text; >4000 chars; non-DM channel rejected; Joshua's user ID OK) - 1 SlackClient unavailable - 2 slack happy-path tests (SendResult shape + JSONL entry with caller_actor_kind) - 1 slack token-absence-in-error test - 4 email input validation tests - 2 email client-unavailable tests (listener None + env unset) - 1 email happy-path test (from_addr from env; caller_actor_kind threaded) - 1 email password-absence-in-error test - 1 caller_actor_kind end-to-end propagation test 36/36 pass; 341/341 cross-bucket regression (test_listeners/ + test_heartbeat_probes/ + test_clients/ + test_handlers/). Ruff clean. Agent-facing /mcp surface gains 2 mutating tools (total now: 5 mutating + 8 read = 13 tools). Other agents have a complete operational surface on Kora — read state, request transitions, create Sea_Tickets, send Slack DMs, send emails. The remaining follow-on (attachment support in send_email) ships when the file-upload semantics get a design. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 23, 2026
rafe-walker
added a commit
that referenced
this pull request
May 23, 2026
Backend endpoint swap. Reads both email_inbound_log.jsonl (#138) + email_outbound_log.jsonl (#124), merges, projects to existing EmailMessage TS shape. FE auto-flips (stub badge gone). - kora_cli/web_server.py: -106 stub + 330 LOC live endpoint + 2 projection helpers + status-mapping table. - NEW tests/kora_cli/test_web_server_email_panel_flip.py: 596 LOC, 32 tests covering projection per direction + merge/sort + limit param + malformed tolerance + security walk-payload with message_id carve-out. 4-layer security + message_id carve-out: walk-payload sweep excludes only message_id + in_reply_to fields from email-regex check (RFC 5322 <id@operator-domain> is legitimate). test_no_email_addresses_outside_message_id_carve_out pins the boundary. Status taxonomy collapse: handler filtered_paused/filtered_stopped → FE dropped_paused; filtered_non_joshua → filtered_non_allowlist. JSONL stays canonical; operator panel sees coarser FE union. Fixture-isolation discipline applied: caught real cross-test bleed where original PR #121 fixture only patched upstream namespace; added 3-namespace get_kora_home monkeypatch per CC#2 #137 lesson. Spoofing semantic flip: spoofing_warning = NOT entry.spoofing_check_skipped. Test pins behavior when future bucket adds real envelope-based detection. 47 new + 185 cross-bucket regression. Stub badge auto-disappears.
This was referenced May 23, 2026
rafe-walker
added a commit
that referenced
this pull request
May 23, 2026
…ound (#148) CC#2 follow-on after CC#1 KR-EMAIL-OUTBOUND-REASONING-META (#146) unblocked the gap her STOP-ASK caught. - 2 files, +737/-7: extension to reasoning_xref.py (email path: loader + parser + 3-tier matcher) + 20 new email-specific tests. All 3 K-DG gates verified before drafting per re-dispatch: send_email kwargs ✓; opt-in writer ✓; caller_session_id literal format symmetry between handler + engine ✓. 3-tier cascade: PRIMARY caller_session_id literal equality (closed by #146) → SECONDARY in_reply_to chain → LAST RESORT ±60s timestamp window. Slack-first precedence preserved: existing #141/#143 tests (42/42) still pass without modification. response_text carve-out for email-sourced rows: stays null per #124 design (body never in email JSONL); same shape as slack_dm text + #143 message_id carve-outs. Tracked via xref_source local so the conditional null-set cannot regress to populating from a future field rename. 400/400 admin-panel + audit tests pass across 29 suites.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 2 Feature 3 backend (outbound-only, post double-descope).
SMTP-based PurelymailClient targeting `smtp.purelymail.com:465`
(SSL) by default. Same external `send_email` surface as the
original (pre-STOP-ASK) bucket spec; transport is SMTP via
`aiosmtplib`.
Bucket spec: `17_cc_bucket_prompts/KR-FEAT-EMAIL_purelymail_in_out.md`
Construction (fail-CLOSED)
Missing username OR password → `PurelymailConfigError` at
`init`. Empty/whitespace treated as unset. Invalid port
(non-int, out of range) also raises. Unset allowlist =
operator-config error (NOT silent allow-all).
Send caps
Retry policy (SMTP-specific, not HTTP)
Max 2 attempts total. Per-call timeout: 30s.
Security contract
Password kept in private `_password`; never logged; never in
repr/error/JSONL. `_sanitize_error` strips password from error
strings before exposing (defense against SMTP servers echoing
credentials).
Diverse-failure-mode test battery (auth-fail / 5xx / connect-fail
/ disconnect / 421-transient) asserts password absent across
`error` + JSONL log after each.
Message-ID generation
SMTP returns no server-assigned ID. We generate locally via
`email.utils.make_msgid(domain=<from_addr's domain>)` + set the
`Message-ID` header. Threading uses this same value via
`In-Reply-To` + `References`.
Outbound JSONL log
`${HERMES_HOME}/email_outbound_log.jsonl`. Body NOT logged
(subject + recipients + meta only — operator pulls body from
Purelymail's sent folder if needed). Fail-soft on write error.
Dep addition
`aiosmtplib==4.0.1` added to RUNTIME deps (not under an extra).
Lightweight pure-Python async SMTP client. Same lesson as task
NousResearch#265: runtime-critical capability belongs in core deps.
Test plan
invalid port)
permanent codes; connection error; auth error)
diverse-failure battery; repr; sanitize helper)
Cascade
Base: `feature/phase2-upgrades`. ST2 (operator runbook) stacks on this.
Spec history
Two STOP-ASKs settled the architecture:
KR-FEAT-EMAIL-INBOUND-IMAP follow-on
aiosmtplib (this PR)
🤖 Generated with Claude Code