Skip to content
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
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-FEAT-EMAIL-ST1
May 22, 2026
Merged

feat(kora): KR-FEAT-EMAIL ST1 — Purelymail outbound via SMTP#124
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-FEAT-EMAIL-ST1

Conversation

@rafe-walker

Copy link
Copy Markdown
Owner

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)

Env Purpose
`KORA_PUREMAIL_SMTP_USERNAME` Full email (e.g. `kora@stormhavenenterprises.com`)
`KORA_PUREMAIL_SMTP_APP_PASSWORD` App Password from Purelymail dashboard
`KORA_PUREMAIL_SMTP_HOST` Override (default `smtp.purelymail.com`)
`KORA_PUREMAIL_SMTP_PORT` Override (default `465` SSL; `587` STARTTLS)
`KORA_EMAIL_KORA_ALLOWED_FROM_DOMAINS` Comma-separated allowed from-domains

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

  • Recipient count ≤ 10 (defense against accidental mass-send)
  • Recipients must contain `@`
  • Per-attachment ≤ 10 MiB
  • Total batch attachments ≤ 25 MiB

Retry policy (SMTP-specific, not HTTP)

Condition Retry?
SMTP 421 / 450 / 451 / 452 transient Once
`SMTPConnectError` / `SMTPServerDisconnected` / `asyncio.TimeoutError` Once
SMTP 5xx permanent (550/552/...) No
`SMTPAuthenticationError` No
Other `SMTPException` subclasses No

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

  • 40 tests in `tests/kora_cli/clients/test_purelymail_client.py`
  • 9 construction tests (fail-CLOSED, host/port overrides,
    invalid port)
  • 8 pre-send validation tests
  • 3 happy-path tests (SendResult; JSONL; threading)
  • 8 retry-policy tests (parameterized over 4 transient + 4
    permanent codes; connection error; auth error)
  • 5 security tests (password absence; redaction;
    diverse-failure battery; repr; sanitize helper)
  • 7 helper tests (sanitize edges; parse_smtp_code; module-level convenience)
  • 270/270 cross-bucket regression
  • Ruff clean

Cascade

Base: `feature/phase2-upgrades`. ST2 (operator runbook) stacks on this.

Spec history

Two STOP-ASKs settled the architecture:

  • 1st: Purelymail has no inbound webhooks → inbound descoped to
    KR-FEAT-EMAIL-INBOUND-IMAP follow-on
  • 2nd: Purelymail has no REST send API → transport = SMTP via
    aiosmtplib (this PR)

🤖 Generated with Claude Code

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>
@rafe-walker rafe-walker merged commit 754173b into feature/phase2-upgrades May 22, 2026
@rafe-walker rafe-walker deleted the feat/kora-KR-FEAT-EMAIL-ST1 branch May 22, 2026 23:46
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.
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>
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.
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.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant