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
This commit is contained in:
lorentz 2026-07-18 05:44:56 -04:00
parent 36f4d418d0
commit 94f7dad29c
2 changed files with 41 additions and 3 deletions

View file

@ -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)) {

View file

@ -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 };
}