From 94f7dad29c87be60c12893ffc317032e3e6bd789 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 18 Jul 2026 05:44:56 -0400 Subject: [PATCH 1/2] 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 }; } From 67ee6801054b6b0da4fa3821226caf4772b8ba10 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 18 Jul 2026 05:46:22 -0400 Subject: [PATCH 2/2] test(quick-260718-7v8): cover held-message date-scoping and sender-relevance guard - blast-radius: getHeldMessages called with same start/end window as searchDeliveredMessages - blast-radius: unrelated-sender held row excluded from held/matched and perRecipient; matching-sender held row still counts and overrides - client: getHeldMessages threads start/end into POST body data[0] when provided, omits them when not --- lib/services/mimecast-blast-radius.test.ts | 125 +++++++++++++++++++++ lib/services/mimecast-client.test.ts | 43 ++++++- 2 files changed, 167 insertions(+), 1 deletion(-) diff --git a/lib/services/mimecast-blast-radius.test.ts b/lib/services/mimecast-blast-radius.test.ts index 54ebbd6..d0b9fa5 100644 --- a/lib/services/mimecast-blast-radius.test.ts +++ b/lib/services/mimecast-blast-radius.test.ts @@ -283,4 +283,129 @@ describe('getBlastRadius', () => { }); expect(setCachedDataMock).not.toHaveBeenCalled(); }); + + it('calls getHeldMessages with the SAME start/end window passed to searchDeliveredMessages', async () => { + await getBlastRadius(BASE_INPUT); + + const expectedStart = BASE_INPUT.dateWindow.start.toISOString().replace(/\.\d{3}Z$/, '+0000'); + const expectedEnd = BASE_INPUT.dateWindow.end.toISOString().replace(/\.\d{3}Z$/, '+0000'); + + expect(searchDeliveredMessagesMock).toHaveBeenCalledWith( + expect.objectContaining({ start: expectedStart, end: expectedEnd }) + ); + expect(getHeldMessagesMock).toHaveBeenCalledWith({ + recipient: BASE_INPUT.recipient, + start: expectedStart, + end: expectedEnd, + }); + }); + + it('excludes an in-window held row from an UNRELATED sender domain from held/matched and perRecipient', async () => { + searchDeliveredMessagesMock.mockResolvedValue({ + messages: [ + { + id: 'd1', + status: 'Delivered', + subject: BASE_INPUT.subject, + from: BASE_INPUT.sender, + fromEnv: BASE_INPUT.sender, + to: BASE_INPUT.recipient, + toDisplay: 'Reporter', + received: '2026-07-14T12:00:00+0000', + senderIP: '1.2.3.4', + spamScore: 0, + detectionLevel: 'none', + attachments: false, + route: 'inbound', + info: '', + }, + ], + }); + getHeldMessagesMock.mockResolvedValue({ + messages: [ + { + id: 'h1', + subject: 'Weekly promo', + from: 'promo@paulfredrick.test', // unrelated domain vs. evil.test + fromDisplay: 'Paul Fredrick', + to: BASE_INPUT.recipient, + toDisplay: 'Reporter', + dateReceived: '2026-07-14T13:00:00+0000', + reason: 'spam', + reasonCode: 'SPAM', + policyInfo: '', + route: 'inbound', + hasAttachments: false, + size: 100, + }, + ], + totalCount: 1, + }); + + const result = await getBlastRadius(BASE_INPUT); + + expect(result).toEqual({ + status: 'ok', + matched: 1, + delivered: 1, + held: 0, + rejected: 0, + clicked: 0, + perRecipient: [{ recipient: BASE_INPUT.recipient, status: 'delivered' }], + source: 'fan-out', + }); + }); + + it('still counts and overrides when a held row sender domain MATCHES input.sender', async () => { + searchDeliveredMessagesMock.mockResolvedValue({ + messages: [ + { + id: 'd1', + status: 'Delivered', + subject: BASE_INPUT.subject, + from: BASE_INPUT.sender, + fromEnv: BASE_INPUT.sender, + to: BASE_INPUT.recipient, + toDisplay: 'Reporter', + received: '2026-07-14T12:00:00+0000', + senderIP: '1.2.3.4', + spamScore: 0, + detectionLevel: 'none', + attachments: false, + route: 'inbound', + info: '', + }, + ], + }); + getHeldMessagesMock.mockResolvedValue({ + messages: [ + { + id: 'h1', + subject: BASE_INPUT.subject, + from: 'queue@evil.test', // same domain as BASE_INPUT.sender (evil.test) + fromDisplay: 'Attacker Queue', + to: BASE_INPUT.recipient, + toDisplay: 'Reporter', + dateReceived: '2026-07-14T13:00:00+0000', + reason: 'spam', + reasonCode: 'SPAM', + policyInfo: '', + route: 'inbound', + hasAttachments: false, + size: 100, + }, + ], + totalCount: 1, + }); + + const result = await getBlastRadius(BASE_INPUT); + + expect(result.status).toBe('ok'); + if (result.status === 'ok') { + expect(result.held).toBe(1); + expect(result.delivered).toBe(1); + expect(result.matched).toBe(2); // 1 non-rejected delivered + 1 held (counts are independent of the perRecipient override) + expect(result.perRecipient).toEqual([{ recipient: BASE_INPUT.recipient, status: 'held' }]); + } + }); }); diff --git a/lib/services/mimecast-client.test.ts b/lib/services/mimecast-client.test.ts index a0fcb44..748ca80 100644 --- a/lib/services/mimecast-client.test.ts +++ b/lib/services/mimecast-client.test.ts @@ -6,7 +6,7 @@ * for this phase (Phase 17, Plan 01, Task 1). */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { isMimecastConfigured, getMimecastClient, @@ -103,3 +103,44 @@ describe('getMimecastClientForTenant', () => { expect(() => getMimecastClientForTenant({ client_id: 'tid', client_secret: 'tsecret' })).not.toThrow(); }); }); + +describe('getHeldMessages date-scoping', () => { + // Fake credentials only — never real mimecast_tenants values. + const FAKE_TENANT = { client_id: 'tid', client_secret: 'tsecret', base_url: 'https://tenant.example' }; + + it('includes start/end in the POST body data[0] when provided', async () => { + const client = getMimecastClientForTenant(FAKE_TENANT); + const requestSpy = vi + .spyOn(client as any, 'request') + .mockResolvedValue({ data: [], meta: { pagination: {} } }); + + await client.getHeldMessages({ + recipient: 'r@x.test', + start: '2026-07-14T00:00:00+0000', + end: '2026-07-15T00:00:00+0000', + }); + + expect(requestSpy).toHaveBeenCalledTimes(1); + const body: any = requestSpy.mock.calls[0][2]; + expect(body.data[0]).toMatchObject({ + admin: true, + start: '2026-07-14T00:00:00+0000', + end: '2026-07-15T00:00:00+0000', + searchBy: { fieldName: 'recipient', value: 'r@x.test' }, + }); + }); + + it('omits start/end from the POST body data[0] when not provided', async () => { + const client = getMimecastClientForTenant(FAKE_TENANT); + const requestSpy = vi + .spyOn(client as any, 'request') + .mockResolvedValue({ data: [], meta: { pagination: {} } }); + + await client.getHeldMessages({ recipient: 'r@x.test' }); + + expect(requestSpy).toHaveBeenCalledTimes(1); + const body: any = requestSpy.mock.calls[0][2]; + expect(body.data[0]).not.toHaveProperty('start'); + expect(body.data[0]).not.toHaveProperty('end'); + }); +});