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 intoMay 23, 2026
Conversation
…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>
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.
4 tasks
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
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)
`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`:
Failure modes → canned fallback text `"Kora is currently unable to respond by email; operator notified."`:
Send failures DON'T change inbound status — outbound JSONL records the SendResult separately; inbound stays SEEN.
Surface
Security carry-forward
Test plan
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