From 8b032c38901323b736876710d26c50af3596eadc Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 15 Jul 2026 14:27:47 -0400 Subject: [PATCH 1/4] feat(17-01): add isMimecastConfigured() config gate + test seam - Add isMimecastConfigured() to lib/services/mimecast-client.ts mirroring the pax8-factory.ts isConfigured() convention - Add _resetMimecastClient() test seam so tests can isolate env-var state - Add lib/services/mimecast-client.test.ts covering config gate + throw/cache behavior of getMimecastClient() --- lib/services/mimecast-client.test.ts | 63 ++++++++++++++++++++++++++++ lib/services/mimecast-client.ts | 10 +++++ 2 files changed, 73 insertions(+) create mode 100644 lib/services/mimecast-client.test.ts diff --git a/lib/services/mimecast-client.test.ts b/lib/services/mimecast-client.test.ts new file mode 100644 index 0000000..c48d74e --- /dev/null +++ b/lib/services/mimecast-client.test.ts @@ -0,0 +1,63 @@ +/** + * mimecast-client.ts — isMimecastConfigured()/getMimecastClient() factory tests. + * + * Mirrors pax8-factory.test.ts's beforeEach env-var-delete + reset seam + * pattern. Does not test MimecastClient's other 20+ methods — out of scope + * for this phase (Phase 17, Plan 01, Task 1). + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { isMimecastConfigured, getMimecastClient, _resetMimecastClient } from './mimecast-client'; + +beforeEach(() => { + delete process.env.MIMECAST_CLIENT_ID; + delete process.env.MIMECAST_CLIENT_SECRET; + _resetMimecastClient(); +}); + +describe('isMimecastConfigured', () => { + it('returns false when neither env var is set', () => { + expect(isMimecastConfigured()).toBe(false); + }); + + it('returns false when only MIMECAST_CLIENT_ID is set', () => { + process.env.MIMECAST_CLIENT_ID = 'id1'; + expect(isMimecastConfigured()).toBe(false); + }); + + it('returns false when only MIMECAST_CLIENT_SECRET is set', () => { + process.env.MIMECAST_CLIENT_SECRET = 'secret1'; + expect(isMimecastConfigured()).toBe(false); + }); + + it('returns true when both env vars are set', () => { + process.env.MIMECAST_CLIENT_ID = 'id1'; + process.env.MIMECAST_CLIENT_SECRET = 'secret1'; + expect(isMimecastConfigured()).toBe(true); + }); +}); + +describe('getMimecastClient', () => { + it('throws the exact configuration error when not configured', () => { + expect(() => getMimecastClient()).toThrow( + 'MIMECAST_CLIENT_ID and MIMECAST_CLIENT_SECRET must be set' + ); + }); + + it('returns the same cached instance on repeated calls', () => { + process.env.MIMECAST_CLIENT_ID = 'id1'; + process.env.MIMECAST_CLIENT_SECRET = 'secret1'; + const first = getMimecastClient(); + const second = getMimecastClient(); + expect(second).toBe(first); + }); + + it('rebuilds a new instance after _resetMimecastClient()', () => { + process.env.MIMECAST_CLIENT_ID = 'id1'; + process.env.MIMECAST_CLIENT_SECRET = 'secret1'; + const first = getMimecastClient(); + _resetMimecastClient(); + const second = getMimecastClient(); + expect(second).not.toBe(first); + }); +}); diff --git a/lib/services/mimecast-client.ts b/lib/services/mimecast-client.ts index aa5b03a..f04b77c 100644 --- a/lib/services/mimecast-client.ts +++ b/lib/services/mimecast-client.ts @@ -644,6 +644,16 @@ export class MimecastClient { let _client: MimecastClient | null = null; +export function isMimecastConfigured(): boolean { + return !!(process.env.MIMECAST_CLIENT_ID && process.env.MIMECAST_CLIENT_SECRET); +} + +// Test seam — reset the cached client (e.g. after rotating credentials, or +// to isolate env-var state between test cases). +export function _resetMimecastClient(): void { + _client = null; +} + export function getMimecastClient(): MimecastClient { if (!_client) { const clientId = process.env.MIMECAST_CLIENT_ID; From efbc437e2ec4a56c975e240fa4fe8fc27e5c37f7 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 15 Jul 2026 14:30:00 -0400 Subject: [PATCH 2/4] feat(17-01): build mimecast-blast-radius.ts fan-out orchestration - Add getBlastRadius(): never-throwing orchestration that fans out to searchDeliveredMessages + getHeldMessages + getThreatEvents (D-01, unconditional fan-out) and merges into normalized matched/delivered/ held/rejected/clicked counts + perRecipient status array - Config gate (BLAST-02): returns status:'unavailable' reason:'not_configured' synchronously when Mimecast is unconfigured, never constructs the client - Redis-backed 5-min cache (D-04) via redis-client.ts, short-circuits before any MimecastClient call on hit - clicked derived best-effort from getThreatEvents() analysis[] (D-02); documents the /api/ttp/url/get-logs limitation in code - Documents D-05 known limitation: single global getMimecastClient() only, not per-company mimecast_tenants - Unrecognized delivered-message status strings treated conservatively as non-rejected (A3 unconfirmed enum), raw values logged at debug level - Add lib/services/mimecast-blast-radius.test.ts covering config gate, cache-hit short-circuit, fan-out merge, never-throw-on-error, and unknown-recipient classification - Log pre-existing unrelated itglue-search.test.ts failures to deferred-items.md (out of scope for this plan) --- .../deferred-items.md | 21 ++ lib/services/mimecast-blast-radius.test.ts | 227 ++++++++++++++++++ lib/services/mimecast-blast-radius.ts | 195 +++++++++++++++ 3 files changed, 443 insertions(+) create mode 100644 .planning/phases/17-mimecast-blast-radius-lookup/deferred-items.md create mode 100644 lib/services/mimecast-blast-radius.test.ts create mode 100644 lib/services/mimecast-blast-radius.ts diff --git a/.planning/phases/17-mimecast-blast-radius-lookup/deferred-items.md b/.planning/phases/17-mimecast-blast-radius-lookup/deferred-items.md new file mode 100644 index 0000000..f157c18 --- /dev/null +++ b/.planning/phases/17-mimecast-blast-radius-lookup/deferred-items.md @@ -0,0 +1,21 @@ +# Deferred Items — Phase 17, Plan 01 + +Out-of-scope discoveries found during execution. Not fixed per SCOPE BOUNDARY +(only auto-fix issues directly caused by the current task's changes). + +## Pre-existing test failures unrelated to this plan + +`npm test` (full suite) shows 2 pre-existing failures in +`lib/services/analyzer/itglue-search.test.ts`: + +- `itglueSearch > returns capped, redacted doc snippets when the org is found` +- `itglueSearch > tolerates per-call failures (configurations errors, flex still returns)` + +Both fail with a `client.getFlexibleAssetsForOrganization is not a function` +stderr log and count-mismatch assertions. Neither `lib/services/analyzer/ +itglue-search.ts` nor its test file was touched by Phase 17 Plan 01 (which only +modified `lib/services/mimecast-client.ts` and added +`lib/services/mimecast-blast-radius.ts` + their test files). Last commit +touching those files: `a0a6e7f fix(itglue): list flexible assets per type to +satisfy API 422 requirement` — predates this plan's work. Left unfixed; not +in scope for Phase 17. diff --git a/lib/services/mimecast-blast-radius.test.ts b/lib/services/mimecast-blast-radius.test.ts new file mode 100644 index 0000000..0945ad1 --- /dev/null +++ b/lib/services/mimecast-blast-radius.test.ts @@ -0,0 +1,227 @@ +/** + * mimecast-blast-radius.ts — getBlastRadius() orchestration tests. + * + * Mocks './mimecast-client' and './redis-client' entirely (following + * phishing-eml-service.test.ts's vi.mock() factory-mocking discipline) — no + * real Mimecast network calls or Redis connection are used. Synthetic inline + * fixtures matching MimecastDeliveredMessage/MimecastHeldMessage/ + * MimecastThreatEvent shapes are used directly; no fixture file needed. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const isMimecastConfiguredMock = vi.fn(); +const getMessageInfoMock = vi.fn(); +const searchDeliveredMessagesMock = vi.fn(); +const getHeldMessagesMock = vi.fn(); +const getThreatEventsMock = vi.fn(); +const getMimecastClientMock = vi.fn(() => ({ + getMessageInfo: getMessageInfoMock, + searchDeliveredMessages: searchDeliveredMessagesMock, + getHeldMessages: getHeldMessagesMock, + getThreatEvents: getThreatEventsMock, +})); + +vi.mock('./mimecast-client', () => ({ + isMimecastConfigured: () => isMimecastConfiguredMock(), + getMimecastClient: () => getMimecastClientMock(), +})); + +const getCachedDataMock = vi.fn(); +const setCachedDataMock = vi.fn(); +vi.mock('./redis-client', () => ({ + getCachedData: (...args: unknown[]) => getCachedDataMock(...args), + setCachedData: (...args: unknown[]) => setCachedDataMock(...args), +})); + +// Import AFTER the mocks are declared so vi.mock hoisting takes effect. +import { getBlastRadius, type BlastRadiusInput } from './mimecast-blast-radius'; + +const BASE_INPUT: BlastRadiusInput = { + sender: 'attacker@evil.test', + recipient: 'reporter@wulfconsulting.test', + subject: 'Urgent: verify your account', + dateWindow: { + start: new Date('2026-07-14T00:00:00.000Z'), + end: new Date('2026-07-15T00:00:00.000Z'), + }, +}; + +describe('getBlastRadius', () => { + beforeEach(() => { + isMimecastConfiguredMock.mockReset().mockReturnValue(true); + getMessageInfoMock.mockReset().mockResolvedValue(null); + searchDeliveredMessagesMock.mockReset().mockResolvedValue({ messages: [] }); + getHeldMessagesMock.mockReset().mockResolvedValue({ messages: [], totalCount: 0 }); + getThreatEventsMock.mockReset().mockResolvedValue({ items: [], nextCursor: null }); + getCachedDataMock.mockReset().mockResolvedValue(null); + setCachedDataMock.mockReset().mockResolvedValue(undefined); + }); + + it('returns unavailable/not_configured synchronously and never calls getMimecastClient when unconfigured', async () => { + isMimecastConfiguredMock.mockReturnValue(false); + + const result = await getBlastRadius(BASE_INPUT); + + expect(result).toEqual({ status: 'unavailable', reason: 'not_configured' }); + expect(getMimecastClientMock).not.toHaveBeenCalled(); + expect(searchDeliveredMessagesMock).not.toHaveBeenCalled(); + expect(getHeldMessagesMock).not.toHaveBeenCalled(); + expect(getThreatEventsMock).not.toHaveBeenCalled(); + }); + + it('returns the cached result on a cache hit without calling any MimecastClient method', async () => { + const cachedResult = { + status: 'ok' as const, + matched: 1, + delivered: 1, + held: 0, + rejected: 0, + clicked: 0, + perRecipient: [{ recipient: BASE_INPUT.recipient, status: 'delivered' as const }], + source: 'fan-out' as const, + }; + getCachedDataMock.mockResolvedValue(cachedResult); + + const result = await getBlastRadius(BASE_INPUT); + + expect(result).toEqual(cachedResult); + expect(searchDeliveredMessagesMock).not.toHaveBeenCalled(); + expect(getHeldMessagesMock).not.toHaveBeenCalled(); + expect(getThreatEventsMock).not.toHaveBeenCalled(); + expect(setCachedDataMock).not.toHaveBeenCalled(); + }); + + it('merges delivered/held/threat-event fixtures into a normalized ok result and caches it once', async () => { + searchDeliveredMessagesMock.mockResolvedValue({ + messages: [ + { + id: 'd1', + status: 'Delivered', + subject: BASE_INPUT.subject, + from: BASE_INPUT.sender, + fromEnv: BASE_INPUT.sender, + to: BASE_INPUT.recipient, + toDisplay: 'Reporter', + received: '2026-07-14T12:00:00+0000', + senderIP: '1.2.3.4', + spamScore: 0, + detectionLevel: 'none', + attachments: false, + route: 'inbound', + info: '', + }, + { + id: 'd2', + status: 'Rejected', + subject: BASE_INPUT.subject, + from: BASE_INPUT.sender, + fromEnv: BASE_INPUT.sender, + to: 'other-recipient@wulfconsulting.test', + toDisplay: 'Other', + received: '2026-07-14T12:05:00+0000', + senderIP: '1.2.3.4', + spamScore: 0, + detectionLevel: 'none', + attachments: false, + route: 'inbound', + info: '', + }, + ], + }); + getHeldMessagesMock.mockResolvedValue({ + messages: [ + { + id: 'h1', + subject: BASE_INPUT.subject, + from: BASE_INPUT.sender, + fromDisplay: 'Attacker', + to: 'held-recipient@wulfconsulting.test', + toDisplay: 'Held', + dateReceived: '2026-07-14T13:00:00+0000', + reason: 'spam', + reasonCode: 'SPAM', + policyInfo: '', + route: 'inbound', + hasAttachments: false, + size: 100, + }, + ], + totalCount: 1, + }); + getThreatEventsMock.mockResolvedValue({ + items: [ + { + id: 't1', + eventType: 'click', + analysis: ['click'], + source: [], + direction: [], + status: [], + }, + ], + nextCursor: null, + }); + + const result = await getBlastRadius(BASE_INPUT); + + expect(result).toEqual({ + status: 'ok', + matched: 2, // 1 non-rejected delivered + 1 held + delivered: 1, + held: 1, + rejected: 1, + clicked: 1, + perRecipient: expect.arrayContaining([ + { recipient: BASE_INPUT.recipient, status: 'delivered' }, + { recipient: 'other-recipient@wulfconsulting.test', status: 'rejected' }, + { recipient: 'held-recipient@wulfconsulting.test', status: 'held' }, + ]), + source: 'fan-out', + }); + expect(setCachedDataMock).toHaveBeenCalledTimes(1); + expect(setCachedDataMock).toHaveBeenCalledWith(expect.any(String), result, 300); + }); + + it('clicked is 0 when no click-type analysis value is present', async () => { + getThreatEventsMock.mockResolvedValue({ + items: [{ id: 't1', eventType: 'malware', analysis: ['malware'], source: [], direction: [], status: [] }], + nextCursor: null, + }); + + const result = await getBlastRadius(BASE_INPUT); + + expect(result.status).toBe('ok'); + if (result.status === 'ok') { + expect(result.clicked).toBe(0); + } + }); + + it('degrades to unavailable/lookup_failed (never throws) when a fan-out call rejects', async () => { + getHeldMessagesMock.mockRejectedValue(new Error('Mimecast API timeout')); + + const result = await getBlastRadius(BASE_INPUT); + + expect(result).toEqual({ + status: 'unavailable', + reason: 'lookup_failed', + error: 'Mimecast API timeout', + }); + expect(setCachedDataMock).not.toHaveBeenCalled(); + }); + + it('a recipient with no delivered/held rows at all is reported as unknown', async () => { + const result = await getBlastRadius(BASE_INPUT); + + expect(result).toEqual({ + status: 'ok', + matched: 0, + delivered: 0, + held: 0, + rejected: 0, + clicked: 0, + perRecipient: [{ recipient: BASE_INPUT.recipient, status: 'unknown' }], + source: 'fan-out', + }); + }); +}); diff --git a/lib/services/mimecast-blast-radius.ts b/lib/services/mimecast-blast-radius.ts new file mode 100644 index 0000000..7be67e5 --- /dev/null +++ b/lib/services/mimecast-blast-radius.ts @@ -0,0 +1,195 @@ +/** + * Mimecast blast-radius lookup abstraction (Phase 17). + * + * Given a reported message's identity (Message-ID, sender, recipient, + * subject, date window), returns normalized delivery data — matched/ + * delivered/held/rejected/clicked counts and per-recipient status — when + * Mimecast is configured, or a clean `status: 'unavailable'` signal (never a + * throw) when it isn't or when the underlying lookup fails unexpectedly. + * + * Three load-bearing facts: + * + * (a) EPHEMERAL (D-03): this lookup is a pure function call — nothing is + * persisted here. If Phase 19's classifier wants to keep a result, it + * writes to its own `classifications.reasons` JSONB column; that is + * Phase 19's concern, not this module's. + * + * (b) `clicked` IS BEST-EFFORT (D-02): it is derived from getThreatEvents()'s + * analysis[] subtype containing a click-like value. Mimecast's real + * click-tracking data (TTP URL Protect logs) lives in a separate, + * currently-unwrapped `/api/ttp/url/get-logs` endpoint (see + * 17-RESEARCH.md Pitfall 2) — this module does not call it. A value of + * `clicked: 0` means "no click-type threat event found in the events + * this module can see," NOT "confirmed zero clicks." + * + * (c) KNOWN LIMITATION — MULTI-TENANT GAP (D-05): this module uses only the + * single global env-var-configured getMimecastClient(), NOT the + * per-company `mimecast_tenants` table / getMimecastClientForTenant(). + * Reports belonging to companies with their own registered Mimecast + * tenant (not covered by the global MIMECAST_CLIENT_ID) will return + * `status: 'unavailable'` even though Mimecast is technically configured + * for that company. Per-tenant resolution is a deliberate, documented v1 + * gap — not a silent oversight — and would be a small, mechanical + * follow-up later (swap getMimecastClient() for a tenant lookup + + * getMimecastClientForTenant(), same fan-out/merge logic below). + */ + +import { + isMimecastConfigured, + getMimecastClient, + type MimecastDeliveredMessage, + type MimecastHeldMessage, +} from './mimecast-client'; +import { getCachedData, setCachedData } from './redis-client'; + +export interface BlastRadiusInput { + /** Optional Mimecast Message-ID — used only for supplementary body/header + * evidence via getMessageInfo(); it never gates or replaces the fan-out. */ + messageId?: string; + /** Reported message's sender address. Required — see T-17-01: never allow + * a date-range-only fan-out query. */ + sender: string; + /** Reporter/reported-to recipient address. Required — see T-17-01. */ + recipient: string; + /** Reported message's subject. Required — see T-17-01. */ + subject: string; + /** Date window to search within. Required — see T-17-01. */ + dateWindow: { start: Date; end: Date }; +} + +export type BlastRadiusResult = + | { status: 'unavailable'; reason: 'not_configured' | 'lookup_failed'; error?: string } + | { + status: 'ok'; + matched: number; + delivered: number; + held: number; + rejected: number; + clicked: number; + perRecipient: Array<{ recipient: string; status: 'delivered' | 'held' | 'rejected' | 'unknown' }>; + source: 'fan-out'; + }; + +/** + * Best-effort delivered/rejected split. The exact string values Mimecast + * returns in MimecastDeliveredMessage.status have NOT been confirmed against + * a live/sandbox tenant (17-RESEARCH.md A3 / Open Question #2) — only the + * TypeScript field name and type (`string`) are confirmed from the client + * code. Treat unrecognized status strings conservatively as NOT rejected + * (i.e. delivered) rather than hardcoding a guessed enum as if it were + * confirmed. Log raw values at debug level so a first real-tenant run can + * validate/refine this heuristic. + */ +function isRejectedStatus(status: string): boolean { + return /reject/i.test(status); +} + +/** Best-effort click-type detection (D-02) — see module doc-comment (b). */ +function isClickEvent(analysis: string[] | undefined): boolean { + return (analysis ?? []).some((a) => /click/i.test(a)); +} + +export async function getBlastRadius(input: BlastRadiusInput): Promise { + if (!isMimecastConfigured()) { + return { status: 'unavailable', reason: 'not_configured' }; + } + + const cacheKey = input.messageId + ? `mimecast:blast-radius:msgid:${input.messageId}` + : `mimecast:blast-radius:composite:${input.sender}:${input.subject}:${input.dateWindow.start.toISOString()}:${input.dateWindow.end.toISOString()}`; + + const cached = await getCachedData(cacheKey); + if (cached) return cached; + + try { + const client = getMimecastClient(); + + // Supplementary only — body/header evidence, never gates the fan-out + // (17-RESEARCH.md Pitfall 1: getMessageInfo() has no status/counts). + if (input.messageId) { + await client.getMessageInfo(input.messageId); + } + + const startStr = input.dateWindow.start.toISOString().replace(/\.\d{3}Z$/, '+0000'); + const endStr = input.dateWindow.end.toISOString().replace(/\.\d{3}Z$/, '+0000'); + + // D-01 (corrected): the fan-out runs unconditionally — it is the only + // source of the matched/delivered/held/rejected/clicked counts. + const [deliveredResult, heldResult, threatsResult] = await Promise.all([ + client.searchDeliveredMessages({ + to: input.recipient, + from: input.sender, + subject: input.subject, + start: startStr, + end: endStr, + }), + client.getHeldMessages({ recipient: input.recipient }), + client.getThreatEvents(), + ]); + + const deliveredRows: MimecastDeliveredMessage[] = deliveredResult.messages ?? []; + const heldRows: MimecastHeldMessage[] = heldResult.messages ?? []; + + const rawStatuses = Array.from(new Set(deliveredRows.map((m) => m.status))); + if (rawStatuses.length > 0) { + console.debug('[MIMECAST-BLAST-RADIUS] raw delivered-message status values seen:', rawStatuses); + } + + const rejectedRows = deliveredRows.filter((m) => isRejectedStatus(m.status)); + const nonRejectedDeliveredRows = deliveredRows.filter((m) => !isRejectedStatus(m.status)); + + const delivered = nonRejectedDeliveredRows.length; + const rejected = rejectedRows.length; + const held = heldRows.length; + // matched = delivered + held; a rejected recipient was never actually + // delivered, so rejected is NOT included in matched. + const matched = delivered + held; + + const clicked = (threatsResult.items ?? []).filter((t) => isClickEvent(t.analysis)).length; + + // Per-recipient merge: group by the single-string `to` field. Delivered + // (non-rejected) rows → 'delivered', rejected rows → 'rejected', held + // rows → 'held' (held overwrites a same-recipient delivered entry — a + // held message for a recipient is the more actionable signal). Ensure + // the originally-queried recipient is always represented, even if + // neither result set returned a row for them ('unknown'). + const perRecipientMap = new Map(); + for (const row of nonRejectedDeliveredRows) { + if (row.to) perRecipientMap.set(row.to, 'delivered'); + } + for (const row of rejectedRows) { + if (row.to) perRecipientMap.set(row.to, 'rejected'); + } + for (const row of heldRows) { + if (row.to) perRecipientMap.set(row.to, 'held'); + } + if (!perRecipientMap.has(input.recipient)) { + perRecipientMap.set(input.recipient, 'unknown'); + } + + const perRecipient = Array.from(perRecipientMap.entries()).map(([recipient, status]) => ({ + recipient, + status, + })); + + const result: BlastRadiusResult = { + status: 'ok', + matched, + delivered, + held, + rejected, + clicked, + perRecipient, + source: 'fan-out', + }; + + await setCachedData(cacheKey, result, 300); + return result; + } catch (err) { + // T-17-02: log err.message ONLY, never full Mimecast response bodies + // (which may contain other recipients' subjects/content). + const message = err instanceof Error ? err.message : String(err); + console.error('[MIMECAST-BLAST-RADIUS] lookup failed', message); + return { status: 'unavailable', reason: 'lookup_failed', error: message }; + } +} From 2bb1817e8d6b3edfc1e9e3522ab223fcdcb52a89 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 15 Jul 2026 14:31:10 -0400 Subject: [PATCH 3/4] docs(17-01): complete Mimecast blast-radius lookup plan Mark BLAST-01/BLAST-02 complete in REQUIREMENTS.md and add the plan's SUMMARY.md documenting getBlastRadius() delivery. Co-Authored-By: Claude Sonnet 5 --- .planning/REQUIREMENTS.md | 8 +- .../17-01-SUMMARY.md | 102 ++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 .planning/phases/17-mimecast-blast-radius-lookup/17-01-SUMMARY.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 7388e59..fd527f3 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -50,11 +50,11 @@ destructive remediation gated behind explicit human approval. ### Blast Radius (Mimecast) -- [ ] **BLAST-01**: The system can query a Mimecast blast-radius abstraction for +- [x] **BLAST-01**: The system can query a Mimecast blast-radius abstraction for message delivery data (matched/delivered/held/rejected/clicked counts, per-recipient status) when Mimecast is configured, keyed on message ID, sender, recipient/reporter, subject, and date window -- [ ] **BLAST-02**: When Mimecast is not configured, the system records +- [x] **BLAST-02**: When Mimecast is not configured, the system records `status: unavailable` for that lookup and classification proceeds using ticket/email evidence alone — it never blocks on missing Mimecast config @@ -159,8 +159,8 @@ Populated during roadmap creation. | CAMP-01 | Phase 18 | Pending | | CAMP-02 | Phase 18 | Pending | | CAMP-03 | Phase 18 | Pending | -| BLAST-01 | Phase 17 | Pending | -| BLAST-02 | Phase 17 | Pending | +| BLAST-01 | Phase 17 | Complete | +| BLAST-02 | Phase 17 | Complete | | CLASSIFY-01 | Phase 19 | Pending | | CLASSIFY-02 | Phase 19 | Pending | | CLASSIFY-03 | Phase 19 | Pending | diff --git a/.planning/phases/17-mimecast-blast-radius-lookup/17-01-SUMMARY.md b/.planning/phases/17-mimecast-blast-radius-lookup/17-01-SUMMARY.md new file mode 100644 index 0000000..765189e --- /dev/null +++ b/.planning/phases/17-mimecast-blast-radius-lookup/17-01-SUMMARY.md @@ -0,0 +1,102 @@ +--- +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: + - "isConfigured() 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 :: (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 `isConfigured()` 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 `` 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' `` and `` 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. + +--- +*Phase: 17-mimecast-blast-radius-lookup* +*Completed: 2026-07-15* From eaee6292dc517bfcdc0ca79c2a34b65257624712 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 15 Jul 2026 14:31:22 -0400 Subject: [PATCH 4/4] docs(17-01): record self-check result in SUMMARY.md Co-Authored-By: Claude Sonnet 5 --- .../phases/17-mimecast-blast-radius-lookup/17-01-SUMMARY.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.planning/phases/17-mimecast-blast-radius-lookup/17-01-SUMMARY.md b/.planning/phases/17-mimecast-blast-radius-lookup/17-01-SUMMARY.md index 765189e..650f4ad 100644 --- a/.planning/phases/17-mimecast-blast-radius-lookup/17-01-SUMMARY.md +++ b/.planning/phases/17-mimecast-blast-radius-lookup/17-01-SUMMARY.md @@ -97,6 +97,10 @@ None - no external service configuration required. This phase reuses the existin `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*