/** * Mimecast blast-radius lookup abstraction (Phase 17). * * Given a reported message's identity (Message-ID, sender, recipient, * subject, date window), returns normalized delivery data — matched/ * delivered/held/rejected/clicked counts and per-recipient status — when * Mimecast is configured, or a clean `status: 'unavailable'` signal (never a * throw) when it isn't or when the underlying lookup fails unexpectedly. * * Three load-bearing facts: * * (a) EPHEMERAL (D-03): this lookup is a pure function call — nothing is * persisted here. If Phase 19's classifier wants to keep a result, it * writes to its own `classifications.reasons` JSONB column; that is * Phase 19's concern, not this module's. * * (b) `clicked` IS BEST-EFFORT (D-02): it is derived from getThreatEvents()'s * analysis[] subtype containing a click-like value. Mimecast's real * click-tracking data (TTP URL Protect logs) lives in a separate, * currently-unwrapped `/api/ttp/url/get-logs` endpoint (see * 17-RESEARCH.md Pitfall 2) — this module does not call it. A value of * `clicked: 0` means "no click-type threat event found in the events * this module can see," NOT "confirmed zero clicks." * * (c) PER-TENANT RESOLUTION (D-05, formerly a known gap): this module now * accepts an optional `options.client` — a pre-built MimecastClient for a * specific company's `mimecast_tenants` row (via * getMimecastClientForTenant()). Resolution of WHICH tenant to use is the * caller's responsibility (the campaign detail route looks up * reports.company_id -> mimecast_tenants); this module simply uses * whatever client it is given, or falls back to the single global * env-var-configured getMimecastClient() when no client is injected. */ import { isMimecastConfigured, getMimecastClient, type MimecastClient, type MimecastDeliveredMessage, type MimecastHeldMessage, } from './mimecast-client'; import { getCachedData, setCachedData } from './redis-client'; export interface BlastRadiusInput { /** Optional Mimecast Message-ID — used only for supplementary body/header * evidence via getMessageInfo(); it never gates or replaces the fan-out. */ messageId?: string; /** Reported message's sender address. Required — see T-17-01: never allow * a date-range-only fan-out query. */ sender: string; /** Reporter/reported-to recipient address. Required — see T-17-01. */ recipient: string; /** Reported message's subject. Required — see T-17-01. */ subject: string; /** Date window to search within. Required — see T-17-01. */ dateWindow: { start: Date; end: Date }; } export type BlastRadiusResult = | { status: 'unavailable'; reason: 'not_configured' | 'lookup_failed'; error?: string } | { status: 'ok'; matched: number; delivered: number; held: number; rejected: number; clicked: number; perRecipient: Array<{ recipient: string; status: 'delivered' | 'held' | 'rejected' | 'unknown' }>; source: 'fan-out'; }; /** * Best-effort delivered/rejected split. The exact string values Mimecast * returns in MimecastDeliveredMessage.status have NOT been confirmed against * a live/sandbox tenant (17-RESEARCH.md A3 / Open Question #2) — only the * TypeScript field name and type (`string`) are confirmed from the client * code. Treat unrecognized status strings conservatively as NOT rejected * (i.e. delivered) rather than hardcoding a guessed enum as if it were * confirmed. Log raw values at debug level so a first real-tenant run can * validate/refine this heuristic. */ function isRejectedStatus(status: string): boolean { return /reject/i.test(status); } /** Best-effort click-type detection (D-02) — see module doc-comment (b). */ function isClickEvent(analysis: string[] | undefined): boolean { return (analysis ?? []).some((a) => /click/i.test(a)); } /** * Sender-relevance guard: true when the domain of `a` exactly matches the * domain of `b`, or one is a proper subdomain of the other — case-insensitive, * never a bare substring match. Mirrors the comparison logic in * campaign-classifier's `domainMatchesAllowlist` (but does not reuse it — that * helper is allowlist-specific). */ function domainsMatch(a: string, b: string): boolean { const domainOf = (address: string): string => { const at = address.lastIndexOf('@'); if (at === -1 || at === address.length - 1) return ''; return address.slice(at + 1).toLowerCase(); }; const x = domainOf(a); const y = domainOf(b); if (!x || !y) return false; return x === y || x.endsWith(`.${y}`) || y.endsWith(`.${x}`); } export async function getBlastRadius( input: BlastRadiusInput, options?: { client?: MimecastClient; cacheScope?: string } ): Promise { // An injected tenant client is self-sufficient (it carries its own // credentials) — only fall back to the global env-configured client (and // its isMimecastConfigured() gate) when no client was injected. const client = options?.client ?? (isMimecastConfigured() ? getMimecastClient() : null); if (!client) { return { status: 'unavailable', reason: 'not_configured' }; } const scope = options?.cacheScope ?? 'global'; const cacheKey = input.messageId ? `mimecast:blast-radius:${scope}:msgid:${input.messageId}` : `mimecast:blast-radius:${scope}:composite:${input.sender}:${input.subject}:${input.dateWindow.start.toISOString()}:${input.dateWindow.end.toISOString()}`; const cached = await getCachedData(cacheKey); if (cached) return cached; try { // Supplementary only — body/header evidence, never gates the fan-out // (17-RESEARCH.md Pitfall 1: getMessageInfo() has no status/counts). if (input.messageId) { await client.getMessageInfo(input.messageId); } const startStr = input.dateWindow.start.toISOString().replace(/\.\d{3}Z$/, '+0000'); const endStr = input.dateWindow.end.toISOString().replace(/\.\d{3}Z$/, '+0000'); // D-01 (corrected): the fan-out runs unconditionally — it is the only // source of the matched/delivered/held/rejected/clicked counts. // // Tenant-wide fan-out (T-17-01): deliberately NOT scoped by a single // recipient. searchDeliveredMessages is queried by sender+subject+ // date-window only, so it returns every delivered/rejected message // matching this campaign across the whole tenant — the true blast // radius, not just the reporter's mailbox. getHeldMessages is queried // by date-window only (it has no server-side sender filter) and relies // entirely on the domainsMatch() post-filter below to stay scoped to // this campaign's sender. const [deliveredResult, heldResult, threatsResult] = await Promise.all([ client.searchDeliveredMessages({ from: input.sender, subject: input.subject, start: startStr, end: endStr, }), client.getHeldMessages({ start: startStr, end: endStr }), client.getThreatEvents(), ]); // Bug 1 defense-in-depth: searchDeliveredMessages() swallows its own // errors internally (returns { messages: [], error } rather than // throwing) — e.g. a future end-date rejected by Mimecast as // err_track_and_trace_invalid_end_date. Without this check, that // swallowed error would silently present as a confident zero-count 'ok'. // Surface it via the existing outer catch instead. if (deliveredResult.error) { throw new Error(deliveredResult.error); } const deliveredRows: MimecastDeliveredMessage[] = deliveredResult.messages ?? []; const heldRows: MimecastHeldMessage[] = heldResult.messages ?? []; // Even within the date window, unrelated held messages (different // senders that happen to land in the same window) must not count toward // held/matched or override a recipient's delivered status. Filter to // rows whose sender domain plausibly matches the campaign's sender. const relevantHeldRows = heldRows.filter((h) => domainsMatch(h.from, input.sender)); if (heldRows.length !== relevantHeldRows.length) { console.debug( '[MIMECAST-BLAST-RADIUS] held rows filtered as unrelated sender:', heldRows.length - relevantHeldRows.length ); } const rawStatuses = Array.from(new Set(deliveredRows.map((m) => m.status))); if (rawStatuses.length > 0) { console.debug('[MIMECAST-BLAST-RADIUS] raw delivered-message status values seen:', rawStatuses); } const rejectedRows = deliveredRows.filter((m) => isRejectedStatus(m.status)); const nonRejectedDeliveredRows = deliveredRows.filter((m) => !isRejectedStatus(m.status)); const delivered = nonRejectedDeliveredRows.length; const rejected = rejectedRows.length; const held = relevantHeldRows.length; // matched = delivered + held; a rejected recipient was never actually // delivered, so rejected is NOT included in matched. const matched = delivered + held; const clicked = (threatsResult.items ?? []).filter((t) => isClickEvent(t.analysis)).length; // Per-recipient merge: group by the single-string `to` field. Since the // fan-out above is tenant-wide (not scoped to one recipient), this Map // naturally accumulates one entry per distinct `to` value seen across all // delivered/held/rejected rows — the true multi-recipient blast radius, // not just the reporter. Delivered (non-rejected) rows → 'delivered', // rejected rows → 'rejected', held rows → 'held' (held overwrites a // same-recipient delivered entry — a held message for a recipient is the // more actionable signal). Ensure the originally-reported recipient is // always represented, even if neither result set returned a row for them // ('unknown'). const perRecipientMap = new Map(); for (const row of nonRejectedDeliveredRows) { if (row.to) perRecipientMap.set(row.to, 'delivered'); } for (const row of rejectedRows) { if (row.to) perRecipientMap.set(row.to, 'rejected'); } for (const row of relevantHeldRows) { if (row.to) perRecipientMap.set(row.to, 'held'); } if (!perRecipientMap.has(input.recipient)) { perRecipientMap.set(input.recipient, 'unknown'); } const perRecipient = Array.from(perRecipientMap.entries()).map(([recipient, status]) => ({ recipient, status, })); const result: BlastRadiusResult = { status: 'ok', matched, delivered, held, rejected, clicked, perRecipient, source: 'fan-out', }; await setCachedData(cacheKey, result, 300); return result; } catch (err) { // T-17-02: log err.message ONLY, never full Mimecast response bodies // (which may contain other recipients' subjects/content). const message = err instanceof Error ? err.message : String(err); console.error('[MIMECAST-BLAST-RADIUS] lookup failed', message); return { status: 'unavailable', reason: 'lookup_failed', error: message }; } }