fix(260721-mmf): broaden Mimecast blast-radius fan-out to whole tenant

- 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
This commit is contained in:
lorentz 2026-07-21 16:25:08 -04:00
parent f50b8a3a9b
commit 58202e0dee

View file

@ -139,15 +139,23 @@ export async function getBlastRadius(
// D-01 (corrected): the fan-out runs unconditionally — it is the only // D-01 (corrected): the fan-out runs unconditionally — it is the only
// source of the matched/delivered/held/rejected/clicked counts. // 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([ const [deliveredResult, heldResult, threatsResult] = await Promise.all([
client.searchDeliveredMessages({ client.searchDeliveredMessages({
to: input.recipient,
from: input.sender, from: input.sender,
subject: input.subject, subject: input.subject,
start: startStr, start: startStr,
end: endStr, end: endStr,
}), }),
client.getHeldMessages({ recipient: input.recipient, start: startStr, end: endStr }), client.getHeldMessages({ start: startStr, end: endStr }),
client.getThreatEvents(), client.getThreatEvents(),
]); ]);
@ -192,12 +200,16 @@ export async function getBlastRadius(
const clicked = (threatsResult.items ?? []).filter((t) => isClickEvent(t.analysis)).length; const clicked = (threatsResult.items ?? []).filter((t) => isClickEvent(t.analysis)).length;
// Per-recipient merge: group by the single-string `to` field. Delivered // Per-recipient merge: group by the single-string `to` field. Since the
// (non-rejected) rows → 'delivered', rejected rows → 'rejected', held // fan-out above is tenant-wide (not scoped to one recipient), this Map
// rows → 'held' (held overwrites a same-recipient delivered entry — a // naturally accumulates one entry per distinct `to` value seen across all
// held message for a recipient is the more actionable signal). Ensure // delivered/held/rejected rows — the true multi-recipient blast radius,
// the originally-queried recipient is always represented, even if // not just the reporter. Delivered (non-rejected) rows → 'delivered',
// neither result set returned a row for them ('unknown'). // 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<string, 'delivered' | 'held' | 'rejected' | 'unknown'>(); const perRecipientMap = new Map<string, 'delivered' | 'held' | 'rejected' | 'unknown'>();
for (const row of nonRejectedDeliveredRows) { for (const row of nonRejectedDeliveredRows) {
if (row.to) perRecipientMap.set(row.to, 'delivered'); if (row.to) perRecipientMap.set(row.to, 'delivered');