From 58202e0deedf6c603a3c8ffcac2d829fce6ccb03 Mon Sep 17 00:00:00 2001 From: lorentz Date: Tue, 21 Jul 2026 16:25:08 -0400 Subject: [PATCH] fix(260721-mmf): broaden Mimecast blast-radius fan-out to whole tenant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - searchDeliveredMessages now called with from+subject+start+end only (no `to`) so it returns every delivered/rejected message matching the campaign across all recipients, not just the reporter's mailbox - getHeldMessages now called with start+end only (no `recipient`) — domainsMatch() post-filter is the sole scoping mechanism for held rows - Updated inline comments to document the tenant-wide fan-out and the per-recipient merge behavior it now produces --- lib/services/mimecast-blast-radius.ts | 28 +++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/lib/services/mimecast-blast-radius.ts b/lib/services/mimecast-blast-radius.ts index 778975b..ad2d31a 100644 --- a/lib/services/mimecast-blast-radius.ts +++ b/lib/services/mimecast-blast-radius.ts @@ -139,15 +139,23 @@ export async function getBlastRadius( // 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({ - to: input.recipient, from: input.sender, subject: input.subject, start: startStr, end: endStr, }), - client.getHeldMessages({ recipient: input.recipient, start: startStr, end: endStr }), + client.getHeldMessages({ start: startStr, end: endStr }), client.getThreatEvents(), ]); @@ -192,12 +200,16 @@ export async function getBlastRadius( 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'). + // 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');