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

feat(kora): KR-FE-OUTBOUND-EMAIL-LOG-PANEL — outbound email audit stream in cockpit#183

Merged
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-FE-OUTBOUND-EMAIL-LOG-PANEL
May 24, 2026
Merged

feat(kora): KR-FE-OUTBOUND-EMAIL-LOG-PANEL — outbound email audit stream in cockpit#183
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-FE-OUTBOUND-EMAIL-LOG-PANEL

Conversation

@rafe-walker

Copy link
Copy Markdown
Owner

Summary

Symmetric follow-on to PR #180 (email-intent log panel). Surfaces the tool.email_to_operator_sent audit seam (PR #179 — the kora__send_email_to_operator reasoning-loop tool). Completes the cockpit's email-surface story: inbound (PR #180) + outbound (this PR) both visible side-by-side in the sidebar.

Screenshots

Populated state — 5 events: 3 sent (SMTP-ids + sizes + 1 with attachment), 1 rejected (hourly_cap_exceeded with truncated detail), 1 SMTP-failure (ConnectionRefusedError). Summary chips + 14-day sparkline above.

Outbound Email Log — populated

Actually use this corrected URL:

Outbound Email Log — populated

Empty filtered state — operator filtered to "SMTP-failure" but zero failures in the window. Calm reassurance copy + one-click "All" link.

Outbound Email Log — empty filtered

Drift-guard pin confirmation

test_status_values_drift_guard pins the 3 canonical status values across three sources (mirrors PR #180's discipline):

# Source Location
1 BE projection allow-list _OUTBOUND_EMAIL_STATUS_VALUES in kora_cli/web_server.py
2 BE emitter constants STATUS_SENT / STATUS_REJECTED / STATUS_SMTP_FAILURE in kora_cli/tools/email_to_operator.py (regex-asserts each constant assigns the matching string literal)
3 FE constant OUTBOUND_EMAIL_STATUS_VALUES in web/src/lib/api.ts

The 3 canonical values: sent / rejected / smtp_failure. Drift in any one fails CI.

⚠ K-DG drift caught: spec assumed subject string in audit

Spec said: "subject CAN be shown (it IS in audit details per #179 sample entry)." K-DG of kora_cli/tools/email_to_operator.py:365-369 showed that PR #179 was MORE privacy-conscious than the spec claimed — the audit row carries only subject_chars (length), body_chars (length), attachment_count, attachment_total_bytes, status, and per-status diagnostic fields. No subject string. No body text. No recipient.

PR #179's implementation is the stricter/correct posture; the spec description was inaccurate. Adopted the privacy reality — panel renders sizes + status + smtp_message_id (sent) or rejection_reason+truncated detail (rejected) or error type (smtp_failure). Pinned by:

  • test_privacy_response_never_contains_subject_or_body_text — hostile-field injection (future writer adding subject/body/recipient to details must NOT leak through projection)
  • test_page_does_not_render_subject_or_body_text — regex-asserts the page never references event.subject / event.body / event.body_text / event.to / event.from / event.recipient (only _chars suffixes allowed). Regression guard against future patches sneaking in text-content render.

What's in here

Backend (kora_cli/web_server.py):

  • GET /api/outbound-email/recent — symmetric to /api/email-intent/recent. Returns newest-first projected events + 24h-window by-status counts + 14-day daily-sent sparkline points.
  • _project_outbound_email_audit() — per-status field whitelist. Unknown statuses coerced to \"unknown\" defensively.
  • _OUTBOUND_EMAIL_STATUS_VALUES — BE projection allow-list (drift-guarded).

Frontend:

  • web/src/pages/OutboundEmailLogPage.tsx (new) — usePanelView, SummaryChips, plain-SVG Sparkline, FilterChips, per-row card with privacy-preserved size row, per-status detail line, calm empty state
  • api.getOutboundEmailRecent + 4 new TS types + OUTBOUND_EMAIL_STATUS_VALUES canonical constant
  • /outbound-email-log route + sidebar nav entry placed right after /email-intent-log — Inbox/Send icon pair completes the inbound/outbound sidebar adjacency

Shared-utility extraction recommendation

PR #180 (EmailIntentLogPage) + this PR (OutboundEmailLogPage) have substantial overlap in five small components/helpers:

Component Shape Difference between the two pages
Sparkline (plain SVG, daily counts) Identical Only the data type's daily-count field name differs
SummaryChips (24h enum-keyed counts) Identical Only values + visual map differ
FilterChips (All + per-enum-value buttons) Identical Only canonical-values list + visual map differ
formatTimestamp / formatRelative / truncate helpers Verbatim copies None
BadgeTone TS type alias Verbatim copy None

Per spec §4 license to "copy first, refactor later" — kept copies in both files this round to keep the PR focused. Recommended follow-on bucket: KR-FE-AUDIT-PANEL-KIT-REFACTOR — extract into web/src/components/AuditPanelKit/ once a 3rd consumer appears. (3 = the established threshold where extraction's clearly worth the abstraction cost vs duplication.)

SECURITY + privacy

  • No subject / body / recipient ever rendered — both the projection (server side) and the page (client side) tested against hostile injection.
  • Bounded lengths: rejection_detail JSON-serialize-then-substring to 200; error truncated to 200; smtp_message_id truncated to 200.
  • Unknown status coercion: defensive against attacker-controlled or unexpected status strings.
  • Projection whitelist: response includes ONLY the per-status fields the panel renders — future writer additions to details don't leak.

Test plan

Refs

🤖 Generated with Claude Code

…eam in cockpit

Symmetric follow-on to PR #180 (email-intent log panel).
Surfaces the tool.email_to_operator_sent audit seam (PR #179 —
the kora__send_email_to_operator reasoning-loop tool). Completes
the cockpit's email-surface story: inbound (PR #180) + outbound
(this PR) both visible.

K-DG drift caught: spec assumed subject string in audit
========================================================

Spec said: "subject CAN be shown (it IS in audit details per
#179 sample entry)." K-DG of kora_cli/tools/email_to_operator.py
lines 365-369 showed that PR #179 was MORE privacy-conscious
than the spec claimed — the audit row carries only:

  * subject_chars (length, not the string)
  * body_chars   (length, not the content)
  * attachment_count + attachment_total_bytes
  * status + per-status diagnostic fields

No subject string. No body text. No recipient. PR #179's
implementation is the stricter/correct posture; the spec
description was inaccurate. Adopted the privacy reality —
panel shows sizes + status + smtp_message_id (sent) or
rejection_reason + truncated rejection_detail (rejected) or
error type (smtp_failure).

This is the safest choice and pinned by
test_privacy_response_never_contains_subject_or_body_text +
test_page_does_not_render_subject_or_body_text against
hostile-field injection (future writer adding subject/body to
details must NOT leak through projection or page render).

Backend (kora_cli/web_server.py)
================================

GET /api/outbound-email/recent?limit=N

Symmetric pattern to /api/email-intent/recent (PR #180):
  * read_audit_entries(seam="tool.email_to_operator_sent")
  * per-status field whitelist projection
  * 24h-window by-status counts (sent/rejected/smtp_failure)
  * daily-sent sparkline points over 14 days
  * status_values echoed for the 3-source drift guard

Projection whitelist (_project_outbound_email_audit):

  All branches: subject_chars, body_chars, attachment_count,
                attachment_total_bytes, status, caller_session_id
  status=sent:  smtp_message_id (≤200), sent_at
  status=rejected: rejection_reason (≤120),
                   rejection_detail (JSON-serialized ≤200)
  status=smtp_failure: error (≤200), smtp_status

Unknown status values coerced to "unknown" defensively.

Frontend
========

  * web/src/pages/OutboundEmailLogPage.tsx (new):
    - usePanelView("OutboundEmailLogPage")
    - SummaryChips: 24h counts per status
    - Sparkline: plain SVG bars (same discipline as PR #180 /
      CostTelemetryPage — no chart library)
    - FilterChips iterating OUTBOUND_EMAIL_STATUS_VALUES
    - Per-row card: relative ts + privacy-preserved size row
      (subject_chars / body_chars / attachment info) + status
      badge + per-status detail line (smtp_message_id or
      rejection_reason+detail or error)
    - Calm empty state (regression-guarded)

  * api.ts: api.getOutboundEmailRecent + OutboundEmailEvent /
    OutboundEmailEventsResponse / OutboundEmailStatus /
    OutboundEmailDailyCount types + OUTBOUND_EMAIL_STATUS_VALUES
    canonical constant

  * App.tsx: /outbound-email-log route + sidebar nav entry
    placed right after /email-intent-log (inbound/outbound
    sidebar pair). Send icon completes the Inbox/Send pair.

Drift-guard pin (marquee — symmetric to PR #180's discipline)
=============================================================

test_status_values_drift_guard pins the 3 canonical status
values (sent / rejected / smtp_failure) across THREE sources:

  1. BE projection allow-list: _OUTBOUND_EMAIL_STATUS_VALUES
     in kora_cli/web_server.py
  2. BE emitter STATUS_* constants in
     kora_cli/tools/email_to_operator.py (STATUS_SENT,
     STATUS_REJECTED, STATUS_SMTP_FAILURE — regex-asserted
     each has the matching string literal)
  3. FE constant: OUTBOUND_EMAIL_STATUS_VALUES in
     web/src/lib/api.ts

Drift in any one fails CI.

Tests (23 in test_outbound_email_panel.py — all pass)
=====================================================

  Backend (10):
    * endpoint registered, empty zero-response, by_status_24h
      shape-stable across all 3 known statuses, per-status
      projection (sent/rejected/smtp_failure), unknown
      coerced, daily sparkline math (14 buckets ending today,
      chronological, correct totals), 24h cutoff
    * PRIVACY: response never contains subject string / body
      content even when hostile fields injected
    * SECURITY: arbitrary details fields don't leak
    * rejection_detail + error truncated to 200 chars

  Drift guard (2):
    * 3-way status_values drift guard (marquee)
    * SeamName Literal includes tool.email_to_operator_sent

  FE source-pins (11):
    * api wrapper exists with correct URL
    * 4 TS types declared, status constant exported
    * Page exists + usePanelView
    * Route + nav entry
    * Filter chips iterate canonical list
    * Sparkline plain SVG (no chart-library imports)
    * PRIVACY: page does NOT reference event.subject /
      event.body / event.body_text / event.to / event.from /
      event.recipient (only subject_chars + body_chars +
      attachment_* allowed — regression guard against future
      patches sneaking in text-content render)
    * Empty state copy committed

Combined with PR #180's existing tests: 47/47 pass (24 from
#180 + 23 new).

tsc -b clean. vite build clean.

Screenshots
===========

  web/docs/outbound-email-log-panel/populated.png — 5 events:
    3 sent (with SMTP-ids + sizes + 1 with attachment), 1
    rejected (hourly_cap_exceeded with truncated detail), 1
    smtp_failure (ConnectionRefusedError). Summary chips +
    sparkline above the cards.
  web/docs/outbound-email-log-panel/empty_filtered.png — operator
    filtered to "SMTP-failure" with zero failures; calm
    green-toned empty state with one-click "All" link.

Shared-utility extraction recommendation (spec §4 follow-on)
============================================================

PR #180 (EmailIntentLogPage) + this PR (OutboundEmailLogPage)
have substantial overlap in five small components/helpers:

  1. Sparkline (plain SVG, daily counts) — IDENTICAL shape;
     only the data type's field name differs (DailyCount).
     Could parameterize over <T extends {date: string; count:
     number}>.
  2. SummaryChips (24h counts by enum-keyed dict) — visual
     shape identical; only the values + visual map differ.
  3. FilterChips (All + per-enum-value buttons with counts) —
     same shape; only the canonical-values list + visual map
     differ.
  4. formatTimestamp / formatRelative / truncate helpers —
     verbatim copies.
  5. BadgeTone TS type alias — verbatim copy.

Per spec §4 license to "copy first, refactor later" — kept
copies in both files this round. Recommend extracting into
web/src/components/AuditPanelKit/ (or similar) in a follow-on
KR-FE-AUDIT-PANEL-KIT-REFACTOR bucket once a 3rd consumer
appears (3 = the threshold where extraction is clearly worth
the abstraction cost vs duplication).

Refs
====

  * rafe-walker/kora-docs
    17_cc_bucket_prompts/KR-FE-OUTBOUND-EMAIL-LOG-PANEL_audit_stream_for_email_sent_seam.md
  * PR #155 — KR-AUDIT-PANEL-ENDPOINTS (per-seam endpoint pattern)
  * PR #164 — CostTelemetryPage (plain-SVG charts discipline)
  * PR #179 — tool.email_to_operator_sent (audit source)
  * PR #180 — KR-FE-EMAIL-INTENT-LOG-PANEL (symmetric inbound panel)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rafe-walker rafe-walker merged commit 1f0a530 into feature/phase2-upgrades May 24, 2026
@rafe-walker rafe-walker deleted the feat/kora-KR-FE-OUTBOUND-EMAIL-LOG-PANEL branch May 24, 2026 05:00
rafe-walker added a commit that referenced this pull request May 24, 2026
…utofix-log + kora-actions-aggregate (#187)

4 deliverables: (A) Extracted AuditPanelKit (Sparkline/SummaryChips/FilterChips/formatters/BadgeTone/EmptyFilteredMessage); (B) #180+#183 retrofit (zero visual diff); (C) KR-FE-AUTOFIX-LOG-PANEL (3rd consumer; surfaces tool.probe_autofix_attempted); (D) KR-FE-KORA-ACTIONS-AGGREGATED-PANEL (apex timeline joining all 4+ mutating-action seams; action_category chip filter).

Drift-guards: 4 pin tests (status / categories / seam_literal_includes / badge_tone_matches_library). Existing #180+#183 drift guards still pass.

98/98 panel-suite tests + tsc + vite build clean.
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