- getHeldMessages() accepts optional start/end, threaded into data[0] as siblings of admin/searchBy (backward compatible when omitted; 403 fallback body inherits them automatically via the existing spread) - getBlastRadius() passes the same startStr/endStr window already computed for searchDeliveredMessages into getHeldMessages() - Added domainsMatch() sender-relevance guard: held rows whose sender domain doesn't match input.sender (exact-or-proper-subdomain) are filtered out before counting/merging, so unrelated same-window holds never inflate held/matched or override a delivered recipient
240 lines
10 KiB
TypeScript
240 lines
10 KiB
TypeScript
/**
|
|
* 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<BlastRadiusResult> {
|
|
// 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<BlastRadiusResult>(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.
|
|
const [deliveredResult, heldResult, threatsResult] = await Promise.all([
|
|
client.searchDeliveredMessages({
|
|
to: input.recipient,
|
|
from: input.sender,
|
|
subject: input.subject,
|
|
start: startStr,
|
|
end: endStr,
|
|
}),
|
|
client.getHeldMessages({ recipient: input.recipient, 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. Delivered
|
|
// (non-rejected) rows → 'delivered', rejected rows → 'rejected', held
|
|
// rows → 'held' (held overwrites a same-recipient delivered entry — a
|
|
// held message for a recipient is the more actionable signal). Ensure
|
|
// the originally-queried recipient is always represented, even if
|
|
// neither result set returned a row for them ('unknown').
|
|
const perRecipientMap = new Map<string, 'delivered' | 'held' | 'rejected' | 'unknown'>();
|
|
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 };
|
|
}
|
|
}
|