From 94f7dad29c87be60c12893ffc317032e3e6bd789 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 18 Jul 2026 05:44:56 -0400 Subject: [PATCH] fix(quick-260718-7v8): date-scope held-message lookup + sender-relevance guard - 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 --- lib/services/mimecast-blast-radius.ts | 36 ++++++++++++++++++++++++--- lib/services/mimecast-client.ts | 8 ++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/lib/services/mimecast-blast-radius.ts b/lib/services/mimecast-blast-radius.ts index 31ee6ef..778975b 100644 --- a/lib/services/mimecast-blast-radius.ts +++ b/lib/services/mimecast-blast-radius.ts @@ -88,6 +88,25 @@ 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 } @@ -128,7 +147,7 @@ export async function getBlastRadius( start: startStr, end: endStr, }), - client.getHeldMessages({ recipient: input.recipient }), + client.getHeldMessages({ recipient: input.recipient, start: startStr, end: endStr }), client.getThreatEvents(), ]); @@ -144,6 +163,17 @@ export async function getBlastRadius( 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) { @@ -155,7 +185,7 @@ export async function getBlastRadius( const delivered = nonRejectedDeliveredRows.length; const rejected = rejectedRows.length; - const held = heldRows.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; @@ -175,7 +205,7 @@ export async function getBlastRadius( for (const row of rejectedRows) { if (row.to) perRecipientMap.set(row.to, 'rejected'); } - for (const row of heldRows) { + for (const row of relevantHeldRows) { if (row.to) perRecipientMap.set(row.to, 'held'); } if (!perRecipientMap.has(input.recipient)) { diff --git a/lib/services/mimecast-client.ts b/lib/services/mimecast-client.ts index f04b77c..74a93fa 100644 --- a/lib/services/mimecast-client.ts +++ b/lib/services/mimecast-client.ts @@ -482,6 +482,8 @@ export class MimecastClient { async getHeldMessages(options: { recipient?: string; maxMessages?: number; + start?: string; + end?: string; } = {}): Promise<{ messages: MimecastHeldMessage[]; totalCount: number }> { const maxMessages = options.maxMessages ?? 100; const all: MimecastHeldMessage[] = []; @@ -490,6 +492,12 @@ export class MimecastClient { do { const reqBody: any = { admin: true }; + if (options.start) { + reqBody.start = options.start; + } + if (options.end) { + reqBody.end = options.end; + } if (options.recipient) { reqBody.searchBy = { fieldName: 'recipient', value: options.recipient }; }