Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.

feat(kora): KR-FEAT-EMAIL-INBOUND-IMAP ST2 — EmailInboundHandler + AUTO_REPLY#138

Merged
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-FEAT-EMAIL-INBOUND-IMAP-ST2
May 23, 2026
Merged

feat(kora): KR-FEAT-EMAIL-INBOUND-IMAP ST2 — EmailInboundHandler + AUTO_REPLY#138
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-FEAT-EMAIL-INBOUND-IMAP-ST2

Conversation

@rafe-walker

Copy link
Copy Markdown
Owner

Summary

Wires ST1's IMAP poll listener through to a real `EmailInboundHandler` mirroring `SlackDMHandler`'s 5-step filter precedence. Adds JSONL inbound log, chain-event emit on identified Joshua mail, and opt-in AUTO_REPLY via reasoning engine + existing PurelymailClient outbound.

Bucket spec: `17_cc_bucket_prompts/KR-FEAT-EMAIL-INBOUND-IMAP_active.md`

Filter precedence (5 steps, ordered per spec)

# Filter Drop status should_mark_seen
1 State gate (PAUSED / STOPPED) `filtered_paused` / `filtered_stopped` True
2 Sender allowlist (`KORA_EMAIL_SENDER_ALLOWLIST` — empty = fail-CLOSED DENY ALL) `filtered_non_allowlist` True
3 Recipient (`KORA_EMAIL_KORA_ADDRESS` in to[]) `filtered_wrong_recipient` True
4 Spoofing (envelope sender unavailable post-IMAP-delivery; `spoofing_check_skipped: true` flag) — (defense-in-depth, not gate)
5 Identity (sender == `KORA_EMAIL_JOSHUA_ADDRESS` — unset = fail-CLOSED) `filtered_non_joshua` True
All passed `received` True
Exception in inner loop `handler_error` False (UNSEEN for next-poll retry)

`HandlerResult.should_mark_seen` drives the listener's `mark_seen()` call. Terminal-success statuses get SEEN (we explicitly chose to not re-process a filter-dropped UID); handler errors stay UNSEEN so transient failures get a retry.

Chain event `[kora.email_inbound.received]` emitted ONLY on identified Joshua mail.

JSONL schema (`${KORA_HOME}/email_inbound_log.jsonl`)

```json
{
"received_at": "...", "message_id": "...", "from": "...",
"to": [...], "subject": "...",
"body_text_truncated_2k": "..." (max 2048 chars),
"has_html": false, "attachments_count": 0,
"handled_status": "received|filtered_*|handler_error",
"spoofing_check_skipped": true, "imap_uid": 42,
"in_reply_to": null,
"extra": {...} (optional, per-filter diagnostic),
"error": "..." (optional, handler_error path only)
}
```

Body truncated to 2KB per spec — operator pulls full body from Purelymail webmail.

AUTO_REPLY (`KORA_EMAIL_AUTO_REPLY`, default OFF)

When env in {true, 1, yes, on} AND `handled_status == received`:

  1. Build `IncomingMessage(text=body_text, source="email", metadata={from, subject, message_id, in_reply_to})`
  2. Load `ConversationContext` via new `load_email_context()` — reads BOTH `email_inbound_log.jsonl` + `email_outbound_log.jsonl`, applies transitive chain-anchor closure over (message_id ↔ in_reply_to) pairs, returns up to N most-recent turns chronologically
  3. `current_reasoning_engine().respond(message, context)`
  4. Send via `current_purelymail_client().send_email(...)` with `from_addr=KORA_EMAIL_KORA_ADDRESS`, `to=[parsed.from_address]`, `subject="Re: " + parsed.subject` (idempotent), `in_reply_to=parsed.message_id`

Failure modes → canned fallback text `"Kora is currently unable to respond by email; operator notified."`:

  • Engine unavailable
  • `engine.respond()` raises
  • `result.error` set (e.g. cost-halted)
  • Empty response text

Send failures DON'T change inbound status — outbound JSONL records the SendResult separately; inbound stays SEEN.

Surface

Layer Net
`kora_cli/handlers/email_inbound_handler.py` (NEW) 530 LOC — `EmailInboundHandler.handle_event` + `HandlerResult` + 5-filter precedence + AUTO_REPLY plumbing
`kora_cli/listeners/email_inbound_imap_listener.py` +20/-32 — stub replaced by handler dispatch + `mark_seen` threading
`kora_cli/reasoning/context_loader.py` +173 — `load_email_context()` + helpers (transitive chain closure + cross-file merge)
Tests 1334 LOC, 63 tests across 3 files

Security carry-forward

  • IMAP / SMTP passwords + bot tokens NEVER in JSONL (test pins absence across diverse-failure modes — env-read raise, log-write failure, handler exception)
  • Body text logged truncated to 2KB; full payload only in Purelymail's webmail
  • Spoofing-check absence is a recorded flag (`spoofing_check_skipped: true`), never a silent bypass

Test plan

  • 63 new tests pass (28 handler + 16 listener wire-in + 19 context loader)
  • 477/477 cross-bucket regression (clients/ + test_listeners/ + handlers/ + reasoning/)
  • Ruff clean
  • Verified AUTO_REPLY env truthy variants {true, 1, yes, on}; default + falsy values keep reply disabled
  • Verified subject idempotency: `Re: existing thread` → `Re: existing thread` (no doubling)

Cascade

ST3: Operator runbook addendum — extend `purelymail_outbound_runbook.md` with inbound section (Doppler secrets, smoke test, troubleshooting). After ST3 merges Feature 3 closes fully.

🤖 Generated with Claude Code

…TO_REPLY

Wires ST1's IMAP poll listener through to a real
EmailInboundHandler that mirrors SlackDMHandler's shape:

  - 5-step filter precedence (state gate → sender allowlist →
    recipient → spoofing → identity), each writing a JSONL entry
    with handled_status
  - JSONL append-only at ${KORA_HOME}/email_inbound_log.jsonl
    (schema: received_at, message_id, from, to, subject,
    body_text_truncated_2k [2KB cap], has_html, attachments_count,
    handled_status, spoofing_check_skipped, imap_uid)
  - HandlerResult.should_mark_seen drives the listener's mark_seen
    call — terminal-success statuses (received + filtered_*) get
    SEEN; handler_error keeps UNSEEN for next-poll retry
  - Empty allowlist env = fail-CLOSED DENY ALL (defense against
    accidental wide-open inbound, mirrors SMTP outbound-bucket
    discipline)
  - Joshua-address env unset = fail-CLOSED (mirrors SlackDMHandler
    JOSHUA_USER_ID_ENV gating)
  - Spoofing check is currently a recorded-skip (envelope sender
    unavailable post-IMAP-delivery); flagged in JSONL as
    spoofing_check_skipped=true rather than silent bypass
  - Chain event `[kora.email_inbound.received]` emitted ONLY on
    identified Joshua mail

AUTO_REPLY (env-gated, default OFF):

  - When KORA_EMAIL_AUTO_REPLY in {true,1,yes,on}: build
    IncomingMessage(source="email") + load conversation context
    via new load_email_context() + call current_reasoning_engine()
    + send via current_purelymail_client()
  - Engine unavailable / engine.respond raises / result.error set
    / empty response → canned fallback text sent (Joshua sees a
    visible "I tried but couldn't" rather than silence)
  - Send failure DOES NOT change inbound status — outbound JSONL
    records the SendResult separately, inbound stays SEEN
  - Subject prefixed `Re: ` (idempotent; doesn't double-prefix
    existing `Re:` subjects)
  - In-Reply-To threaded to parsed.message_id so Joshua's mail
    client groups the reply correctly

New context loader: load_email_context() reads BOTH
email_inbound_log.jsonl + email_outbound_log.jsonl, applies
transitive chain-anchor closure over (message_id ↔ in_reply_to)
pairs, returns ConversationContext with up to N most-recent turns
chronologically. RFC 5322 globally-unique message-ids make
transitive walks safe.

63 new tests pass: 28 handler (filter precedence, fail-CLOSED
defaults, HandlerResult shape, AUTO_REPLY happy path, AUTO_REPLY
engine/client failure modes, JSONL schema, body truncation, log-
write fail-soft, password absence across diverse failures) + 16
listener (mark_seen threading + should_mark_seen=False keeps
UNSEEN + per-message handler exception/mark_seen failure don't
abort cycle) + 19 context loader (chain closure transitive +
cross-file chronological order + max_turns slicing + malformed
JSONL handling).

477/477 cross-bucket regression (clients/ + test_listeners/ +
handlers/ + reasoning/). Ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rafe-walker rafe-walker merged commit 6230a9d into feature/phase2-upgrades May 23, 2026
@rafe-walker rafe-walker deleted the feat/kora-KR-FEAT-EMAIL-INBOUND-IMAP-ST2 branch May 23, 2026 01:13
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.
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