---
phase: 17-mimecast-blast-radius-lookup
plan: 01
subsystem: api
tags: [mimecast, redis-cache, blast-radius, phishing-triage, vitest]

# Dependency graph
requires:
  - phase: 15-data-model-detection-ticket-evidence
    provides: migrations/097_phishing_triage_schema.sql (classifications.reasons JSONB — the eventual persistence point for a blast-radius result, not touched by this phase)
provides:
  - "isMimecastConfigured() config gate + _resetMimecastClient() test seam in lib/services/mimecast-client.ts"
  - "getBlastRadius() orchestration in lib/services/mimecast-blast-radius.ts — normalized matched/delivered/held/rejected/clicked counts + perRecipient status, never-throwing, Redis-cached (5min TTL)"
affects: [19-classifier-service]

# Tech tracking
tech-stack:
  added: []
  patterns:
    - "is<Name>Configured() config-gate convention extended to mimecast-client.ts (matches pax8-factory.ts/veeam-factory.ts)"
    - "Never-throw orchestration wrapper: config gate returns synchronously, unexpected errors degrade to a discriminated-union 'unavailable' result instead of propagating"
    - "Redis cache-key format <service>:<resource>:<discriminators> (mirrors app/api/addigy-devices/route.ts)"

key-files:
  created:
    - lib/services/mimecast-blast-radius.ts
    - lib/services/mimecast-blast-radius.test.ts
    - lib/services/mimecast-client.test.ts
  modified:
    - lib/services/mimecast-client.ts

key-decisions:
  - "Fan-out (searchDeliveredMessages + getHeldMessages + getThreatEvents) runs unconditionally, not as a fallback after getMessageInfo — per corrected D-01, getMessageInfo() has no status/count data"
  - "clicked is best-effort from getThreatEvents() analysis[] click-type match; documented in code as not a confirmed-zero signal (D-02)"
  - "Delivered-message .status rejection classification treats unrecognized strings as non-rejected (conservative) since the real enum (17-RESEARCH A3) is unconfirmed; raw values logged at console.debug for first-real-tenant validation"
  - "Single global getMimecastClient() only for v1 — per-company mimecast_tenants/getMimecastClientForTenant() explicitly out of scope, documented as D-05 known limitation in the module doc-comment"

patterns-established:
  - "Blast-radius abstraction is a pure function: config-gate → cache-check → fan-out → merge → cache-write → return; every branch returns a typed discriminated union, never throws"

requirements-completed: [BLAST-01, BLAST-02]

# Metrics
duration: ~20min
completed: 2026-07-15
---

# Phase 17 Plan 01: Mimecast Blast Radius Lookup Summary

**New `getBlastRadius()` orchestration composes Mimecast's searchDeliveredMessages/getHeldMessages/getThreatEvents into normalized matched/delivered/held/rejected/clicked counts + per-recipient status, gated by a new `isMimecastConfigured()` and Redis-cached for 5 minutes — never throws, degrades to `status: 'unavailable'` on missing config or unexpected error.**

## Performance

- **Duration:** ~20 min
- **Completed:** 2026-07-15T18:30:12Z
- **Tasks:** 2 completed
- **Files modified:** 4 (1 modified, 3 created)

## Accomplishments
- `isMimecastConfigured()` + `_resetMimecastClient()` added to `mimecast-client.ts`, matching the project's `is<Name>Configured()` factory convention (mirrors `pax8-factory.ts`)
- `lib/services/mimecast-blast-radius.ts`: pure, never-throwing `getBlastRadius()` that fans out to the three existing `MimecastClient` methods unconditionally (not gated on `getMessageInfo`), merges into a normalized shape, and short-circuits on a Redis cache hit (5-min TTL, matching D-04)
- Both new test files (13 tests total) pass; full `npm test` shows no new failures (2 pre-existing, unrelated `itglue-search.test.ts` failures logged to `deferred-items.md`, out of scope for this plan)

## Task Commits

Each task was committed atomically:

1. **Task 1: Add isMimecastConfigured() config gate + _resetMimecastClient() test seam + tests** - `8b032c3` (feat)
2. **Task 2: Build mimecast-blast-radius.ts orchestration (fan-out merge, never-throw, Redis cache) + tests** - `efbc437` (feat)

_Both tasks were tdd="true"; test files were authored alongside the implementation in the same commit per the plan's task grouping (test file + implementation in one commit, matching the plan's `<files>` grouping rather than separate RED/GREEN commits)._

## Files Created/Modified
- `lib/services/mimecast-client.ts` - Added `isMimecastConfigured()` + `_resetMimecastClient()`; `getMimecastClient()` and `getMimecastClientForTenant()` untouched
- `lib/services/mimecast-client.test.ts` - New: covers `isMimecastConfigured()` env-var combinations + `getMimecastClient()` throw/cache/reset behavior
- `lib/services/mimecast-blast-radius.ts` - New: `BlastRadiusInput`/`BlastRadiusResult` types + `getBlastRadius()` orchestration
- `lib/services/mimecast-blast-radius.test.ts` - New: covers not-configured short-circuit, cache-hit short-circuit, fan-out merge (delivered/rejected/held/clicked/perRecipient), never-throw-on-error, and unknown-recipient classification

## Decisions Made
- Fan-out is unconditional (corrected D-01) — `getMessageInfo()` is called only as a supplementary body/header fetch when `messageId` is present, and never gates whether the fan-out runs
- `clicked` best-effort derivation and the D-05 multi-tenant known-limitation are both documented directly in the module's doc-comment, per the plan's acceptance criteria (`grep -qi "D-05"` passes)
- Delivered-message rejection status classification is conservative (unrecognized → non-rejected) since the real Mimecast `.status` enum is unconfirmed (17-RESEARCH A3); raw values are logged via `console.debug` for future validation against a real tenant, not hardcoded as a guessed enum

## Deviations from Plan

None — plan executed exactly as written. Both tasks' `<action>` and `<behavior>` requirements were implemented as specified; all acceptance criteria (exports present, D-05 comment present, test/tsc/npm-test green) verified directly.

## Issues Encountered

Full `npm test` run surfaces 2 pre-existing failures in `lib/services/analyzer/itglue-search.test.ts` (`client.getFlexibleAssetsForOrganization is not a function`), unrelated to any file this plan touched. Verified via `git log` that the last commit affecting those files (`a0a6e7f`) predates this plan's work. Logged to `.planning/phases/17-mimecast-blast-radius-lookup/deferred-items.md` per the SCOPE BOUNDARY rule (only auto-fix issues directly caused by the current task's changes) — not fixed here.

## User Setup Required

None - no external service configuration required. This phase reuses the existing `MIMECAST_CLIENT_ID`/`MIMECAST_CLIENT_SECRET`/`REDIS_URL` env vars already documented in CLAUDE.md; no new env vars, migrations, or dependencies were introduced.

## Next Phase Readiness

`getBlastRadius({ messageId?, sender, recipient, subject, dateWindow })` is ready for Phase 19's classifier to call directly — it never throws, and its `BlastRadiusResult` discriminated union (`status: 'ok' | 'unavailable'`) is fully typed. Known, documented limitation: v1 uses only the single global env-var Mimecast client, not per-company `mimecast_tenants` — Phase 19 (or a later phase) should be aware that companies with their own registered Mimecast tenant will see `status: 'unavailable'` from this abstraction even when Mimecast is otherwise configured for them.

## Self-Check: PASSED

All created files and commit hashes verified present.

---
*Phase: 17-mimecast-blast-radius-lookup*
*Completed: 2026-07-15*
