Skip to content

feat(ui): expose attachment_policy + folder_policies in SecuritySettingsPanel (#83) - #25

Closed
schmug wants to merge 41 commits into
cloudflare:mainfrom
schmug:claude/magical-cohen-73f352
Closed

feat(ui): expose attachment_policy + folder_policies in SecuritySettingsPanel (#83)#25
schmug wants to merge 41 commits into
cloudflare:mainfrom
schmug:claude/magical-cohen-73f352

Conversation

@schmug

@schmug schmug commented Apr 30, 2026

Copy link
Copy Markdown

Summary

Two security settings existed in the worker but had no UI — operators couldn't change attachment_policy (executable / container / macro-Office filetype actions + a custom blocklist) or folder_policies (per-folder skip_classifier / skip_all / treat_as_verified bypasses) without editing R2 directly. This PR adds two collapsible sections to the existing SecuritySettingsPanel. Closes #83.

What changed

  • Schema (shared/mailbox-settings.ts): typed security sub-shape adds attachment_policy (passthrough) and folder_policies (z.record of passthrough). Every level uses .passthrough() so unrelated security fields (allowlist_senders, thresholds, business_hours, …) round-trip untouched. No defaults set in the schema — the runtime consumer in workers/security/settings.ts is the single source of defaults; duplicating them here would invite drift.
  • PUT validation (workers/index.ts): MailboxSettings.safeParse400 { error, issues } on bad input. Schema-level test (rejects an invalid attachment action enum) covers the same validation path the route calls.
  • Frontend type (app/types/index.ts): added AttachmentAction, AttachmentPolicySettings, FolderPolicySettings; extended SecuritySettings to include the two new fields.
  • UI (app/components/SecuritySettingsPanel.tsx): two new <details> sections — radio groups for executable/container/macro actions plus a custom-blocklist comma input; per-folder rows (driven by SYSTEM_FOLDER_IDS) with checkboxes for skip_classifier, treat_as_verified, and a red-styled skip_all warning. mode is mutex on the server side, so skip_all and skip_classifier interlock client-side: turning on skip_all disables the skip_classifier checkbox.
  • No consumer wiring neededworkers/security/triage.ts:97 and :137 already read settings.folder_policies and settings.attachment_policy; workers/security/settings.ts:145-152 already merges attachment_policy defaults field-by-field.

Note: PUT now normalizes settings on disk

The PUT route used to write body.settings verbatim; it now writes MailboxSettings.parse(...) output. So a partial PUT like {settings: {agentSystemPrompt: "x"}} now stores {agentSystemPrompt: "x", autoDraft: {enabled: true}, agentModel: "@cf/moonshotai/kimi-k2.5"}. The frontend already merges these on the client; this just normalizes the at-rest shape.

Test plan

  • npm test220 passing, 0 failing (11 new: 7 schema + 4 UI; baseline was 209)
  • npm run typecheck — clean
  • Schema test: PUT with executable_action: "lol" produces a zod issue with path .executable_action (the route's only added behavior is the 400 wrapper)
  • Schema regression guard: a security payload with both allowlist_senders and attachment_policy round-trips both fields (passthrough)
  • Local dev-server smoke not run — npm run dev requires Cloudflare Access service-token credentials per #89; component-level UI tests assert the save round-trip for executable_action, custom blocklist, skip_classifier mode, and treat_as_verified

Defaults preserve current behavior

Existing mailboxes with no attachment_policy / folder_policies blob continue to work — getSecuritySettings already merges DEFAULT_ATTACHMENT_POLICY field-by-field and folder_policies?.[folderId] returns undefined for unconfigured folders. No migration.

Out of scope (separate follow-ups)

🤖 Generated with Claude Code

schmug and others added 30 commits April 18, 2026 09:35
Introduces an opt-in, MISP-compatible threat-intel platform layered on top
of agentic-inbox. Two pieces: an edge pipeline that extends the existing
inbox Worker, and a new `hub/` subproject for community intel aggregation.

Edge (agentic-inbox extensions):
- Security pipeline (workers/security/): SPF/DKIM/DMARC parser, URL
  extraction + homograph/shortener heuristics, per-sender reputation,
  Workers-AI classifier (llama-3.1-8b-instruct-fast), and a deterministic
  scoring function that aggregates into allow / tag / quarantine / block
- Layered triage tiers (workers/security/triage.ts) that short-circuit
  the LLM call: hard-block on confirmed intel hits or flagged senders,
  hard-allow on DMARC pass + allowlist or long trusted history. The
  hard-allow tier REQUIRES DMARC pass — allowlist alone is never enough.
- Threat-intel feed consumer (workers/intel/): bloom-filter-backed
  membership checks in KV, hourly refresh cron, URLhaus + OpenPhish
  defaults, MISP-compatible client for posting to a hub
- DMARC aggregate report ingestor (workers/dmarc/): detects RUA emails,
  gunzips XML via DecompressionStream, parses + stores per-source-IP
  stats, with a dashboard route and summary API
- Case workflow (workers/routes/cases.ts + UI routes): TheHive-lite case
  management with a Report-as-Phish button; optional anonymized push to
  the community hub when configured
- Quarantine folder + schema migration 10 adding verdict columns,
  intel_feed_state, dmarc_reports/records/sources, cases, case_emails,
  case_observables, urls, sender_reputation

Hub (new `hub/` subproject):
- MISP-compatible REST subset: POST/GET /events, /events/restSearch,
  /feeds/destroylist.txt, /sharing_groups, /orgs/invite + /orgs/accept
- Invite-based trust circles (MISP "sharing groups") with API-key auth
  (keys stored as sha256 hashes so D1 leaks can't impersonate)
- Trust-weighted aggregation: attributes promote to published feeds when
  score >= 2.0 and contributors >= 2 (tunable)
- Queue-backed LLM triage agent that assigns MISP taxonomy tags only,
  never affects scoring — deliberate prompt-injection defense

Opt-in throughout. Existing mailboxes are unchanged until
`settings.security.enabled = true` is set in the mailbox R2 JSON.

Deferred for follow-up PRs:
- Attachment deep-scan (OCR, macro analysis)
- URL fetch preview + RDAP domain-age
- Attachment-type gates, per-folder bypass tier, time-of-day tier
- Fixture-based pipeline tests

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
)

Stands up a Vitest suite for the security-critical pure-function modules
(auth parser, URL/homograph heuristics, verdict aggregation, triage,
reputation, DMARC XML parser, bloom filter) — 64 tests. The suite caught
two real bugs:

- scoreReputation subtracted 5 when avg_score > 70, so a historically
  malicious sender looked LESS suspicious to the aggregator than an
  unknown sender. Flipped to +10 and attach a reason string so the
  signal surfaces in verdicts.
- isHomographic compared the last two labels as the registrable
  domain, so `amazom.co.uk` was checked against `co.uk` instead of
  `amazon.co.uk` and slipped past detection. Added a small multi-label
  public-suffix set and a registrableDomain() helper.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Authentication-Results hardening (closes forgery vector)
--------------------------------------------------------
An attacker-controlled upstream mail server can inject its own
Authentication-Results header claiming pass results. Cloudflare Email
Routing preserves the header, so the previous parser — which accepted
any Authentication-Results header without validating authserv-id —
could be tricked into reporting DMARC=pass for a forged message,
potentially triggering hard-allow triage.

Two defensive changes in workers/security/auth.ts:

1. Per-mailbox `trusted_authserv_ids` setting. When configured, only
   headers whose authserv-id matches (or is a dotted subdomain of) an
   entry on the list contribute to the verdict. Empty list preserves
   back-compat "trust any" behaviour so existing mailboxes aren't
   broken on upgrade; README guidance should recommend populating it.

2. Track per-method set-ness separately from the AuthResult value.
   Previously the string "none" was both a valid result and the
   "not yet set" sentinel, so a legitimate first header with
   `dkim=none` allowed any later (possibly attacker-controlled)
   header to overwrite it.

Off-hours scoring (completes half-built feature)
-----------------------------------------------
The `business_hours` setting already existed in MailboxSecuritySettings
but nothing consumed it. Added workers/security/time-rules.ts with a
small +10 off-hours boost, wired into the pipeline AFTER aggregation
so it can't short-circuit triage. Targeted at BEC signal where wire
fraud disproportionately lands outside working hours.

Also refactored the duplicated post-aggregation boost logic in
workers/security/index.ts into an applyBoost() helper so the intel and
off-hours cases share a single code path.

14 new tests; 78/78 passing.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the entire workers/security/settings.ts surface was only
reachable by editing mailbox JSON in R2 — the UI had no toggle for
enabling the pipeline at all, let alone for the authserv-id gating,
allowlists, thresholds, or the off-hours boost.

Adds SecuritySettingsPanel with grouped controls for:

- Master enable + learning mode
- Score thresholds (tag / quarantine / block)
- Trusted authserv-id list (forgery defense — routes straight to the
  Authentication-Results gating added in the previous commit)
- Sender / domain allowlists (DMARC-pass gated)
- Intel auto-block + history-based auto-allow tuning
- Business-hours off-hours boost with IANA timezone + hour window

MailboxSettings type gained a typed `security` field so the UI, API,
and server settings loader share the same shape.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously hub/ had zero tests despite hosting the trust-weighted
corroboration logic that decides what shows up on the destroylist
feed — a decision that directly shapes what threats downstream
Agentic Inbox mailboxes act on.

Stands up vitest + a minimal in-memory D1 adapter backed by
better-sqlite3 (foreign_keys OFF to match Cloudflare D1 defaults;
?N placeholders translated to object-keyed binds). Tests exercise:

- Sybil resistance: one org cannot single-handedly promote intel by
  resubmitting, even with a high trust multiplier (contributor_count
  guard is the invariant).
- Trust accumulation across distinct orgs.
- Non-promotable attribute types are ignored.
- Sharing-group isolation between tenants.
- Promotion thresholds scope to the requested sharing group.
- sha256 produces known digests and is deterministic; generateSecret
  returns 43-char base64url (~256 bits).
- requireOrg rejects missing/unknown/revoked keys; accepts both
  bare-key (MISP) and Bearer-prefixed auth; only stores hashes,
  never the raw key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ics) (#9)

* Add async deep-scan (URL redirect chain, RDAP age, attachment heuristics)

Introduces workers/intel/deep-scan.ts — the previously-referenced but
never-built async stage. It runs AFTER the sync pipeline's store-and-
decide step, inside ctx.waitUntil, so it can take seconds without
blocking email receipt.

Three new signal sources, each isolated in its own module so the pure
parts can be unit-tested without network or Workers runtime:

- workers/intel/url-resolver.ts — manual-redirect fetch chain (max 5
  hops, 8s per hop). Surfaces the final hostname, <title>, and a
  host_changed flag. Resolved host feeds the homograph and intel-feed
  checks, so a bit.ly-wrapped phish gets inspected at its real
  destination.

- workers/intel/rdap.ts — registration-age via rdap.org. Fresh domains
  (<30d) are a strong phishing signal; <7d gets a +20, <30d gets +10.
  Never fails closed: a dead RDAP server drops the signal rather than
  holding mail.

- workers/intel/attachment-checks.ts — pure filename/MIME heuristics:
  dangerous extensions (.exe, .scr, .vbs, …), macro-enabled Office,
  `invoice.pdf.exe` double-extensions, MIME/extension mismatches,
  archives whose filename advertises a payload.

Orchestrator caps its total contribution at +40 and ONLY upgrades the
verdict — never downgrades — so a sync "allow" can become a deep-scan
"tag" or "quarantine" but not vice-versa. Quarantined deep-scan
verdicts also move the email to the QUARANTINE folder.

Wired into workers/index.ts via ctx.waitUntil after the sync pipeline.
Gated on security.enabled so disabled mailboxes see no extra latency.

Six new DO methods added for persistence: getUrlsForEmail,
updateUrlScan, getAttachmentsForEmail, updateAttachmentScan,
updateDeepScanStatus, getStoredVerdict.

35 new tests; 113/113 passing total.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* README: add Security section (#10)

Explains the opt-in pipeline, the two high-leverage configuration
knobs (trusted_authserv_ids — forgery defense; business_hours — BEC
nudge), the full stage list with latency budgets, and the async
deep-scan guarantees (only tightens, capped contribution, fails
silently on flaky external services). Also cross-links to the hub
subdirectory so downstream adopters know the feed loop exists.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends triage with a tier-0 folder-policy check evaluated before hard-block
and hard-allow. Each mailbox can configure, per folder, whether to run the
full pipeline, skip the LLM classifier, or skip the pipeline entirely with a
synthetic allow verdict tagged `triage: "folder_bypass"`. The `skip_classifier`
path keeps every other signal (auth, URLs, reputation, off-hours) so truly
suspicious mail can still surface a non-zero score.

Folder bypass is independent of the hard-allow DMARC invariant — it reflects an
explicit owner decision rather than a trust claim about the sender. Inbound
mail defaults to INBOX today; if a future filter-rule engine delivers into
other folders it must pass the destination through so the tier can honour
per-folder policy.

Also wires a `treat_as_verified` hook into `/emails/:id/move`: when the user
moves a message into a verified-flagged folder from a non-verified folder, we
fire `upsertSenderReputation(sender, 0)` best-effort. Snapshotting the email's
pre-move folder keeps the bump idempotent — moving out and back in does not
double-count. Adds the new `folder_bypass` discriminant to the frontend
SecurityVerdict type.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Injectable classifier seam (__setClassifier) lets tests drive
runSecurityPipeline end-to-end against real .eml fixtures without
Workers AI. Map-backed fakes stand in for MAILBOX/BUCKET; BLOOM_KV is
intentionally absent so intel-feed lookups return null.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `workers/security/attachments.ts` module and the `attachment_block`
triage tier shipped in #4 alongside folder-bypass, but the pipeline
plumbing was only half-finished:

- `DEFAULT_SECURITY_SETTINGS.attachment_policy` was missing, so the
  policy was always undefined and the triage tier never fired on
  fresh mailboxes.
- `getSecuritySettings` didn't merge partial user policies, so
  supplying just `custom_blocklist_extensions` would silently drop
  the default `executable_action: "block"` back to undefined.
- `aggregateVerdict` never picked up the container / macro-office
  "score" contributions — those categories were scored in isolation
  only, never reflected in the final verdict.

This change promotes `attachment_policy` to a required field with
`DEFAULT_ATTACHMENT_POLICY`, adds field-by-field merge in
`getSecuritySettings` (custom blocklist is additive, actions fall back
to defaults), threads `attachments` + `attachmentPolicy` through
`VerdictInputs`, and calls `scoreAttachments` inside `aggregateVerdict`
so container (+25) and macro-office (+15) contributions land in the
final score.

Adds 9 new tests: 6 triage-level (executable blocks under default
policy, double-extension trick, runs-before-hard-allow invariant,
safe-attachment no-op, container/macro don't hard-block, custom
blocklist extends the set) and 3 aggregate-level (iso +25, docm +15,
pdf no-op — baselined on DMARC-fail so the [0,100] clamp doesn't
mask deltas).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The DELETE /api/v1/mailboxes/:id handler previously removed only the
R2 settings JSON and left everything else behind: every email, every
attachment blob, every folder, sender-reputation row, DMARC report,
and the agent's chat history. Recreating the same mailbox inherited
all of it — silent data leak across "deletions", plus unbounded R2
storage growth.

The new flow:

  1. Delete the settings JSON first. Mailbox becomes invisible to all
     list/get endpoints immediately, and a retried DELETE 404s cleanly.
  2. Fire reapMailbox(env, mailboxId) via executionCtx.waitUntil —
     the endpoint returns 204 without waiting on cleanup.
  3. reapMailbox lists every attachment's R2 key via a new DO method
     listAllAttachmentKeys(), batch-deletes them (100/batch to stay
     under R2 binding + subrequest caps), then calls a new reset()
     on both MailboxDO and EmailAgent that does ctx.storage.deleteAll().

Every step is isolated in its own try/catch so one transient failure
(e.g. the DO unreachable for a beat) doesn't abandon the remaining
work. Errors are logged but never bubble — the settings JSON is
already gone, so the mailbox is effectively deleted from the user's
point of view. Orphaned rows/blobs are a cleanup cost, never a
correctness bug.

Also consolidated the R2 attachment-key format into a single helper
`attachmentObjectKey(emailId, attachmentId, filename)` shared by
storeAttachments, the receive path, the email-delete handler, the
attachment download endpoint, and the new DO method. Previously the
literal `attachments/${emailId}/${attId}/${filename}` template was
duplicated in five places; a format drift between write and
read/delete sites would silently orphan blobs.

Confirmed safe: EmailAgent DOs are per-mailbox via
idFromName(mailboxId), so reset() only wipes this mailbox's state.
Cloudflare Access JWT middleware (workers/app.ts) covers the
endpoint — this is not exposing destructive action to unauthed
callers.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the TODO in workers/security/attachments.ts that flagged
password-protected archives as a classic malware-smuggling pattern
we weren't catching. Emotet, Qakbot and similar campaigns attach an
encrypted ZIP and put the password in the email body — the encryption
defeats AV scanning at the gateway while the user still opens it.

Implemented in the async deep-scan path rather than the sync pipeline
so the hot receive path stays filename+MIME only (no R2 reads). The
new `detectEncryptedArchive(bytes, ext)` parses the first 32KB of the
attachment:

  - ZIP: scans for the local-file-header signature (0x04034b50) —
    not assuming offset 0 so self-extracting archives with an EXE
    stub are handled — then checks general-purpose flag bit 0.
    Central Directory Encryption is a documented false-negative
    (CD lives at EOF, outside our window).
  - RAR4: walks block headers, returns true on MHD_PASSWORD
    (0x0080 on MAIN_HEAD) or LHD_PASSWORD (0x0004 on file blocks).
  - RAR5 / 7z: deferred. Conservative false rather than risk false
    positives on legitimate archives.

`scoreAttachment` now accepts an optional `{encryptedArchive}`
signal; when true it adds 25 (between macro_enabled_office=20 and
dangerous_extension=40) and suppresses the weaker
`suspicious_archive_name` nudge so reasons don't double-up in the UI.

Existing +30 attachment-aggregation cap and +40 deep-scan cap
preserved — no single flaky signal can push a verdict on its own.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Wire two-domain config (cortech.online + dmarc.mx) and document setup

DOMAINS now lists both domains for E2E testing of inbound/outbound mail.
Replaces KV namespace placeholders with real IDs and documents the
namespace + R2 bucket prerequisites in the README, including a
two-domain end-to-end test checklist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Expose attachments on the MCP send_email tool

The attachment-type gate in the security pipeline could only be exercised
end-to-end via inbound mail; the MCP send_email tool dropped the existing
sendEmail attachments capability. Surface it through the zod schema and
forward base64 payloads to env.EMAIL.send, persisting metadata on the
Sent record so the UI matches what was wired up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Pin RDAP fresh-domain path with deep-scan integration tests

Live testing couldn't isolate the RDAP-only signal — every fresh domain
candidate also had a threat-intel hit, so the score boost and reason came
from the intel feed match rather than RDAP. Cover the four meaningful
RDAP states (≤7 days, 7–29 days, action-upgrade across threshold,
established) by stubbing a fetch that returns canned RDAP bodies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the 30-second inbox poll with a server-pushed event channel so
new mail surfaces immediately and can fire a desktop notification when
the tab is hidden.

- MailboxDO now accepts WebSocket upgrades via the hibernation API and
  exposes notifyNewEmail(emailId, folder) for fanout.
- New /api/v1/mailboxes/:mailboxId/events route gated by the existing
  CF Access middleware + requireMailbox; auth piggybacks on the
  cf-access-jwt-assertion header the edge injects on origin upgrades.
- receiveEmail fanout fires after the sync security verdict and passes
  the final folder so quarantined mail never raises a desktop toast.
- Client useMailboxEvents hook reconnects with jittered backoff;
  mailbox.tsx subscribes for the session, invalidates emails+folders on
  each event, and only fires Notification (tagged by emailId to dedupe
  across tabs) when the tab is hidden and the folder is INBOX.
- Header gains a notification permission bell with granted/denied/
  default states.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(hub): add inbound peer + source_peer_uuid schema

* feat(hub): add admin-key middleware for operator routes

* feat(hub): admin CRUD for inbound peer configs

Adds POST/GET/DELETE /admin/peers routes behind HUB_ADMIN_KEY auth.
POST atomically inserts a peer, synthetic org, inbound_peer, and
sharing_group_orgs row. Also adds batch() to the d1 test shim —
required by admin routes which use multi-statement atomic writes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(hub): inbound MISP sync — pullFromPeer with watermark + UPSERT

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(hub): mount admin routes and wire inbound sync to cron

* docs(hub): restore endpoint reference comments in index.ts

* docs(hub): inbound sync configuration and promotion semantics

* fix(hub): force ASC order on inbound sync + clarify corroboration scope

Final-review fixes:

1. fetchPage now sends order: 'Event.timestamp ASC' to upstream MISP. Without
   it the upstream's order is undefined; if it returns DESC, a deep first
   backfill (>MAX_PAGES * PAGE_SIZE events) advances the watermark past
   older unprocessed events with no way to recover them.

2. README clarifies that local-org corroboration of pulled intel only counts
   when the local report lands in the same sharing group as the peer's
   default_sharing_group_uuid (corroboration rows key on sharing_group_uuid).

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* PhishPilot UI pivot — POC shell, tokens, cases redesign

Foundation for the SOC-console rebrand from design_handoff_phishpilot.
Scope is intentionally limited to evaluating the visual direction on a
preview branch: token system + theme/hue store, new app shell, and the
Cases queue + detail rebuilt against existing case data. Dashboard,
threat-intel hub, and mail review are nav stubs; existing email list and
compose flows remain reachable but un-restyled.

- Tokens: oklch palette + dark/light + brand-hue var ported into
  app/index.css via Tailwind v4 @theme + plain :root / [data-theme]
  blocks. Custom variant wires Kumo's dark: utilities to data-theme.
- Theme/hue: zustand store gains theme/hue/accent fields with manual
  localStorage persistence; /theme-boot.js applies persisted prefs to
  <html> before hydration so first paint has no FOUC.
- Primitives: VerdictPill, ScoreRing, Sparkline, Logo, status/verdict
  tone helpers under app/components/phishpilot.
- Shell: replaces Sidebar+Header with PhishPilot's 232px sidebar (org
  switcher, primary nav with active accent stripe, pipeline status pill,
  user row + theme toggle) and the 52px topbar (search + Ask co-pilot).
- Cases queue: serif H1, segmented tab strip with counts, hairline
  table, status pill per row, mono case IDs and ages.
- Case detail: score ring, status pill, observable IOC rows, resolution
  controls, co-pilot/pipeline-trace placeholders. Score is a deliberate
  placeholder until the security pipeline writes per-case scoring.
- Routes: /dashboard and /hub stubs added; mailbox index now lands on
  Dashboard instead of inbox.
- Dev: vite.config.ts now allows fs serving from the parent checkout so
  worktree dev sessions can resolve symlinked node_modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: keep mailbox index landing on inbox until dashboard is real

Dashboard route is still a placeholder showing "—" for every KPI;
redirecting the bare /mailbox/:id URL there would regress the UX for
anyone hitting the root. Restore the original emails/inbox landing.
The /dashboard and /hub routes remain reachable via direct nav.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Support full Access cert URLs for TEAM_DOMAIN

* Clarify Access troubleshooting in README

* Link Access reset docs in troubleshooting

* Revise Cloudflare deployment setup instructions

Updated setup instructions for deploying to Cloudflare, including important notes and reordering of steps.

---------

Co-authored-by: Thomas Gauvin <tgauvin@cloudflare.com>
Co-authored-by: Thomas Gauvin <35609369+thomasgauvin@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: rename project to PhishSOC across code and docs

Closes #17 modulo the deferred Cloudflare resource renames.

Replaces the Agentic-Inbox-era names from main and the PhishPilot
branding shipped in #21 with PhishSOC. PhishSOC speaks the buyer's
vocabulary directly (SOC == Security Operations Center) where Pilot
was generic AI-assistant framing in a crowded namespace.

User-visible name strings:
- README.md heading + intro + screenshot alt text
- hub/README.md project description
- package.json name + cloudflare label
- package-lock.json name (top-level + root package)
- app/root.tsx <title>
- app/routes/home.tsx meta title
- workers/mcp/index.ts MCP server name

Brand identity from #21:
- app/components/phishpilot/ -> app/components/phishsoc/ (5 imports updated)
- Logo wordmark "Phish/Pilot" -> "Phish/SOC" + comment
- index.css comments
- useUIStore STORAGE_KEY "phishpilot-ui" -> "phishsoc-ui" + comment
- public/theme-boot.js localStorage key (must match useUIStore)

Localstorage migration: changing the storage key resets persisted
theme/hue prefs for any preview users. SSR defaults (dark / Rust)
take over so first paint is unchanged; only manual customizations
are lost. Acceptable at POC stage.

Intentionally untouched (per #17 deferral):
- wrangler.jsonc worker name + R2 bucket_name / preview_bucket_name
  (worker rename = new deploy; R2 not in-place renameable)
- README.md R2 bucket setup instructions (match deferred bucket name)
- README.md Deploy-to-Cloudflare button URL (still upstream
  cloudflare/agentic-inbox; moves with the GitHub repo rename)
- pp- CSS class prefix (internal-only styling; rename is pure churn)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: point Deploy to Cloudflare button at schmug/PhishSOC

Repo was renamed from schmug/agentic-inbox to schmug/PhishSOC. Deploy
button now provisions the rebranded fork instead of upstream Cloudflare
so the README is internally consistent (clicking "Deploy to Cloudflare"
under a PhishSOC heading shouldn't deploy a different project).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: add GitHub Actions, CodeQL, and Dependabot

Adds three baseline checks under .github/ chosen for low cost on PRs:

- ci.yml: typecheck + test on PRs to main and pushes to main. Single
  Ubuntu/Node 20 runner with npm cache, 15-min timeout, paths-ignore
  for docs-only changes, and concurrency that cancels superseded PR
  runs (but not main).
- codeql.yml: javascript-typescript scan with security-and-quality
  query suite. Runs on JS/TS PR diffs, pushes to main, and a weekly
  Monday cron. 20-min timeout.
- dependabot.yml: weekly grouped npm + actions updates. Tiptap (~25
  packages), react/react-router, and Cloudflare/wrangler are grouped
  to collapse what would otherwise be 30+ weekly PRs into ~5; capped
  at 5 npm + 3 actions PRs open.

No matrix and no separate build job — typecheck covers compile-time
correctness and deploy is run locally, so we don't pay for it twice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ui): annotate toggleTheme local so theme keeps Theme type

The conditional `get().theme === "dark" ? "light" : "dark"` widened to
`string` when spread into the prefs object, so `savePrefs(next)` was
called with `{ theme: string, ... }` against `PersistedPrefs` (TS2345).
Annotating the local as `Theme` preserves the literal narrowing through
the spread.

Surfaced by the new CI typecheck gate added in the previous commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#35 added paths-ignore to CI and a paths allowlist to CodeQL to skip
docs-only changes. With branch protection now requiring both checks
(plus enforce_admins), a PR that only touches the ignored paths never
triggers either workflow — the required checks stay pending forever
and the merge is blocked even for admins.

Drop both filters. Trade-off:

- Cost: ~37s of Typecheck & Test + ~1m18s of CodeQL on every PR,
  including docs-only ones. Negligible on a public repo with free
  Actions minutes.
- Benefit: required checks always fire, branch protection works
  honestly, no chance of a workflow-skip × required-check deadlock,
  and no ghost-check companion workflow to keep in sync as the CI
  matrix grows.

This is what kubernetes, rust-lang, react, and most large OSS projects
do for the same reason. The ghost-check pattern is the right answer
when CI is genuinely expensive (long matrix builds); for a 37-second
job it's over-engineering with a real maintenance footgun.

Unblocks #40 and any future docs-only PR.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the GitHub-side hygiene that complements the CI/CodeQL/Dependabot
baseline in #35:

- SECURITY.md routes vuln reports through GitHub Security Advisories
- CODEOWNERS auto-requests review on every change
- PR template locks in the Summary / Why / Test plan structure
- bug_report and feature_request issue forms with required fields,
  config.yml disables blank issues and points security reports to the
  private advisory flow

Docs/config only — no source paths touched. CI on this PR still runs
(these files are not in the workflow paths-ignore).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.12.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](postcss/postcss@8.5.8...8.5.12)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: schmug <38227427+schmug@users.noreply.github.com>
Bumps [hono](https://github.com/honojs/hono) from 4.12.8 to 4.12.15.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](honojs/hono@v4.12.8...v4.12.15)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.15
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: schmug <38227427+schmug@users.noreply.github.com>
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.3.3 to 3.4.0.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](cure53/DOMPurify@3.3.3...3.4.0)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: schmug <38227427+schmug@users.noreply.github.com>
The top-of-file tagline and first two paragraphs still inherited the
upstream cloudflare/agentic-inbox framing ("a self-hosted email client
with an AI agent"), which underplays what PhishSOC actually does. The
rest of the README — Features, Stack, Security, Architecture — already
covers the security pipeline; only the lede was stale.

- Tagline now leads with "phishing-aware email SOC" and names the
  scoring pipeline (SPF/DKIM/DMARC + URL/RDAP).
- First paragraph rewrites the email-client framing as a phishing-
  detection layer wrapped around the Cloudflare-native mail stack, and
  inlines the pipeline stages so the value prop is visible above the
  fold.
- Agent paragraph reflects what the rest of the doc already says: 9
  tools, MCP-exposed, send-confirmation always required.

No claims added that aren't already documented elsewhere in the README.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps [drizzle-orm](https://github.com/drizzle-team/drizzle-orm) from 0.45.1 to 0.45.2.
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](drizzle-team/drizzle-orm@0.45.1...0.45.2)

---
updated-dependencies:
- dependency-name: drizzle-orm
  dependency-version: 0.45.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](lodash/lodash@4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: schmug <38227427+schmug@users.noreply.github.com>
Bumps [@hono/node-server](https://github.com/honojs/node-server) from 1.19.11 to 1.19.14.
- [Release notes](https://github.com/honojs/node-server/releases)
- [Commits](honojs/node-server@v1.19.11...v1.19.14)

---
updated-dependencies:
- dependency-name: "@hono/node-server"
  dependency-version: 1.19.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: schmug <38227427+schmug@users.noreply.github.com>
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.4.1 to 6.4.2.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.4.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.4.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: schmug <38227427+schmug@users.noreply.github.com>
Bumps [picomatch](https://github.com/micromatch/picomatch) from 4.0.3 to 4.0.4.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](micromatch/picomatch@4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: schmug <38227427+schmug@users.noreply.github.com>
* Bump undici, @cloudflare/vite-plugin and wrangler

Bumps [undici](https://github.com/nodejs/undici) to 7.24.8 and updates ancestor dependencies [undici](https://github.com/nodejs/undici), [@cloudflare/vite-plugin](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/vite-plugin-cloudflare) and [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler). These dependencies need to be updated together.


Updates `undici` from 7.18.2 to 7.24.8
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](nodejs/undici@v7.18.2...v7.24.8)

Updates `@cloudflare/vite-plugin` from 1.29.0 to 1.34.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Changelog](https://github.com/cloudflare/workers-sdk/blob/main/packages/vite-plugin-cloudflare/CHANGELOG.md)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/@cloudflare/vite-plugin@1.34.0/packages/vite-plugin-cloudflare)

Updates `wrangler` from 4.74.0 to 4.86.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.86.0/packages/wrangler)

---
updated-dependencies:
- dependency-name: "@cloudflare/vite-plugin"
  dependency-version: 1.34.0
  dependency-type: direct:development
- dependency-name: undici
  dependency-version: 7.24.8
  dependency-type: indirect
- dependency-name: wrangler
  dependency-version: 4.86.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(types): drop now-redundant @ts-expect-error on llama model string

wrangler 4.86 / @cloudflare/workers-types now includes
"@cf/meta/llama-3.1-8b-instruct-fast" in the generated Ai.run() model
union, so the two @ts-expect-error suppressions become "unused"
directives (TS2578) and fail typecheck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: schmug <38227427+schmug@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
schmug and others added 11 commits April 28, 2026 09:25
* ci: extend Typecheck & Test to cover the hub/ subpackage

Two pieces:

1. Adds a `Typecheck & Test (hub)` job to .github/workflows/ci.yml
   that runs `npm ci`, `npm run typecheck`, and `npm test` inside
   `hub/`. Uses setup-node's `cache-dependency-path` so each subpackage
   gets its own npm cache key. Runs in parallel with the existing
   root verify job.

2. Refreshes hub/package-lock.json. Discovered while writing the new
   job: `npm ci` on hub failed with "Missing: @emnapi/core@1.9.2 from
   lock file" — the lockfile had drifted from package.json on main.
   This is why hub tests have never been in CI: nobody could run them
   reproducibly. The refresh adds one missing transitive (@emnapi/core
   under @rolldown/binding-wasm32-wasi) and clears one stale `peer`
   flag.

After this lands, every PR touching the hub/ Worker (including any
future Dependabot bumps) will be exercised against hub's own Vitest
suite (currently 42 tests across 6 files) and tsc, instead of getting
a false-green from the root pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: use npm install for hub-verify (lenient on platform drift)

The first hub-verify run failed on Linux because hub's lockfile carries
platform-specific optional deps that resolve differently on macOS vs
Linux runners. Switch to `npm install` so CI is resilient to this drift.

Goal of hub-verify is "hub's code typechecks and tests pass" — root
CI's `npm ci` is what gates byte-exact reproducibility for the
deployable artifact. The hub Worker is deployed via wrangler from a
fresh install anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps [undici](https://github.com/nodejs/undici) to 7.24.8 and updates ancestor dependency [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler). These dependencies need to be updated together.


Updates `undici` from 5.29.0 to 7.24.8
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](nodejs/undici@v5.29.0...v7.24.8)

Updates `wrangler` from 3.114.17 to 4.86.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.86.0/packages/wrangler)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.24.8
  dependency-type: indirect
- dependency-name: wrangler
  dependency-version: 4.86.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: schmug <38227427+schmug@users.noreply.github.com>
…imit (#54)

* feat(hub): rate-limit POST /orgs/accept against token brute-force

Adds a Workers Rate Limiting binding (10 req/min/IP) on the public
invite-redemption endpoint. Without it, an attacker who learns a hub
URL can brute-force invite tokens (43-char base64url space, but no
rate cap is no rate cap). Authenticated routes are unchanged — they
already require a hashed API key.

Also wires the D1 database_id so this branch deploys cleanly without
needing a manual edit per the README bootstrap steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(hub): log upstream status + body on inbound sync fetch failure

Previously, sync.ts only recorded "upstream returned non-OK on page N"
when a peer's restSearch failed. That message is the same for HTTP 401
(bad API key), 522 (CF couldn't connect), 526 (TLS chain incomplete),
and a network exception — distinguishing them required reading code or
attaching a tail. Operators were burning hours diagnosing peer onboarding.

Now logs include:
  - the request URL
  - HTTP status + statusText
  - first 400 bytes of the response body
  - the underlying exception message when fetch() itself throws

This is `console.warn`, so it's tail-visible without changing log levels
in prod. The DB-recorded `last_error` shape is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(hub): preflight probe on POST /admin/peers with TLS hint catalog

Without a probe, the only way to learn that a peer is misconfigured was
to wait up to 5 min for the cron to fire and then check D1 for
last_error. The error itself was opaque (see prior commit). Operators
hit this hard during real onboarding — a 30-second TLS misconfig burned
half an hour of triage time.

The probe is a single restSearch (limit: 1) issued from the Worker
during peer creation. It runs after the sharing-group check, before any
DB inserts, with a 10s timeout. On failure, the admin gets HTTP 400 with
a structured response:

  {
    "error": "preflight_failed",
    "probe": {
      "stage": "secret" | "fetch" | "status",
      "status": <number?>,
      "body_snippet": <string?>,
      "error": <string?>,
      "hint": <string?>
    }
  }

The hint catalog handles the cases I've seen in practice or expect:
HTTP 526 (incomplete cert chain — most common nginx misconfig),
525 (handshake), 522 (CF can't reach origin), 401/403 (bad API key),
404 (wrong base_url or non-MISP), and bot-challenge bodies. Each hint
is one sentence, copy-pastable, with the verification command where
applicable.

Escape hatch: `skip_probe: true` in the body. Lets operators register a
peer that's currently down (e.g. waiting for the upstream to fix their
chain) and gives tests a way to bypass the outbound fetch.

Tests cover all three failure stages plus the skip path. Nothing is
written to D1 on probe failure — the existing `peers` / `inbound_peers`
/ `orgs` rows stay clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-draft & model picker (#59)

* docs: add PhishSOC UI continuation design spec

Plans the three-PR sequence for finishing the PhishSOC visual-language
migration on remaining surfaces and shipping the two UI-touching open
issues: #19 (loading/error states) and #11 (auto-draft toggle + agent
model picker). Defers infra rename (worker, R2 bucket), README link
cleanup, and onboarding rebuild to follow-up work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: add PhishSOC UI continuation implementation plan

Three-PR plan with bite-sized tasks: PR A token sweep + dead-code, PR B
loading/error states (#19), PR C settings auto-draft + model (#11).
Discovered no frontend test runner exists, so PR A/B validate via
typecheck + manual smoke and PR C ships workers tests for the new
schema. Toast helper uses kumo's actual variant: "error" API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: delete unused Header and Sidebar components

Both replaced by app/components/phishsoc/Shell. No remaining importers.

* fix(types): restore @ts-expect-error on llama model string

The @cf/meta/llama-3.1-8b-instruct-fast model is not present in the
AiModels union generated by the current wrangler version, so the
ai.run(...) call sites need the suppression. The directives were
removed in ab66b01 when the model was briefly in the union; PR #47's
dependency bump (undici, vite-plugin, wrangler) regenerated
worker-configuration.d.ts without the model and re-introduced the
typecheck failure on main. Restoring the directives unblocks CI for
this branch and any other branches based on current main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(home): swap kumo classes to PhishSOC tokens, add Logo header

* style(settings): swap kumo classes to PhishSOC tokens

* style(email-list): swap kumo classes to PhishSOC tokens

* style(search-results): swap kumo classes to PhishSOC tokens

* style(routes): finish kumo→token sweep across remaining routes

* style(verdict-badge): swap kumo classes to PhishSOC tokens

* style(mailbox-split-view): swap kumo classes to PhishSOC tokens

* style(email-attachment-list): swap kumo classes to PhishSOC tokens

* style(rich-text-editor): swap kumo classes to PhishSOC tokens

* style(mcp-panel): swap kumo classes to PhishSOC tokens

* style(security-settings-panel): swap kumo classes to PhishSOC tokens

* style(email-panel-header): swap kumo classes to PhishSOC tokens

* style(email-panel-toolbar): swap kumo classes to PhishSOC tokens

* style(email-panel-dialogs): swap kumo classes to PhishSOC tokens

* style(email-panel-security-verdict): swap kumo classes to PhishSOC tokens

* style(email-panel-single-message): swap kumo classes to PhishSOC tokens

* style(email-panel-thread-message): swap kumo classes to PhishSOC tokens

* style(email-panel): swap kumo classes to PhishSOC tokens

* style(compose-panel): swap kumo classes to PhishSOC tokens

* style(compose-email): swap kumo classes to PhishSOC tokens

* style(agent-panel): swap kumo classes to PhishSOC tokens

* style(agent-sidebar): swap kumo classes to PhishSOC tokens

* style(root): swap kumo classes to PhishSOC tokens

* Revert "fix(types): restore @ts-expect-error on llama model string"

This reverts commit 21694ad.

* feat(lib): add useFeedback toast helper

Wraps kumo's useKumoToastManager so call sites stop repeating the
{variant: 'error'} shape and so the toast surface is centralized for
future style/layout changes.

* feat(compose): route compose toasts through useFeedback

Replaces direct useKumoToastManager calls in useComposeForm with the new
feedback helper so the toast surface is centralized. ComposeEmail's send
and save-draft buttons were already disabled while isSending is true, so
no wiring change was needed there. No behavior change beyond the toast
plumbing.

* feat(agent-panel): toast on agent chat errors

Wires onError on useAgentChat to surface failures via the feedback
helper. Without this, a failed request silently leaves the chat in a
non-streaming state with no user feedback.

* feat(email-list): toast on mutation errors

Wires onError on fire-and-forget mutations (move/delete/mark-read/star)
in EmailPanel and email-list so failures surface as toasts instead of
silent no-ops. Migrates existing toastManager.add sites to the shared
useFeedback helper.

* feat(search-results): row-shaped skeleton + error toast

Replaces the single Loader with a 5-row pulsing skeleton that matches
the search-result row layout, and surfaces query failures via the
feedback helper.

* refactor(toasts): route remaining sites through useFeedback

Migrates ReportPhishButton, settings, home, and case-detail off direct
useKumoToastManager calls so the toast surface is fully centralized.

* feat(search-results): toast on row-click mark-read failure

Fire-and-forget mark-as-read mutation in search-results lacked onError,
unlike its email-list counterpart. Closes the gap so silent failures
don't slip through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(shared): add MailboxSettings zod schema with autoDraft + agentModel

* test(settings): cover MailboxSettings defaults and passthrough

* feat(workers): gate auto-draft dispatch on settings.autoDraft.enabled

Reads the per-mailbox MailboxSettings blob from R2 and skips the agent
onNewEmail dispatch when autoDraft.enabled is false. Defaults to true
when settings are absent or malformed (preserves existing behavior).

Adds workers/lib/mailbox-settings.ts as a shared helper used by both
workers/index.ts (auto-draft gate) and workers/agent/index.ts (model +
prompt selection — see follow-up commit).

* feat(agent): read agentModel from per-mailbox settings

Both streamText (chat) and generateText (auto-draft) now use
settings.agentModel. Defaults to the existing kimi-k2.5 model when the
field is absent — no migration required for existing mailboxes.

* feat(settings): add Behavior card with auto-draft toggle and model picker

Closes upstream issue #11. Hand-curated model list with custom free-text
fallback; validates @cf/ prefix client-side. Defaults preserve existing
behavior for already-configured mailboxes (autoDraft enabled, current
default model).

* feat(settings): explicit error for empty custom model

Reviewer flagged that picking "Custom…" and leaving the input blank
silently saved as the default Kimi model. Add a guard so the user gets
an explicit toast and the save bails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#70)

Closes #65.

The existing vitest config only ran Node-only suites under `tests/` and
`test/`, so frontend behavior (mutation pending states, toast assertions,
form interactions) could not be covered in tests. PRs #60 and #61 had to
validate via `npm run typecheck` plus manual smoke.

Adds a second vitest project with `environment: "jsdom"`, RTL, and
jest-dom matchers. Two starter test files exercise the harness:

- `feedback.test.tsx` — smallest possible jsdom + RTL check; mocks
  kumo's toast manager and asserts `useFeedback` forwards the right
  shape.
- `settings-behavior.test.tsx` — renders `SettingsRoute` via a
  `MemoryRouter`, mocks `useMailbox` / `useUpdateMailbox`, exercises
  the auto-draft toggle + agent-model picker (including the two
  validation error paths) end-to-end against the real `<Toasty>`.

Implementation notes:

- Uses Vitest 4's `projects` config (the path forward; the issue body
  suggested `environmentMatchGlobs` but that was deprecated in v3).
- `~/*` and `shared/*` are wired via explicit `resolve.alias` rather
  than `vite-tsconfig-paths`. The plugin works in `vite.config.ts` but
  does not propagate into nested project configs in Vitest 4 — explicit
  aliases are the documented escape hatch.
- `tests/frontend/**` added to `tsconfig.cloudflare.json` includes so
  `tsc -b` actually picks it up. Verified by introducing a deliberate
  type error and confirming the probe failed before adding the include
  and passed after.
- `esbuild.jsx: "automatic"` set explicitly so `.tsx` test files don't
  need a `React` import (the project tsconfig declares this but vite
  doesn't follow project references).

The kumo `Input must have an accessible name` warnings in test stderr
are pre-existing in `app/routes/settings.tsx` and not introduced here.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nt (#73)

Closes #56.

Replaces the POC stub at `app/routes/dashboard.tsx` (hardcoded sparkline,
`—` placeholders) with a real Operations dashboard backed by a single
aggregation endpoint. Flips `mailbox-index.tsx` back to land on the
dashboard — the inbox stays one click away via the Shell's "Mail review"
nav item.

Backend (`workers/`):
- New DO method `MailboxDO.getDashboardSummary()` runs five indexed
  queries against the existing schema and returns a single payload.
- New route `GET /api/v1/mailboxes/:mailboxId/dashboard` composes the DO
  result with two pure helpers (`bucketThreatPressure`,
  `pipelineSuccessRate`) in `workers/lib/dashboard-aggregation.ts`.
- Migration `11_dashboard_indexes` adds `idx_cases_updated_at` to
  support the `updated_at >= now-24h` filters.

Cards:
- Threats blocked · 24h — count of cases with `status = 'closed-tp'`
  closed in the last 24h.
- Open cases — count of `cases.status = 'open'`.
- Pipeline success · 24h — completed / (completed + failed) over
  `emails.deep_scan_status` for the last 24h, or `—` when no runs.
- Hub contributions · 24h — local proxy on `cases.shared_to_hub`.
- Threat pressure sparkline — 12 × 2-hour buckets of the count of
  emails with verdict in {tag, quarantine, block} over the last 24h.
- Recent cases — top 5 by `updated_at desc`, each linking to
  `/mailbox/:id/cases/:caseId`.

Frontend (`app/`):
- `useDashboardSummary` hook + `api.getDashboardSummary`.
- New `DashboardSummary` and `DashboardCase` types.
- Loading state (kumo Loader), error state with retry that calls
  React Query's refetch, and empty-state per-card copy.

Spec deltas (issue acceptance vs. ship):
- "p95 latency" card scope-cut to "% success" — pipeline doesn't yet
  log per-run timing. Follow-up: #71.
- "Hub corroboration" card scope-cut to "Hub contributions" (local
  signal). Follow-up: #72.
- Issue says "loader endpoint(s) under `app/routes/api.*`"; this stack
  has its API in the worker, so the endpoint lives in
  `workers/index.ts`. Functionally equivalent.
- Auth scoping: `requireMailbox` is path-only today (pre-existing per
  #27); the dashboard endpoint inherits that scope.

Tests:
- 10 unit tests for `bucketThreatPressure` + `pipelineSuccessRate`
  pure helpers.
- 5 frontend tests on `DashboardRoute` (loading / error+retry / KPIs /
  empty-card copy / case link).
- 180 tests pass overall (was 165).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes #57.

Replaces the "Hub coming soon" placeholder at `app/routes/hub.tsx` with
three real panels backed by worker proxy endpoints. The browser can't
talk to the MISP hub directly because the hub API key is a worker
secret, so each panel call goes through `GET /api/v1/mailboxes/:id/hub/*`
which resolves the per-mailbox `intel.hub` config and forwards.

Backend (`workers/`):
- New helper `workers/lib/hub-config.ts` with `loadHubConfig` +
  `loadHubCredentials`. Replaces the inline `loadHubSettings` from
  `routes/cases.ts` (now deleted) — single source of truth.
- `MispClient` extended with `searchEvents`, `listSharingGroups`, and a
  scoped variant of `fetchDestroyList({ sharingGroup })`.
- New `workers/routes/hub-ui.ts` exposes `/contributions`, `/destroylist`,
  `/sharing-groups` under `/api/v1/mailboxes/:mailboxId/hub`. Each
  returns `{ configured: true, data }` or `{ configured: false }` so
  the UI renders one empty state for the not-configured case.

Frontend (`app/`):
- New types `HubContribution`, `HubSharingGroup`, `HubEnvelope<T>`.
- New `useHubContributions`, `useHubDestroylist`, `useHubSharingGroups`
  hooks (60s staleTime — hub data isn't volatile).
- `app/routes/hub.tsx` rebuilt with three panels (My contributions,
  Destroylist preview, Sharing groups), per-panel loading / empty /
  error states, and a global "Hub not configured" hint that flips to
  if any panel reports `configured: false`. The hint links to the
  mailbox settings.

Sharing-group visibility is enforced server-side by the hub
(`hub/src/routes/feeds.ts:31-36` returns 403 to non-members,
`events.ts:168-172` filters restSearch by `sharing_group_orgs`
membership). A new MispClient test asserts the client doesn't silently
widen a 403'd scoped destroylist request to the unscoped endpoint.

Spec deltas (issue acceptance vs. ship):
- Invite flow (button → modal → POST /orgs/invite → one-time token UX)
  cut to its own PR. Filed as #74. The acceptance bullet "Invite flow"
  is the only one not addressed in this PR.
- Auth scoping for the proxy endpoints inherits the same path-only
  `requireMailbox` check the rest of the worker uses (#27 unchanged).

Tests:
- 7 unit tests for `MispClient` (`searchEvents`, `listSharingGroups`,
  `fetchDestroyList` happy path + scoped + 403-no-widen).
- 4 frontend tests for `HubRoute` (not-configured hint, populated
  panels, per-panel error+retry, partial loading).
- 191 tests pass overall (was 180).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…it (#76)

Closes #58 (mostly — see "Out of scope" below).

Issue #58 had two reach goals once the bigger token migration shipped
in #21/#59: replace the legacy `VerdictBadge` with the new
`phishsoc/VerdictPill`, and confirm no hard-coded hex/rgb colors leaked
into the SOC chrome. Both done here.

VerdictBadge → VerdictPill:
- New `verdictActionToPill(action)` helper in
  `app/components/phishsoc/verdict.ts` maps the security pipeline's
  `verdict.action` onto a `{tone, label}` pair. Returns null for
  `allow` so clean rows stay un-pilled, matching previous behavior.
- `VerdictPill` gained an optional `title` prop so the email-list call
  site can carry the verdict's `explanation` as a hover tooltip — no
  loss of information vs. the old badge.
- `app/routes/email-list.tsx` now renders an inline `EmailVerdictPill`
  component that composes `parseVerdict` + `verdictActionToPill` +
  `VerdictPill` with the same shield iconography (block/quarantine →
  ShieldWarning, tag → Shield).
- `app/components/VerdictBadge.tsx` deleted.

Hex/rgb audit:
- `grep -nE '#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(' app/components app/routes`
  is now clean except for `app/components/EmailIframe.tsx`, which
  styles the email body inside a fully-sandboxed iframe (CSP locks down
  external resources, no same-origin → the SOC tokens aren't reachable).
  Added a comment in EmailIframe explaining why those hex values are
  intentionally distinct from the app's design system.

Tests: 5 unit tests for `verdictActionToPill`. 196 pass overall (was 191).

Out of scope for this PR (still open from #58):
- AgentSidebar / AgentPanel co-pilot affordance restyle to match the
  topbar entry point — that's UX work, not a token sweep, and benefits
  from a separate dedicated review.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The global search input in `app/components/phishsoc/Shell.tsx` was wired to
nothing — pressing Enter, typing, or ⌘K all did nothing. The placeholder
also promised "indicators, cases" but the backend at
/api/v1/mailboxes/:mailboxId/search only searches emails.

This change:
- Wraps the input in a `<form role="search">` with an onSubmit handler that
  navigates to `/mailbox/:id/search?q=<encoded>` when the trimmed query is
  non-empty and a mailboxId is present.
- Adds a global Cmd+K / Ctrl+K listener that focuses the input from any
  screen, matching the placeholder's promise.
- Replaces the placeholder copy with "Search emails…  ⌘K" to match what the
  backend can actually search.

Tests: new `tests/frontend/shell-search.test.tsx` covers Enter-to-navigate,
URL encoding, empty-input no-op, both Cmd+K and Ctrl+K focus, and
placeholder honesty. Full suite: 202 passing.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pill in `app/components/phishsoc/Shell.tsx` was a static-green
"Pipeline online" indicator that ignored real pipeline state — a
misinformation bug worse than no indicator. The "p50 —" placeholder
beside it advertised latency the chrome had no data to back.

Wired the pill to the existing `useDashboardSummary` hook, mapping
`pipelineSuccess` (0..1, or null) to four states:

- >=0.95 → "Pipeline online" (safe, pulse)
- >=0.5  → "Degraded" (suspect, no pulse)
- < 0.5  → "Pipeline failing" (danger, no pulse)
- null   → "No data" (muted, no pulse)

Loading and error states fall through to "No data" — never green —
so an outage during page load can't masquerade as healthy.

Pill is now a button that navigates to the mailbox dashboard, with
`role=status` + `aria-live=polite` + `aria-label` for screen readers.
Dropped the fake "p50 —" text; real latency is tracked separately
(#71) and will land with the pipeline-runs aggregation.

Tests: new `tests/frontend/shell-pipeline-pill.test.tsx` covers the
four state transitions, navigation on click, no-fake-green during
loading, and absence of fabricated latency. Full suite: 209 passing.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ngsPanel (#83)

Two security settings existed in the worker but had no UI: attachment_policy
(executable/container/macro actions + custom blocklist) and folder_policies
(per-folder skip_classifier / skip_all / treat_as_verified). Operators had no
way to change them without editing R2 directly.

This PR adds two collapsible sections to the existing SecuritySettingsPanel,
extends the zod schema in shared/mailbox-settings.ts to validate the new
shapes (passthrough at every level so unrelated security fields like
allowlist_senders round-trip untouched), and wires safeParse + 400 into the
PUT /api/v1/mailboxes/:id route.

The runtime consumer in workers/security/settings.ts (getSecuritySettings)
already merges defaults field-by-field; no consumer wiring was needed.

Schema tests cover defaults preservation, valid policies, invalid enums
(400 path), forward-compatible folder names, and the passthrough regression
guard. UI tests assert save round-trip for executable_action, custom
blocklist, skip_classifier mode, and treat_as_verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@schmug

schmug commented Apr 30, 2026

Copy link
Copy Markdown
Author

Wrong remote — re-creating on schmug/PhishSOC

@schmug schmug closed this Apr 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant