diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 04bac74..b56c972 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -404,7 +404,8 @@ summarizes classification, blast radius, and recommended/approved remediation st
1. When Mimecast is configured, querying the blast-radius abstraction for a message (keyed on message ID, sender, recipient/reporter, subject, and date window) returns normalized delivery data — matched/delivered/held/rejected/clicked counts and per-recipient status
2. When Mimecast is not configured, the same lookup call returns `status: unavailable` synchronously rather than throwing, timing out, or blocking the caller
3. The lookup follows the existing `lib/services/` factory convention (`getMimecastClient()` + `isMimecastConfigured()`-equivalent) so Phase 19's classifier can call it without knowing whether Mimecast is present
-**Plans**: TBD
+**Plans**: 1 plan
+- [ ] 17-01-PLAN.md — isMimecastConfigured() gate + mimecast-blast-radius.ts fan-out/merge/cache orchestration + tests (BLAST-01, BLAST-02)
**UI hint**: no
### Phase 18: Campaign Grouping & Phishing Analysis API
diff --git a/.planning/phases/17-mimecast-blast-radius-lookup/17-01-PLAN.md b/.planning/phases/17-mimecast-blast-radius-lookup/17-01-PLAN.md
new file mode 100644
index 0000000..f304b3f
--- /dev/null
+++ b/.planning/phases/17-mimecast-blast-radius-lookup/17-01-PLAN.md
@@ -0,0 +1,252 @@
+---
+phase: 17-mimecast-blast-radius-lookup
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - lib/services/mimecast-client.ts
+ - lib/services/mimecast-client.test.ts
+ - lib/services/mimecast-blast-radius.ts
+ - lib/services/mimecast-blast-radius.test.ts
+autonomous: true
+requirements: [BLAST-01, BLAST-02]
+
+must_haves:
+ truths:
+ - "isMimecastConfigured() returns true only when both MIMECAST_CLIENT_ID and MIMECAST_CLIENT_SECRET are set, false otherwise"
+ - "getBlastRadius() returns status:'unavailable' reason:'not_configured' synchronously (no Mimecast call) when Mimecast is unconfigured"
+ - "getBlastRadius() when configured returns normalized matched/delivered/held/rejected/clicked counts plus a per-recipient status array, built by fanning out to searchDeliveredMessages + getHeldMessages + getThreatEvents"
+ - "An unexpected error thrown during the fan-out degrades to status:'unavailable' reason:'lookup_failed' rather than propagating to the caller"
+ - "A repeated lookup for the same message identity within the cache TTL returns the cached result without re-calling any MimecastClient method"
+ artifacts:
+ - path: "lib/services/mimecast-client.ts"
+ provides: "isMimecastConfigured() config gate + _resetMimecastClient() test seam"
+ contains: "export function isMimecastConfigured"
+ - path: "lib/services/mimecast-blast-radius.ts"
+ provides: "getBlastRadius() orchestration + BlastRadiusInput/BlastRadiusResult types"
+ exports: ["getBlastRadius", "BlastRadiusInput", "BlastRadiusResult"]
+ - path: "lib/services/mimecast-client.test.ts"
+ provides: "Unit tests for isMimecastConfigured() + getMimecastClient() throw/cache behavior"
+ - path: "lib/services/mimecast-blast-radius.test.ts"
+ provides: "Unit tests for getBlastRadius() config gate, fan-out merge, never-throw, cache-hit"
+ key_links:
+ - from: "lib/services/mimecast-blast-radius.ts"
+ to: "lib/services/mimecast-client.ts"
+ via: "import isMimecastConfigured + getMimecastClient"
+ pattern: "from './mimecast-client'"
+ - from: "lib/services/mimecast-blast-radius.ts"
+ to: "lib/services/redis-client.ts"
+ via: "getCachedData / setCachedData"
+ pattern: "from './redis-client'"
+---
+
+
+Build the Mimecast blast-radius lookup abstraction: a pure, never-throwing
+orchestration function that, given a reported message's identity, returns
+normalized delivery data (matched/delivered/held/rejected/clicked counts +
+per-recipient status) when Mimecast is configured, and a clean
+`status: 'unavailable'` signal when it isn't. Also adds the missing
+`isMimecastConfigured()` config-gate helper to `mimecast-client.ts` so the
+abstraction (and future callers) can check config presence without triggering
+the throw in `getMimecastClient()`.
+
+Purpose: Phase 19's classifier needs a stable, typed, non-throwing
+blast-radius signal it can call without knowing whether Mimecast is present.
+This phase delivers exactly that abstraction and nothing more — no schema, no
+HTTP route, no UI (per D-03 the lookup is ephemeral).
+
+Output: `isMimecastConfigured()` + `_resetMimecastClient()` in
+`mimecast-client.ts`; new `mimecast-blast-radius.ts` module; two new test files.
+Zero new dependencies, zero migrations.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/17-mimecast-blast-radius-lookup/17-CONTEXT.md
+@.planning/phases/17-mimecast-blast-radius-lookup/17-RESEARCH.md
+@.planning/phases/17-mimecast-blast-radius-lookup/17-PATTERNS.md
+@.planning/phases/17-mimecast-blast-radius-lookup/17-VALIDATION.md
+
+
+
+
+
+Existing factory (lib/services/mimecast-client.ts, ~line 645-661):
+ let _client: MimecastClient | null = null; // module-level singleton
+ export function getMimecastClient(): MimecastClient // throws 'MIMECAST_CLIENT_ID and MIMECAST_CLIENT_SECRET must be set' when unconfigured
+ export function getMimecastClientForTenant(tenant: {...}) // DO NOT touch — out of scope (D-05: single global client only)
+
+Four MimecastClient methods the abstraction composes (return types verified in 17-RESEARCH.md Pattern 3 / 17-PATTERNS.md File 2):
+ getMessageInfo(messageId: string): Promise
+ // MimecastMessageInfo = { messageId; bodyText?; bodyHtml?; headers? } — BODY/HEADERS ONLY, no status/counts (Pitfall 1)
+ searchDeliveredMessages(options: { to?; from?; subject?; startHours?; start?; end?; route? }): Promise<{ messages: MimecastDeliveredMessage[]; error? }>
+ // MimecastDeliveredMessage.to is a single recipient string; .status is a string (exact rejection enum UNCONFIRMED — see 17-RESEARCH A3). NEVER throws (internal try/catch).
+ getHeldMessages(options: { recipient?; maxMessages? }): Promise<{ messages: MimecastHeldMessage[]; totalCount }>
+ // MimecastHeldMessage.to is a single recipient string. CAN throw (paginated loop, no top-level try/catch) — orchestrator MUST wrap.
+ getThreatEvents(options?: { cursor?; pageSize? }): Promise<{ items: MimecastThreatEvent[]; nextCursor: string | null }>
+ // MimecastThreatEvent.analysis?: string[]. No server-side sender/subject/date filter — filter client-side. NEVER throws.
+
+redis-client.ts exports being reused (do NOT hand-roll ioredis):
+ getCachedData(key: string): Promise // returns null when REDIS_URL unset (silent no-op)
+ setCachedData(key: string, data: T, ttlSeconds = 300): Promise // default TTL already 300s = D-04's 5 min
+
+Factory-convention analogs to mirror:
+ lib/services/pax8-factory.ts — isPax8Configured() one-liner + _resetPax8Client() test seam
+ lib/services/pax8-factory.test.ts — beforeEach env-var-delete + reset pattern
+ lib/services/phishing-eml-service.ts — orchestration/graceful-degrade module shape
+ lib/services/phishing-eml-service.test.ts — vi.mock() factory-mocking discipline (import module AFTER mocks)
+ app/api/addigy-devices/route.ts — the one place getCachedData/setCachedData is called end-to-end; cache-key format `::`
+
+
+
+
+
+
+ Task 1: Add isMimecastConfigured() config gate + _resetMimecastClient() test seam + tests
+ lib/services/mimecast-client.ts, lib/services/mimecast-client.test.ts
+
+ - lib/services/mimecast-client.ts (lines 640-690 — the existing `_client` singleton, `getMimecastClient()`, and `getMimecastClientForTenant()`; add new exports near here, do NOT modify these)
+ - lib/services/pax8-factory.ts (the isPax8Configured() + _resetPax8Client() shape to mirror exactly)
+ - lib/services/pax8-factory.test.ts (the beforeEach env-var-delete + reset test structure to mirror)
+ - lib/services/integration-health.ts (~line 338 — confirms the exact two env vars MIMECAST_CLIENT_ID / MIMECAST_CLIENT_SECRET that the gate must check)
+
+
+ - isMimecastConfigured() === false when neither env var is set
+ - isMimecastConfigured() === false when only MIMECAST_CLIENT_ID is set
+ - isMimecastConfigured() === false when only MIMECAST_CLIENT_SECRET is set
+ - isMimecastConfigured() === true when both MIMECAST_CLIENT_ID and MIMECAST_CLIENT_SECRET are set
+ - getMimecastClient() throws with message 'MIMECAST_CLIENT_ID and MIMECAST_CLIENT_SECRET must be set' when unconfigured
+ - getMimecastClient() returns the same cached instance on repeated calls once configured (and _resetMimecastClient() clears it between tests)
+
+
+ Add two new exports to `lib/services/mimecast-client.ts`, placed next to the existing `getMimecastClient()` (~line 645), mirroring `pax8-factory.ts`:
+ 1. `export function isMimecastConfigured(): boolean` returning `!!(process.env.MIMECAST_CLIENT_ID && process.env.MIMECAST_CLIENT_SECRET)` — the same two env vars `checkConfigOnly('mimecast', ...)` in integration-health.ts already checks.
+ 2. `export function _resetMimecastClient(): void` that sets the module-level `_client` singleton (declared line 645) back to `null` — a test seam matching `_resetPax8Client()`, needed so `mimecast-client.test.ts` can isolate env-var state per `it()` block.
+ Do NOT alter `MimecastClient` internals, `getMimecastClient()`, or `getMimecastClientForTenant()` behavior — this is purely additive.
+
+ Create `lib/services/mimecast-client.test.ts` mirroring `pax8-factory.test.ts`: a `beforeEach` that `delete`s both env vars and calls `_resetMimecastClient()`; a `describe('isMimecastConfigured')` block covering the four env-var combinations in the behavior list; and a `describe('getMimecastClient')` block asserting it throws the exact string 'MIMECAST_CLIENT_ID and MIMECAST_CLIENT_SECRET must be set' when unconfigured and returns the same instance on repeat calls when configured. This is a Wave 0 test-scaffold requirement (17-VALIDATION.md) — it did not exist before this phase.
+
+
+ npx vitest run lib/services/mimecast-client.test.ts
+ npx tsc --noEmit --pretty
+
+
+ - `lib/services/mimecast-client.ts` contains `export function isMimecastConfigured(`
+ - `lib/services/mimecast-client.ts` contains `export function _resetMimecastClient(`
+ - `grep -c "getMimecastClientForTenant" lib/services/mimecast-client.ts` is unchanged (still present — not deleted/renamed)
+ - `npx vitest run lib/services/mimecast-client.test.ts` exits 0 with all isMimecastConfigured + getMimecastClient cases passing
+ - `npx tsc --noEmit --pretty` reports no errors
+
+
+ isMimecastConfigured() and _resetMimecastClient() are exported from mimecast-client.ts, existing factory functions untouched, and mimecast-client.test.ts passes.
+
+
+
+
+ Task 2: Build mimecast-blast-radius.ts orchestration (fan-out merge, never-throw, Redis cache) + tests
+ lib/services/mimecast-blast-radius.ts, lib/services/mimecast-blast-radius.test.ts
+
+ - lib/services/mimecast-client.ts (return-type interfaces MimecastMessageInfo / MimecastDeliveredMessage / MimecastHeldMessage / MimecastThreatEvent and the four method signatures — build the merge against the real field names)
+ - lib/services/redis-client.ts (getCachedData / setCachedData signatures + default 300s TTL + REDIS_URL-unset no-op behavior)
+ - lib/services/phishing-eml-service.ts (module doc-comment convention + graceful-degrade posture; NOTE: it rethrows at top level — this module must NOT rethrow)
+ - lib/services/phishing-eml-service.test.ts (vi.mock() discipline: declare mocks before importing the module under test)
+ - app/api/addigy-devices/route.ts (lines 1-45 — the `::` cache-key format + explicit 300 TTL call)
+ - .planning/phases/17-mimecast-blast-radius-lookup/17-RESEARCH.md (Pattern 3 = exact per-recipient merge algorithm; Pitfall 1 = getMessageInfo has no counts; Pitfall 2 = clicked best-effort; A3 = status enum unconfirmed)
+
+
+ - Not configured (isMimecastConfigured mocked false) → resolves { status:'unavailable', reason:'not_configured' } and getMimecastClient is never called
+ - Configured + cache hit (getCachedData resolves a fixture result) → returns the cached BlastRadiusResult and none of the four MimecastClient methods are called
+ - Configured + cache miss + fan-out success → merges delivered/held/threat-event fixtures into { status:'ok', matched, delivered, held, rejected, clicked, perRecipient[], source:'fan-out' }, then calls setCachedData exactly once
+ - Fan-out call rejects (e.g. getHeldMessages mockRejectedValue) → resolves { status:'unavailable', reason:'lookup_failed', error } and does NOT throw to the caller
+ - clicked === N when threat-event fixtures include a click-type analysis[] value; clicked === 0 when none match (best-effort, not "confirmed zero")
+ - perRecipient groups delivered+held rows by recipient: delivered rows → 'delivered', held rows → 'held', recipients in neither result set → 'unknown'
+
+
+ Create `lib/services/mimecast-blast-radius.ts` following the `phishing-eml-service.ts` module shape. Open with a doc-comment documenting three load-bearing facts: (a) the lookup is ephemeral per D-03 (no persistence); (b) `clicked` is best-effort derived from getThreatEvents()'s analysis[] subtype — Mimecast's real click data lives in the currently-unwrapped `/api/ttp/url/get-logs` endpoint (17-RESEARCH Pitfall 2), so `clicked: 0` means "no click-type threat event found," not "confirmed zero clicks"; (c) KNOWN LIMITATION D-05 — uses only the single global env-var `getMimecastClient()`, NOT the per-company `mimecast_tenants` table / `getMimecastClientForTenant()`, so reports from companies with their own Mimecast tenant will return `unavailable` even though Mimecast is technically configured for them.
+
+ Export `BlastRadiusInput` with required fields `sender: string`, `recipient: string`, `subject: string`, `dateWindow: { start: Date; end: Date }` and optional `messageId?: string`. Keeping sender/subject/dateWindow non-optional is the V5 input-validation control for T-17-01 (never allow a date-range-only fan-out). Export `BlastRadiusResult` as a discriminated union: `{ status:'unavailable'; reason:'not_configured'|'lookup_failed'; error?: string }` OR `{ status:'ok'; matched:number; delivered:number; held:number; rejected:number; clicked:number; perRecipient: Array<{recipient:string; status:'delivered'|'held'|'rejected'|'unknown'}>; source:'fan-out' }`.
+
+ Export `async function getBlastRadius(input: BlastRadiusInput): Promise`:
+ 1. If `!isMimecastConfigured()` return `{ status:'unavailable', reason:'not_configured' }` synchronously — do NOT construct the client (that would throw).
+ 2. Build the cache key `::` per addigy-devices precedent: with messageId → `mimecast:blast-radius:msgid:${input.messageId}`; else → `mimecast:blast-radius:composite:${input.sender}:${input.subject}:${input.dateWindow.start.toISOString()}:${input.dateWindow.end.toISOString()}`.
+ 3. `const cached = await getCachedData(cacheKey); if (cached) return cached;` — a cache hit must short-circuit BEFORE any MimecastClient call (D-04).
+ 4. Inside a `try`: `const client = getMimecastClient();`. If `input.messageId` is present, optionally `await client.getMessageInfo(input.messageId)` for body/header evidence ONLY — never use its return to derive counts and never let it gate the fan-out (Pitfall 1). Then ALWAYS run the fan-out via `Promise.all([...])` (D-01 corrected — fan-out is unconditional, not a fallback): `searchDeliveredMessages({ to: input.recipient, from: input.sender, subject: input.subject, start, end })` where `start`/`end` are `input.dateWindow.start/end.toISOString().replace(/\.\d{3}Z$/, '+0000')`; `getHeldMessages({ recipient: input.recipient })`; `getThreatEvents()`.
+ 5. Merge per 17-RESEARCH Pattern 3: build `perRecipient[]` by grouping delivered + held rows on their single-string `to` field (delivered → 'delivered', held → 'held', neither → 'unknown'); `delivered` = count of delivered rows, `held` = count of held rows, `rejected` = delivered rows whose `.status` string indicates rejection, `matched` = total delivered + held rows. Because the exact `.status` rejection enum is UNCONFIRMED (17-RESEARCH A3), add a code comment noting this and treat unrecognized status strings conservatively as 'delivered' rather than hardcoding a guessed rejected value as confirmed; log the raw status values seen at debug level for first-real-tenant validation. Derive `clicked` best-effort by counting threat-event items whose `analysis[]` includes a click-type value, else `0` (comment the best-effort limitation).
+ 6. `await setCachedData(cacheKey, result, 300);` then return the result.
+ 7. `catch (err)`: `console.error('[MIMECAST-BLAST-RADIUS] lookup failed', err instanceof Error ? err.message : err)` — log err.message ONLY, never full Mimecast response bodies which may contain other recipients' subjects/content (T-17-02) — and return `{ status:'unavailable', reason:'lookup_failed', error: err instanceof Error ? err.message : String(err) }`. This satisfies BLAST-02: the public entry point never throws.
+
+ Create `lib/services/mimecast-blast-radius.test.ts` mirroring `phishing-eml-service.test.ts`: declare `vi.mock('./mimecast-client', ...)` (exporting mocked `isMimecastConfigured`, `getMimecastClient` returning an object with the four vi.fn() methods) and `vi.mock('./redis-client', ...)` (mocked `getCachedData`/`setCachedData`) BEFORE importing `getBlastRadius`. Cover every case in the behavior list, using inline synthetic objects matching the verified MimecastDeliveredMessage / MimecastHeldMessage / MimecastThreatEvent shapes — no fixture file needed. Assert call counts (e.g. `expect(searchDeliveredMessages).not.toHaveBeenCalled()` on the not-configured and cache-hit paths) to prove the short-circuits.
+
+
+ npx vitest run lib/services/mimecast-blast-radius.test.ts lib/services/mimecast-client.test.ts
+ npx tsc --noEmit --pretty
+ npm test
+
+
+ - `lib/services/mimecast-blast-radius.ts` contains `export async function getBlastRadius(`
+ - `lib/services/mimecast-blast-radius.ts` contains `export interface BlastRadiusInput` and `export type BlastRadiusResult` (or `export interface` for the result union parts)
+ - `grep -q "from './mimecast-client'" lib/services/mimecast-blast-radius.ts` and `grep -q "from './redis-client'" lib/services/mimecast-blast-radius.ts` both succeed
+ - `grep -qi "D-05" lib/services/mimecast-blast-radius.ts` — the multi-tenant known-limitation comment is present
+ - The module's `getBlastRadius` returns `{ status:'unavailable', reason:'not_configured' }` without calling getMimecastClient when unconfigured (asserted by test with call-count check)
+ - A mocked fan-out rejection produces `{ status:'unavailable', reason:'lookup_failed' }` and the promise resolves (does not reject) — asserted by test
+ - A cache-hit test asserts none of the four MimecastClient methods were called
+ - `npx vitest run lib/services/mimecast-blast-radius.test.ts lib/services/mimecast-client.test.ts` exits 0
+ - `npx tsc --noEmit --pretty` reports no errors
+ - `npm test` (full suite) exits 0
+
+
+ getBlastRadius() composes the fan-out into a normalized shape, never throws (returns 'unavailable' on unconfigured or unexpected error), caches via redis-client with a 300s TTL and short-circuits on cache hit, documents the D-02 clicked and D-05 multi-tenant limitations in code, and the full test suite is green.
+
+
+
+
+
+
+Reuses the ASVS L1 applicability analysis already performed in 17-RESEARCH.md
+"Security Domain" — V5 Input Validation applies; V2/V3/V4/V6 do not (no HTTP
+route, no session, no new crypto in this phase). No new external packages are
+installed, so no supply-chain (T-*-SC) checkpoint is required
+(17-RESEARCH "Package Legitimacy Audit": not applicable).
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Phase 19 classifier (in-process) → getBlastRadius() input | Caller-supplied message identity crosses into Mimecast query params; must be bounded (sender + subject + date-window, or Message-ID) — never a date-range-only query |
+| getBlastRadius() → Mimecast API (external, via MimecastClient) | Outbound query + inbound response bodies that may contain other recipients' email metadata/content |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-17-01 | Information Disclosure | mimecast-blast-radius.ts fan-out query | mitigate | `BlastRadiusInput` makes `sender`, `subject`, and `dateWindow` non-optional (V5). Never call `searchDeliveredMessages`/`getHeldMessages` with only a date range — a too-broad query could return another company's unrelated messages. Enforced at the type level and by the required-field input contract. |
+| T-17-02 | Information Disclosure | error/log paths in getBlastRadius catch block | mitigate | Log only `err.message` (never full Mimecast response bodies, which may include other recipients' subjects/content), matching the existing `console.error` discipline in mimecast-sync-service.ts / the mimecast API routes. |
+| T-17-03 | Tampering / Information Disclosure | Redis cache key | accept | v1 uses only the single global env-var `getMimecastClient()` (D-05), so all cached lookups belong to one tenant — no cross-company key collision is possible. Documented in code: IF per-company tenant resolution is added later (Open Question #1), the cache key MUST be extended with a company/tenant discriminator to prevent one company's cached blast-radius being served for another's identically-subjected report. |
+
+
+
+- `npx vitest run lib/services/mimecast-client.test.ts lib/services/mimecast-blast-radius.test.ts` — both new test files green (BLAST-01 fan-out merge + config gate; BLAST-02 unavailable-on-unconfigured + never-throw-on-error; D-04 cache short-circuit).
+- `npm test` — full suite green (no regressions in the ~10 existing lib test files).
+- `npx tsc --noEmit --pretty` — no type errors across the new module + modified client.
+- `grep -qi "D-05" lib/services/mimecast-blast-radius.ts` — multi-tenant known-limitation comment present.
+- Manual inspection: `getMimecastClientForTenant` still present and unmodified in mimecast-client.ts (out-of-scope function not disturbed).
+
+
+
+1. BLAST-01: When Mimecast is configured, `getBlastRadius({ messageId?, sender, recipient, subject, dateWindow })` returns normalized matched/delivered/held/rejected/clicked counts + per-recipient status, built by fanning out to searchDeliveredMessages + getHeldMessages + getThreatEvents (proven by the fan-out-success test).
+2. BLAST-02: When Mimecast is not configured, the same call returns `status: 'unavailable'` synchronously without throwing, timing out, or blocking; and an unexpected fan-out error degrades to `status: 'unavailable'` reason `'lookup_failed'` rather than propagating (both proven by tests).
+3. The lookup follows the factory convention: `isMimecastConfigured()` now exists alongside `getMimecastClient()`, so Phase 19's classifier can call `getBlastRadius()` without knowing whether Mimecast is present.
+4. Zero new dependencies, zero migrations (D-03 ephemeral), no UI/HTTP route added.
+
+
+