---
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'"
---

<objective>
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.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.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

<interfaces>
<!-- Verified exports from lib/services/mimecast-client.ts (read the file to confirm; do NOT modify these). -->
<!-- Executor uses these signatures directly — no exploration needed. -->

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 | null>
    // 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<T>(key: string): Promise<T | null>            // returns null when REDIS_URL unset (silent no-op)
  setCachedData<T>(key: string, data: T, ttlSeconds = 300): Promise<void>  // 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 `<service>:<resource>:<discriminators>`
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add isMimecastConfigured() config gate + _resetMimecastClient() test seam + tests</name>
  <files>lib/services/mimecast-client.ts, lib/services/mimecast-client.test.ts</files>
  <read_first>
    - 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)
  </read_first>
  <behavior>
    - 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)
  </behavior>
  <action>
    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.
  </action>
  <verify>
    <automated>npx vitest run lib/services/mimecast-client.test.ts</automated>
    <automated>npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `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
  </acceptance_criteria>
  <done>
    isMimecastConfigured() and _resetMimecastClient() are exported from mimecast-client.ts, existing factory functions untouched, and mimecast-client.test.ts passes.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Build mimecast-blast-radius.ts orchestration (fan-out merge, never-throw, Redis cache) + tests</name>
  <files>lib/services/mimecast-blast-radius.ts, lib/services/mimecast-blast-radius.test.ts</files>
  <read_first>
    - 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 `<service>:<resource>:<discriminators>` 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)
  </read_first>
  <behavior>
    - 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'
  </behavior>
  <action>
    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<BlastRadiusResult>`:
    1. If `!isMimecastConfigured()` return `{ status:'unavailable', reason:'not_configured' }` synchronously — do NOT construct the client (that would throw).
    2. Build the cache key `<service>:<resource>:<discriminators>` 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<BlastRadiusResult>(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'); first partition `searchDeliveredMessages` rows into non-rejected vs. rejected using `.status` (rejected = row's `.status` string indicates rejection); `delivered` = count of NON-rejected delivered rows (mutually exclusive with `rejected`), `rejected` = count of rejected delivered rows, `held` = count of held rows, `matched` = `delivered + held` (does not include `rejected` — a rejected recipient was never actually delivered). 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' (i.e. not rejected) 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.
  </action>
  <verify>
    <automated>npx vitest run lib/services/mimecast-blast-radius.test.ts lib/services/mimecast-client.test.ts</automated>
    <automated>npx tsc --noEmit --pretty</automated>
    <automated>npm test</automated>
  </verify>
  <acceptance_criteria>
    - `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
  </acceptance_criteria>
  <done>
    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.
  </done>
</task>

</tasks>

<threat_model>
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. |
</threat_model>

<verification>
- `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).
</verification>

<success_criteria>
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.
</success_criteria>

<output>
Create `.planning/phases/17-mimecast-blast-radius-lookup/17-01-SUMMARY.md` when done.
</output>
