docs(17): research phase domain

This commit is contained in:
lorentz 2026-07-15 13:28:58 -04:00
parent e30d9af46d
commit cacf41a493

View file

@ -0,0 +1,680 @@
# Phase 17: Mimecast Blast Radius Lookup - Research
**Researched:** 2026-07-15
**Domain:** Internal service-layer abstraction over an existing Mimecast API client (TypeScript, Next.js `lib/services/`)
**Confidence:** HIGH (all core findings verified by reading the actual source files in this repo; one area — Mimecast's live click-event field values — is LOW/MEDIUM, see Open Questions)
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
**D-01 (Query Strategy):** Try `getMessageInfo(messageId)` first for an exact Message-ID
match. If that misses (no match, or Message-ID unknown/altered by intermediate relays),
fan out to `searchDeliveredMessages` + `getHeldMessages` + `getThreatEvents` keyed on
sender/subject/date-window and merge results into one normalized blast-radius shape
(matched/delivered/held/rejected/clicked counts + per-recipient status). This is the most
complete picture available from the existing `MimecastClient` methods, at the cost of more
API calls on the fallback path.
**D-02 (Clicked Count):** Mimecast's currently-wrapped API surface (`lib/services/mimecast-client.ts`)
has no URL-click-tracking method. Derive `clicked` best-effort from `getThreatEvents()`
where Mimecast's TTP URL Protect logs a click as a threat-event subtype. If no matching
click-type event is found, `clicked` is `0` (not `unavailable`) — this is a best-effort
signal, documented as such in code, not a guaranteed complete count. Do not add a new
dedicated click-log API method in this phase.
**D-03 (Persistence):** The blast-radius lookup is ephemeral — a pure function call that
returns normalized data, nothing more. No new migration, no new table/column in this phase.
If Phase 19's classifier wants to persist a result, it does so via its own
`classifications.reasons` JSONB column (already defined in migration 097) — that is Phase
19's concern, not Phase 17's.
**D-04 (Caching):** Repeat lookups for the same message are cached in Redis via the existing
`lib/services/redis-client.ts`, keyed on message identity (Message-ID when known, else a
composite of sender+subject+date-window), with a **5-minute TTL** — matching the existing
cache pattern in `lib/services/integration-health.ts`. This protects against Mimecast API
hammering when Phase 19 re-classifies a campaign multiple times in quick succession, while
staying short enough that data doesn't go stale across a single classification session.
### Claude's Discretion
- **`isMimecastConfigured()` helper.** `lib/services/mimecast-client.ts` currently has
`getMimecastClient()` (throws if `MIMECAST_CLIENT_ID`/`MIMECAST_CLIENT_SECRET` are missing)
but no `is<Name>Configured()` helper matching the project's factory convention. Add one,
checking the same two env vars `lib/services/integration-health.ts` already checks via
`checkConfigOnly('mimecast', ...)`. Exact location (alongside `getMimecastClient` in
`mimecast-client.ts`, vs. a new `mimecast-factory.ts` mirroring `veeam-factory.ts`) is
planner's call — follow whichever existing precedent is cleaner given the file is already
1000+ lines. *(Research note: the file is actually ~676 lines as of this research — see
Architecture Patterns below for a concrete recommendation.)*
- **New module name/location for the blast-radius abstraction itself** (e.g.
`lib/services/mimecast-blast-radius.ts`) — orchestration logic that composes multiple
`MimecastClient` calls into one normalized shape belongs in a new file, not bolted onto
`mimecast-client.ts` directly, but exact naming/exports are planner's call.
- **Exact normalized output TypeScript shape** (field names, per-recipient status enum
values) — derive from BLAST-01's requirement text (matched/delivered/held/rejected/clicked
+ per-recipient status) plus whatever shapes `MimecastHeldMessage`/`MimecastDeliveredMessage`/
`MimecastThreatEvent` already provide — planner/researcher's call.
- **Date-window sizing** for the fan-out fallback query (e.g. ±48h around report creation
time) — implementation detail, not a user preference call.
- **Redis cache key exact format** — planner's call, following whatever key format
`redis-client.ts` callers already use elsewhere.
### Deferred Ideas (OUT OF SCOPE)
- **Dedicated Mimecast click-log API method** — deferred per D-02. If best-effort click
derivation from threat events proves insufficient later, a real `getUrlClickLogs()`-style
method against Mimecast's TTP URL Protect logs endpoint would be a future, separate
addition (not blocking this phase or Phase 19).
- **Persisting blast-radius results to a new table/column** — deferred per D-03; left to
Phase 19 (or a later phase) if audit/idempotency needs surface once the classifier is built.
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| BLAST-01 | 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, date window | See Architecture Patterns (exact fan-out call shapes with real field names) and Common Pitfalls #1 (getMessageInfo does NOT provide status/counts — fan-out is mandatory for this requirement, not just a fallback) |
| BLAST-02 | When Mimecast is not configured, record `status: unavailable` — never block on missing config | See Code Examples (`isMimecastConfigured()` + wrapping pattern) and existing failure-mode evidence in `app/api/mimecast/status/route.ts` |
</phase_requirements>
## Summary
This phase builds a pure orchestration layer on top of an already-complete, already-deployed
`MimecastClient` (`lib/services/mimecast-client.ts`, 676 lines). No new external package, no
new Mimecast API surface (beyond composing 4 existing methods), and no new database schema
are required. The work is: (1) add a missing `isMimecastConfigured()` helper matching the
project's `get<Name>Client()` + `is<Name>Configured()` convention, (2) write a new
`lib/services/mimecast-blast-radius.ts` module that calls `getMessageInfo`,
`searchDeliveredMessages`, `getHeldMessages`, and `getThreatEvents`, merges their outputs into
one normalized shape, and wraps the whole thing in a Redis-cached, never-throwing function,
and (3) write tests using the same `vi.mock()` factory-mocking discipline established in
`pax8-factory.test.ts` and `phishing-eml-service.test.ts`.
Two findings materially affect implementation and are surfaced prominently below:
1. **`getMessageInfo()` cannot satisfy BLAST-01's data requirement on its own.** It returns
only `{ messageId, bodyText, bodyHtml, headers }` — no status, no delivery/held/rejected
classification, no per-recipient data at all. D-01's "try exact match first, fan out only
on miss" framing needs a small correction: the fan-out (`searchDeliveredMessages` +
`getHeldMessages` + `getThreatEvents`) is the *only* source of the matched/delivered/held/
rejected/clicked counts and per-recipient status this phase must return — it should run
unconditionally, not merely as a fallback. `getMessageInfo` remains useful as a
supplementary existence/body check, not as a data source for the counts.
2. **There is an existing per-company multi-tenant Mimecast setup** (`mimecast_tenants` table,
migration 062, consumed via `getMimecastClientForTenant()` in the existing `/api/mimecast/held`
and `/api/mimecast/delivered` routes) that CONTEXT.md's decisions do not address. The bulk
sync service (`mimecast-sync-service.ts`) and the health check both use only the single
env-var-configured client (`getMimecastClient()`), but the two routes that most resemble
this phase's "look up one message" use case use per-tenant clients keyed to `company_id`.
This phase's success criteria and locked decisions only mention the single env-var client —
see Open Questions for the recommendation and the risk if this is wrong.
**Primary recommendation:** Build `lib/services/mimecast-blast-radius.ts` around the single
env-var-configured `getMimecastClient()` (matching `isMimecastConfigured()`'s env-var check
and the phase's stated scope), always run the fan-out for counts, use `getMessageInfo` only
as a supplementary body/header fetch, cache via `getCachedData`/`setCachedData` from
`redis-client.ts` (already supports arbitrary TTL, default 300s), and flag the multi-tenant
question to the user/planner before implementation rather than silently picking a tenant
resolution strategy.
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Mimecast OAuth2 token acquisition/caching | API/Backend (`MimecastClient` internal) | — | Already implemented in `mimecast-client.ts`; this phase does not touch it |
| Message delivery data fan-out + merge | API/Backend (`lib/services/mimecast-blast-radius.ts`, new) | — | Pure orchestration function; no HTTP route in this phase |
| Configuration gating (`isMimecastConfigured`) | API/Backend (`lib/services/mimecast-client.ts` or new `mimecast-factory.ts`) | — | Mirrors existing `is<Name>Configured()` factory convention |
| Result caching | API/Backend (Redis via `lib/services/redis-client.ts`) | — | Existing singleton client with graceful no-Redis fallback (`getRedisClient()` returns `null`, callers already no-op safely) |
| Consumption by classifier | API/Backend (Phase 19, not built yet) | — | Out of scope for this phase; this phase only needs to expose a stable, typed, non-throwing function signature |
## Standard Stack
### Core
No new libraries. This phase is 100% composition of existing internal modules.
| Module | Status | Purpose | Why reuse |
|--------|--------|---------|-----------|
| `lib/services/mimecast-client.ts` (`MimecastClient`, `getMimecastClient()`) | Existing, unmodified except adding `isMimecastConfigured()` | OAuth2 client + 4 methods this phase composes | Already deployed, already used by sync service + 7 existing API routes |
| `lib/services/redis-client.ts` (`getCachedData`, `setCachedData`) | Existing, unmodified | Generic get/set-with-TTL cache, already defaults to 300s (5 min) | Exact match for D-04's caching requirement; no raw `ioredis` calls needed |
| `ioredis` 5.9.0 `[VERIFIED: npm registry]` | Existing dependency (see `package.json`, already installed) | Underlying Redis client used by `redis-client.ts` | Already in `package.json`; not a new install |
### Supporting
None required.
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Reusing `redis-client.ts`'s `getCachedData`/`setCachedData` | Writing bespoke `ioredis` get/set/expire calls in the new module | No advantage — `redis-client.ts` already handles the "Redis not configured" no-op case (`getRedisClient()` returns `null` when `REDIS_URL` unset) exactly the way this phase needs for graceful caching-optional behavior. Hand-rolling would duplicate that null-check logic. |
| A new `mimecast-blast-radius.ts` orchestration file | Adding the fan-out logic directly inside `mimecast-client.ts` | CONTEXT.md already decided against this (new file, not bolted on) — noted here only for completeness, not re-litigated. |
**Installation:** None. No `npm install` needed for this phase.
## Package Legitimacy Audit
Not applicable — this phase introduces zero new external packages. All work reuses
`mimecast-client.ts`, `redis-client.ts`, and standard TypeScript. Skip the slopcheck/registry
verification gate for this phase; there is nothing to audit.
## Architecture Patterns
### System Architecture Diagram
```
Phase 19 classifier (future)
│ getBlastRadius({ messageId?, sender, recipient, subject, dateWindow })
┌───────────────────────────────────────────────────────────────┐
│ lib/services/mimecast-blast-radius.ts (NEW — this phase) │
│ │
│ 1. isMimecastConfigured()? ──No──► return { status:'unavailable' } (sync, no throw)
│ │ Yes │
│ 2. Build cache key (Message-ID, else sender+subject+window) │
│ 3. getCachedData(key) hit? ──Yes──► return cached result │
│ │ No │
│ 4. (optional) getMessageInfo(messageId) — body/headers only, │
│ NOT status/counts │
│ 5. Fan out (always, for counts): │
│ ├─ searchDeliveredMessages({ to, from, subject, start, end }) │
│ ├─ getHeldMessages({ recipient }) │
│ └─ getThreatEvents() (then filter by sender/subject/window) │
│ 6. Merge → normalized shape: matched/delivered/held/rejected/ │
│ clicked counts + perRecipient[] status array │
│ 7. setCachedData(key, result, 300) │
│ 8. return result │
└───────────────────────────────────────────────────────────────┘
│ │
▼ ▼
lib/services/mimecast-client.ts lib/services/redis-client.ts
(existing, OAuth2 + 4 methods) (existing, getCachedData/setCachedData)
https://api.services.mimecast.com (external, only if configured)
```
### Recommended Project Structure
```
lib/services/
├── mimecast-client.ts # EXISTING — unmodified except: add isMimecastConfigured()
├── mimecast-client.test.ts # NEW — tests isMimecastConfigured() (no test file exists today)
├── mimecast-blast-radius.ts # NEW — the orchestration abstraction (this phase's deliverable)
└── mimecast-blast-radius.test.ts # NEW — orchestration tests, mocking mimecast-client
```
### Pattern 1: `isMimecastConfigured()` placement
**What:** A small env-var check function matching `isVeeamConfigured()` / `isPax8Configured()`.
**Where:** Add directly inside `mimecast-client.ts`, next to `getMimecastClient()` — do NOT
create a separate `mimecast-factory.ts`. Rationale: unlike Veeam (`veeam-client.ts` +
`veeam-factory.ts` are already two separate files), `mimecast-client.ts` already contains
*both* the class and its factory function (`getMimecastClient()` is defined at the bottom of
`mimecast-client.ts`, not in a separate file). Splitting just `isMimecastConfigured()` into a
new file while `getMimecastClient()` stays put would fragment one concern across two files
for no benefit. The file is ~676 lines today (not 1000+ as CONTEXT.md's discretion note
estimated) — well within normal size for this codebase's service files.
```typescript
// Source: existing pattern in lib/services/veeam-factory.ts, adapted for mimecast-client.ts
// Add near the bottom of mimecast-client.ts, above or below getMimecastClient():
export function isMimecastConfigured(): boolean {
return !!(process.env.MIMECAST_CLIENT_ID && process.env.MIMECAST_CLIENT_SECRET);
}
```
This matches exactly what `checkConfigOnly('mimecast', 'Mimecast', 'mail', ['MIMECAST_CLIENT_ID', 'MIMECAST_CLIENT_SECRET'])`
in `lib/services/integration-health.ts` (line 338-339) already checks — confirmed by reading
that file directly.
### Pattern 2: Never-throw wrapper (BLAST-02)
**What:** The public entry point must never throw, timeout unbounded, or block — it must
return `{ status: 'unavailable' }` synchronously when not configured, and should catch any
unexpected error from the fan-out calls and degrade to a partial/unavailable result rather
than propagating.
**Existing precedent for the "unconfigured throws" failure mode this phase wraps:**
```typescript
// Source: app/api/mimecast/status/route.ts (existing, unmodified by this phase)
try {
const client = getMimecastClient(); // throws if MIMECAST_CLIENT_ID/SECRET unset
const connection = await client.testConnection();
return NextResponse.json({ configured: true, connected: connection.ok, ... });
} catch (err: any) {
return NextResponse.json({ configured: false, connected: false, error: err.message, ... });
}
```
This confirms the exact throw message this phase's `isMimecastConfigured()` check must
pre-empt: `'MIMECAST_CLIENT_ID and MIMECAST_CLIENT_SECRET must be set'` (from
`getMimecastClient()`, line ~652 of `mimecast-client.ts`).
**Recommended shape for the new module:**
```typescript
// lib/services/mimecast-blast-radius.ts (illustrative — planner finalizes exact shape)
export interface BlastRadiusInput {
messageId?: string;
sender: string;
recipient: string; // reporter/reported-to address
subject: string;
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: 'message-info' | 'fan-out'; // which path produced the result
};
export async function getBlastRadius(input: BlastRadiusInput): Promise<BlastRadiusResult> {
if (!isMimecastConfigured()) {
return { status: 'unavailable', reason: 'not_configured' };
}
try {
// cache check, fan-out, merge — see diagram above
} catch (err: any) {
// BLAST-02 requires this to degrade, not throw, even on unexpected API failure
return { status: 'unavailable', reason: 'lookup_failed', error: err.message };
}
}
```
### Pattern 3: Real field names for the fan-out merge (BLAST-01)
These are the **actual** exported types from `mimecast-client.ts` — verified by reading the
file directly, not training-data recall:
```typescript
// searchDeliveredMessages() returns:
export interface MimecastDeliveredMessage {
id: string; status: string; subject: string; from: string; fromEnv: string;
to: string; toDisplay: string; received: string; senderIP: string;
spamScore: number; detectionLevel: string; attachments: boolean; route: string; info: string;
}
// Call shape:
async searchDeliveredMessages(options: {
to?: string; from?: string; subject?: string;
startHours?: number; start?: string; end?: string; route?: string;
}): Promise<{ messages: MimecastDeliveredMessage[]; error?: string }>
// getHeldMessages() returns:
export interface MimecastHeldMessage {
id: string; subject: string; from: string; fromDisplay: string; to: string;
toDisplay: string; dateReceived: string; reason: string; reasonCode: string;
policyInfo: string; route: string; hasAttachments: boolean; size: number;
}
// Call shape (paginates internally, caps at maxMessages, default 100):
async getHeldMessages(options: { recipient?: string; maxMessages?: number })
: Promise<{ messages: MimecastHeldMessage[]; totalCount: number }>
// getThreatEvents() returns:
export interface MimecastThreatEvent {
id: string; messageId?: string; eventType: string; threatLevel?: string;
url?: string; fileName?: string; verdict?: string; actorEmail?: string;
eventDateTime?: string; analysis: string[]; source: string[]; direction: string[]; status: string[];
}
// Call shape (no sender/subject filter param — this method takes only pagination options;
// filtering by sender/subject/date-window must happen client-side after fetching):
async getThreatEvents(options?: { cursor?: string; pageSize?: number })
: Promise<{ items: MimecastThreatEvent[]; nextCursor: string | null }>
// eventType is derived from analysis[0] (e.g. 'malware' | 'phishing' | 'spam' | 'unknown');
// threatLevel is a derived heuristic ('high' for malware/phishing, 'medium' for spam, else 'info')
// — NOT a raw Mimecast field. See Common Pitfalls #2 re: click-event derivation.
// getMessageInfo() returns (body/headers ONLY — no status, no counts, no recipients):
export interface MimecastMessageInfo {
messageId: string; bodyText?: string; bodyHtml?: string; headers?: Record<string, string>;
}
async getMessageInfo(messageId: string): Promise<MimecastMessageInfo | null>
```
**Per-recipient merge strategy:** Both `MimecastDeliveredMessage.to` and `MimecastHeldMessage.to`
are single strings (one recipient per row), consistent with Mimecast's message-tracking model
of one log entry per recipient-message pair. Build `perRecipient[]` by grouping all rows
returned across `searchDeliveredMessages` + `getHeldMessages` (keyed by `to`), classifying
each recipient as `delivered` (from the delivered-search result set), `held` (from the held
result set), or `rejected` (delivered-search rows whose `status` field indicates rejection —
exact string values not yet confirmed against a live tenant, see Open Questions). Recipients
appearing in neither result set are `unknown`, not silently dropped.
### Anti-Patterns to Avoid
- **Treating `getMessageInfo()`'s non-null return as "delivered: true".** Its data comes from
`data.data?.[0]?.deliveredMessage` internally, which *suggests* a bias toward delivered
messages, but the method's only documented behavior is "returns body/headers or null" —
it has no explicit status field. Do not infer delivery status from whether this call
succeeds; use it only for body/header evidence.
- **Skipping the fan-out when `getMessageInfo` hits.** As covered in Summary/Pitfall #1, this
would silently produce a normalized result with `matched`/`delivered`/`held`/`rejected`
hardcoded to some default rather than real counts. BLAST-01 requires the fan-out regardless.
- **Calling `getThreatEvents()` with sender/subject filters.** The method's actual signature
only accepts `{ cursor?, pageSize? }` — no server-side filter params exist for this endpoint
in the current client. Fetch and filter client-side by `analysis`/`source`/`eventDateTime`
matching the input's date window and sender.
- **Writing new raw `ioredis` cache code.** `redis-client.ts`'s `getCachedData`/`setCachedData`
already do this with graceful no-Redis fallback; re-implementing it duplicates logic and
risks missing the null-check-when-`REDIS_URL`-unset behavior.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Redis get/set-with-TTL | Custom `ioredis` wrapper in the new module | `getCachedData<T>()` / `setCachedData<T>()` from `lib/services/redis-client.ts` | Already handles `REDIS_URL` unset (returns `null`/no-ops), already has retry/backoff config, default TTL is already 300s matching D-04 |
| OAuth2 token management for Mimecast | New token-fetch/cache logic | `MimecastClient`'s internal `getToken()` (private, already handles 60s-early refresh) | Already correct and tested in production via the existing sync service + 7 API routes |
| Config-presence checking | Ad-hoc `process.env` checks scattered in the new module | One `isMimecastConfigured()` function, called once at the top of the public entry point | Matches project-wide `is<Name>Configured()` convention; single source of truth |
**Key insight:** Nearly everything this phase needs already exists somewhere in the codebase.
The actual new code surface is small: one boolean-returning function, one orchestration
function with a merge algorithm, and tests. Resist the urge to add configuration options,
pagination controls, or additional Mimecast endpoints beyond what BLAST-01/02 require —
Phase 19 is the only consumer and its needs are fully described by the phase's success criteria.
## Common Pitfalls
### Pitfall 1: `getMessageInfo()` does not provide delivery status
**What goes wrong:** Implementer treats a non-null `getMessageInfo()` result as satisfying
BLAST-01's required counts, skips the fan-out, and returns a normalized result with fabricated
or all-zero delivered/held/rejected/clicked counts.
**Why it happens:** D-01's phrasing ("try exact match first ... if that misses, fan out")
reads naturally as an either/or, and `getMessageInfo`'s name and internal `deliveredMessage`
key structure both suggest it carries delivery information.
**How to avoid:** Read `MimecastMessageInfo`'s actual shape (verified above: `messageId`,
`bodyText`, `bodyHtml`, `headers` only) before writing the merge logic. Treat `getMessageInfo`
as a supplementary body/header fetch only; always run the fan-out to build the counts.
**Warning signs:** Tests pass with `getMessageInfo` mocked to return non-null but the
normalized result has no way to distinguish delivered vs. held vs. rejected recipients.
### Pitfall 2: Assuming `getThreatEvents()` reliably carries click events
**What goes wrong:** D-02's best-effort click derivation is implemented assuming
`getThreatEvents()`'s `analysis`/`eventType` field set includes a click-type value, and tests
are written against a fabricated fixture with `analysis: ['click']` that has never been
validated against how Mimecast's `/threats/v1/events` endpoint actually categorizes events.
**Why it happens:** The endpoint's name ("threat events") and this client's existing
`normalizeThreatEvent()` heuristic (`analysis.includes('malware') ? 'high' : ...`) only handle
`malware`/`phishing`/`spam` — there is no existing code path or fixture proving a click
subtype appears here at all.
**Why it matters (research finding):** Per Mimecast's public API documentation (verified via
WebFetch against `integrations.mimecast.com`), URL-click data (`userEmailAddress`, `url`,
`category`, `action`, `date` of the click) is documented as living in a **separate** endpoint —
`POST /api/ttp/url/get-logs` ("Get TTP URL Logs") — not in `/threats/v1/events`, which this
client's `getThreatEvents()` wraps. This means D-02's best-effort approach may realistically
return `clicked: 0` for the vast majority of lookups, regardless of whether a click actually
occurred, because the wrapped endpoint isn't the one Mimecast uses to track clicks. This does
not block the phase (D-02 explicitly accepts `0` as a valid, documented best-effort value,
and explicitly defers building a dedicated click-log method) — but it should be documented
prominently in code comments so a future reader doesn't mistake `clicked: 0` for "confirmed no
one clicked."
**How to avoid:** In the merge function, add a code comment stating this limitation. Do not
silently treat `getThreatEvents()`'s absence of a click-type entry as proof of zero clicks in
any UI or classifier-facing text — CLASSIFY-03 (lowering confidence for incomplete evidence)
in the broader roadmap may need this signal.
**Confidence:** MEDIUM — based on official Mimecast documentation (integrations.mimecast.com),
not the actual raw response of this account's `/threats/v1/events` calls (no live tenant
access during this research pass).
### Pitfall 3: Multi-tenant Mimecast credentials are not addressed by this phase's scope
**What goes wrong:** The new `getBlastRadius()` function is built using only the single
env-var-configured `getMimecastClient()`, but this MSP's phishing reports come from many
different client companies, some of which may have their own Mimecast tenant registered in
the `mimecast_tenants` table (migration 062) rather than being covered by the global
credentials. If Wulf Consulting's own tenant is the only one configured via env vars, lookups
for reports belonging to companies with their own `mimecast_tenants` row will silently miss
data that the existing `/api/mimecast/held` and `/api/mimecast/delivered` routes (which use
`getMimecastClientForTenant()`, keyed to `company_id`) would have found.
**Why it happens:** CONTEXT.md's locked decisions and success criteria only reference the
single env-var client and `checkConfigOnly('mimecast', ...)`'s health check — the multi-tenant
table isn't mentioned anywhere in the phase's discussion.
**How to avoid:** Flag this to the user/planner explicitly before implementation (see Open
Questions) rather than silently choosing single-tenant or silently adding tenant resolution
that wasn't discussed. `reports.company_id` (migration 097) is available if per-company
resolution turns out to be needed.
**Warning signs:** Manual QA against a report from a company that has a `mimecast_tenants` row
but isn't the account behind the global `MIMECAST_CLIENT_ID` returns `unavailable` or all-zero
counts even though Mimecast *is* configured for that company.
## Code Examples
### Test mocking discipline to follow (verified from this repo's most recent phase)
```typescript
// Source: lib/services/pax8-factory.test.ts (existing, verified by reading the file)
import { describe, it, expect, beforeEach } from 'vitest';
import { isPax8Configured, getPax8Client, _resetPax8Client } from './pax8-factory';
beforeEach(() => {
delete process.env.PAX8_CLIENT_ID;
delete process.env.PAX8_CLIENT_SECRET;
_resetPax8Client();
});
describe('isPax8Configured', () => {
it('returns false when neither env var is set', () => {
expect(isPax8Configured()).toBe(false);
});
it('returns true when both env vars are set', () => {
process.env.PAX8_CLIENT_ID = 'id1';
process.env.PAX8_CLIENT_SECRET = 'secret1';
expect(isPax8Configured()).toBe(true);
});
});
```
Apply the same pattern for `isMimecastConfigured()` in a new `lib/services/mimecast-client.test.ts`
(no such file exists yet — this phase creates it).
```typescript
// Source: lib/services/phishing-eml-service.test.ts (existing, verified by reading the file)
// Pattern for mocking an internal factory dependency in the new orchestration test:
vi.mock('./mimecast-client', () => ({
isMimecastConfigured: () => true,
getMimecastClient: () => ({
getMessageInfo: vi.fn(),
searchDeliveredMessages: vi.fn(),
getHeldMessages: vi.fn(),
getThreatEvents: vi.fn(),
}),
}));
```
### Module header/return-type convention to follow
```typescript
// Source: lib/services/phishing-eml-service.ts (existing, verified by reading the file)
/**
* <Module purpose>.
*
* <Key behavior / invariant this module guarantees>.
*/
export interface FooInput { /* ... */ }
export interface FooResult { /* discriminated union, no throw on expected non-error outcomes */ }
export async function doTheThing(input: FooInput): Promise<FooResult> {
try {
// ...
} catch (err) {
console.error('[MIMECAST-BLAST-RADIUS] ...', err);
return { status: 'unavailable', reason: 'lookup_failed' };
}
}
```
## State of the Art
Not applicable in the traditional sense — this is a small internal composition task, not a
library/framework adoption. One relevant note: the repo's own `docs/mimecast-api-guide.md`
documents a *different* set of Mimecast endpoints (`/api/audit/get-siem-logs`,
`/api/ttp/attachment/get-logs`, `/api/ttp/url/get-managed-url`, `/api/audit/get-audit-events`)
than the ones `mimecast-client.ts` actually implements (`/api/message-finder/search`,
`/api/message-finder/get-message-info`, `/api/gateway/get-hold-message-list`,
`/threats/v1/events`, `/siem/v1/batch/events/cg`). The doc appears to be a generic
"threat dashboard" reference guide, not documentation of this specific client's implementation
— treat `mimecast-client.ts`'s source code as the source of truth for what this phase can
actually call, not `docs/mimecast-api-guide.md`.
**Deprecated/outdated:** N/A.
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Mimecast's `/threats/v1/events` endpoint (wrapped by `getThreatEvents()`) does not carry URL-click event data — that lives in a separate, currently-unwrapped `/api/ttp/url/get-logs` endpoint | Common Pitfalls #2 | If wrong, D-02's best-effort click derivation could actually work reliably rather than mostly returning 0 — low risk either way since D-02 explicitly treats 0 as an acceptable best-effort value, but code comments should not overstate confidence in either direction until validated against a live tenant |
| A2 | This phase should use only the single env-var-configured `getMimecastClient()`, not the per-company `mimecast_tenants` table / `getMimecastClientForTenant()` | Common Pitfalls #3, Open Questions | If wrong, blast-radius lookups for companies with their own Mimecast tenant (not the global account) will silently return `unavailable` or incomplete data despite Mimecast genuinely being configured for that company — this could materially affect Phase 19's classification confidence for those companies |
| A3 | The exact string values of `MimecastDeliveredMessage.status` (used to distinguish delivered vs. rejected) have not been confirmed against a live/sandbox Mimecast tenant response — only the TypeScript field name and type (`string`) are confirmed from the client code | Architecture Patterns (Pattern 3) | If the assumed status strings (e.g. "Delivered", "Rejected") don't match Mimecast's actual values, the delivered/rejected split in the normalized shape will misclassify recipients; implementer should log raw values during first real-tenant test rather than hardcoding a guessed enum |
## Open Questions
1. **Should this phase's abstraction resolve a per-company Mimecast tenant, or only use the
single global env-var client?**
- What we know: `mimecast_tenants` (migration 062) exists and is actively used by
`/api/mimecast/held` and `/api/mimecast/delivered` (the two existing routes closest in
purpose to this phase's lookup). `reports.company_id` (migration 097) is available to key
off of. CONTEXT.md's decisions and BLAST-01/02 requirement text only mention the single
env-var client and `checkConfigOnly`'s env-var-only health check.
- What's unclear: Whether Phase 19's classifier will ever be classifying reports from
companies whose Mimecast is only registered per-tenant (not covered by the global
`MIMECAST_CLIENT_ID`), and whether that gap is acceptable for v1.
- Recommendation: Default to the single env-var client for this phase (matches locked
scope and keeps the abstraction simple), but flag this explicitly as a known limitation
in code comments and in the phase's done-report, so it's a conscious tradeoff rather than
a silent gap. If per-company resolution turns out to be needed, it's a small, mechanical
addition later (swap `getMimecastClient()` for a tenant lookup + `getMimecastClientForTenant()`,
using the same fan-out logic).
2. **What are the real string values Mimecast returns for `status` in message-tracking search
results (delivered vs. rejected classification)?**
- What we know: The TypeScript field exists (`MimecastDeliveredMessage.status: string`) and
is populated from `e.status` in the raw API response, per `searchDeliveredMessages()`'s
implementation.
- What's unclear: The actual enum of values (no live/sandbox Mimecast tenant was queried
during this research pass — this repo's existing `.env` may have real Mimecast credentials,
see CLAUDE.md's warning to treat `.env` as potentially real, but exercising a live call was
out of scope for a research pass with no test tenant confirmed safe to query).
- Recommendation: During implementation/testing, log raw `status` values seen from a real
or sandbox account before finalizing the delivered/rejected classification logic. Do not
hardcode a guessed enum as if it were confirmed.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| `MIMECAST_CLIENT_ID` / `MIMECAST_CLIENT_SECRET` env vars | BLAST-01 (live Mimecast calls) | Unknown at research time — depends on target deploy env | — | `isMimecastConfigured()` returns `false``status: 'unavailable'` (BLAST-02, by design) |
| Redis (`REDIS_URL`) | D-04 caching | Optional — `redis-client.ts` already handles absence gracefully | ioredis 5.9.0 (installed) | `getCachedData`/`setCachedData` no-op when `REDIS_URL` unset; lookups just aren't cached, no functional break |
| Mimecast API reachability (`https://api.services.mimecast.com`) | BLAST-01 live calls | Not tested (would require real credentials + network egress) | — | Wrapped in try/catch per Pattern 2 — network failure degrades to `status: 'unavailable'`, never throws to caller |
**Missing dependencies with no fallback:** None — every dependency in this phase has an
explicit graceful-degradation path by design (that's the point of BLAST-02).
**Missing dependencies with fallback:** Redis absence (caching becomes a no-op, not a failure);
Mimecast credential absence (`unavailable` status, not a crash).
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | vitest 4.1.5 (`vitest.config.ts`) |
| Config file | `vitest.config.ts``include: ['lib/**/*.test.ts']`, `environment: 'node'` |
| Quick run command | `npx vitest run lib/services/mimecast-blast-radius.test.ts lib/services/mimecast-client.test.ts` |
| Full suite command | `npm test` (= `vitest run`) |
**Important correction to CLAUDE.md's stated test coverage:** CLAUDE.md states `npm test`
"covers `lib/services/analyzer/**`, `lib/services/rmm/**`, `lib/services/b2/**`, and
`lib/services/analyzer/link-discovery.test.ts`. Other parts of the codebase have no tests."
This is now **out of date**`vitest.config.ts`'s actual `include` glob is `lib/**/*.test.ts`
(no subdirectory restriction), and the repo already has passing test files well outside those
three directories: `lib/permissions.test.ts`, `lib/services/autotask-client.test.ts`,
`lib/services/pax8-*.test.ts` (4 files), `lib/services/phishing-detector.test.ts`,
`lib/services/phishing-eml-service.test.ts`, `lib/services/eml-parser.test.ts`. **Confirmed:**
a new `lib/services/mimecast-blast-radius.test.ts` and `lib/services/mimecast-client.test.ts`
WILL run under `npm test` — no special invocation or config change needed.
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| BLAST-01 | Configured + fan-out returns normalized matched/delivered/held/rejected/clicked + perRecipient | unit | `npx vitest run lib/services/mimecast-blast-radius.test.ts` | ❌ Wave 0 |
| BLAST-01 | `isMimecastConfigured()` reflects env var presence | unit | `npx vitest run lib/services/mimecast-client.test.ts` | ❌ Wave 0 |
| BLAST-02 | Not configured → synchronous `status: 'unavailable'`, no throw | unit | `npx vitest run lib/services/mimecast-blast-radius.test.ts` | ❌ Wave 0 |
| BLAST-02 | Unexpected fan-out error (e.g. mocked rejection) degrades to `unavailable`, does not throw | unit | `npx vitest run lib/services/mimecast-blast-radius.test.ts` | ❌ Wave 0 |
| D-04 | Cached lookup returns without re-calling `MimecastClient` methods | unit | `npx vitest run lib/services/mimecast-blast-radius.test.ts` | ❌ Wave 0 |
### Sampling Rate
- **Per task commit:** `npx vitest run lib/services/mimecast-blast-radius.test.ts lib/services/mimecast-client.test.ts`
- **Per wave merge:** `npm test` (full suite — fast, no real network/DB in this phase's tests since everything is mocked per the `pax8-factory.test.ts` / `phishing-eml-service.test.ts` discipline)
- **Phase gate:** Full suite green before `/gsd:verify-work`
### Wave 0 Gaps
- [ ] `lib/services/mimecast-client.test.ts` — new file, covers `isMimecastConfigured()` (BLAST-01/02 config gate)
- [ ] `lib/services/mimecast-blast-radius.test.ts` — new file, covers BLAST-01/02 and D-01 through D-04
- [ ] No shared fixture file needed — synthetic inline mock objects (matching `MimecastDeliveredMessage`/`MimecastHeldMessage`/`MimecastThreatEvent` shapes above) are sufficient; no `.eml`-scale fixture complexity here
- [ ] Framework install: none — vitest already installed and configured
## Security Domain
`security_enforcement` is not present in `.planning/config.json` — treated as enabled per
default.
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-------------------|
| V2 Authentication | No | This phase has no HTTP endpoint, no session handling — it's an internal function called by a future in-process consumer (Phase 19) |
| V3 Session Management | No | Same as above |
| V4 Access Control | No | No new API route in this phase; ACCESS-01 (auth on `/api/phishing/*`) is correctly mapped to Phase 18, not this one |
| V5 Input Validation | Yes | Input shape (`messageId?`, `sender`, `recipient`, `subject`, `dateWindow`) should be validated at the type level (TypeScript) before being passed into Mimecast query params; no raw user-supplied string should be interpolated into request bodies beyond what `MimecastClient.request()` already JSON-serializes safely |
| V6 Cryptography | No | Reuses `MimecastClient`'s existing OAuth2 token handling; no new crypto in this phase |
### Known Threat Patterns for this stack
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Overly broad Mimecast fan-out query (e.g. missing subject/sender bounds) returning another company's unrelated messages | Information Disclosure | Always require sender + subject + date-window (or Message-ID) — never call `searchDeliveredMessages`/`getHeldMessages` with only a date range; this phase's own input type already requires these fields |
| Logging full Mimecast response bodies (which may include other recipients' email content/subjects) in error paths | Information Disclosure | Follow the existing pattern in `mimecast-sync-service.ts` / API routes: log `error.message`, not full response bodies, in `console.error` calls |
| Redis cache key collision across companies (if per-company resolution is added later per Open Question #1) if key doesn't include a company/tenant discriminator | Tampering / Information Disclosure | If tenant resolution is added, the cache key MUST include the tenant/company identifier, not just message identity, to avoid one company's cached blast-radius data being served for another company's identically-subjected report |
## Sources
### Primary (HIGH confidence — read directly from this repo)
- `lib/services/mimecast-client.ts` — full file read; all type shapes and method signatures in this document are verified against this source, not recalled from training data
- `lib/services/mimecast-sync-service.ts` — confirmed `getMimecastClient()` (not per-tenant) usage in the bulk sync path
- `lib/services/veeam-factory.ts``is<Name>Configured()` + `get<Name>Client()` factory precedent
- `lib/services/redis-client.ts` — full file read; `getCachedData`/`setCachedData`/default TTL confirmed
- `lib/services/integration-health.ts` (lines 1-80, 300-360) — `checkConfigOnly()` implementation and the exact `mimecast` entry (env vars, category)
- `app/api/mimecast/status/route.ts`, `held/route.ts`, `delivered/route.ts`, `threats/route.ts` — confirmed existing call patterns, per-tenant vs. singleton client usage, and the exact throw-then-catch failure mode this phase must pre-empt
- `migrations/062_create_mimecast_tenants.sql`, `migrations/097_phishing_triage_schema.sql` — confirmed multi-tenant table schema and `classifications.reasons`/`reports.company_id` schema
- `vitest.config.ts` — confirmed `include: ['lib/**/*.test.ts']` (no subdirectory restriction)
- `lib/services/pax8-factory.test.ts`, `lib/services/phishing-eml-service.ts` — confirmed test-mocking discipline and module-header/return-type conventions to follow
- `.planning/phases/17-mimecast-blast-radius-lookup/17-CONTEXT.md`, `.planning/REQUIREMENTS.md`, `.planning/STATE.md` — phase scope and locked decisions
### Secondary (MEDIUM confidence — official Mimecast docs, cross-checked against this repo's code)
- [Get TTP URL Logs — Mimecast integrations docs](https://integrations.mimecast.com/documentation/endpoint-reference/logs-and-statistics/get-ttp-url-logs/) — confirms click-log data (`userEmailAddress`, `url`, `category`, `action`, `date`) lives at `POST /api/ttp/url/get-logs`, a different endpoint than `/threats/v1/events`
- `docs/mimecast-api-guide.md` (in-repo) — cross-checked against actual client implementation; found to document a *different* endpoint set than what `mimecast-client.ts` implements (see State of the Art)
### Tertiary (LOW confidence — not independently verified)
- Exact runtime string values of Mimecast's message-tracking `status` field (delivered/rejected classification) — not verified against a live tenant during this research pass; flagged in Assumptions Log (A3) and Open Questions (#2)
- Whether `/threats/v1/events` ever surfaces a click-type `analysis` value in practice (vs. the documented-separate `/api/ttp/url/get-logs` endpoint) — flagged in Assumptions Log (A1) and Common Pitfalls #2
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — no new dependencies, all reused modules read directly
- Architecture: HIGH — patterns and field names verified against actual source files, not assumed
- Pitfalls: HIGH for #1 and #3 (both derived directly from reading source code); MEDIUM for #2 (derived from official Mimecast docs, not this account's live API responses)
**Research date:** 2026-07-15
**Valid until:** 30 days (internal composition of existing stable modules; re-verify if `mimecast-client.ts` or `redis-client.ts` change before this phase is implemented)