docs(17): research phase domain
This commit is contained in:
parent
e30d9af46d
commit
cacf41a493
1 changed files with 680 additions and 0 deletions
680
.planning/phases/17-mimecast-blast-radius-lookup/17-RESEARCH.md
Normal file
680
.planning/phases/17-mimecast-blast-radius-lookup/17-RESEARCH.md
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue