Compare commits
4 commits
6eafaae40a
...
3e9c7633d1
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e9c7633d1 | |||
| 534eda3c40 | |||
| 58202e0dee | |||
| f50b8a3a9b |
5 changed files with 317 additions and 10 deletions
|
|
@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-07-14)
|
|||
Phase: Milestone v3.0 complete
|
||||
Plan: —
|
||||
Status: Awaiting next milestone
|
||||
Last activity: 2026-07-21 — Completed quick task 260721-fy8: fix mimecast and qbo sync scheduler dispatch and reschedule mimecast cron
|
||||
Last activity: 2026-07-21 — Completed quick task 260721-mmf: fix Mimecast blast-radius query scope (tenant-wide fan-out instead of single-recipient)
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
|
|
@ -131,6 +131,7 @@ None yet.
|
|||
| 260718-7v8 | Fix Mimecast blast-radius false positives — date-scope `getHeldMessages()` and add a sender-domain relevance guard so unrelated held mail in a recipient's queue no longer inflates held/matched counts or overwrites a genuinely delivered recipient's status | 2026-07-18 | b7d6be4 | [260718-7v8-fix-mimecast-blast-radius-held-message-f](./quick/260718-7v8-fix-mimecast-blast-radius-held-message-f/) |
|
||||
| 260718-9qg | Add self-contained `QBO_INTEGRATION_HANDOFF.md` documenting Pulse's QuickBooks Online OAuth2 flow, token storage/refresh, sandbox/production API base URLs, and gotchas (deletion-diffing, CSRF state gap, NEXTAUTH_URL legacy var) for a new app's team | 2026-07-18 | ea8a36b | [260718-9qg-create-a-quickbooks-online-integration-h](./quick/260718-9qg-create-a-quickbooks-online-integration-h/) |
|
||||
| 260721-fy8 | Fix missing `mimecast-sync`/`qbo` scheduler dispatch branches (both silently fell through to a generic Autotask full sync) and reschedule `mimecast-sync` off the 2am 3-way cron collision with `qbo-sync-2am` and `veeam-full` | 2026-07-21 | db7db98 | [260721-fy8-fix-mimecast-and-qbo-sync-scheduler-disp](./quick/260721-fy8-fix-mimecast-and-qbo-sync-scheduler-disp/) |
|
||||
| 260721-mmf | Fix Mimecast blast-radius query scope — dropped the single-recipient `to`/`recipient` filter from `searchDeliveredMessages`/`getHeldMessages` so the fan-out returns every delivered/held message across the whole tenant for a campaign's sender+subject+date-window, not just whether it reached the original reporter's mailbox | 2026-07-21 | 534eda3 | [260721-mmf-fix-mimecast-blast-radius-scope](./quick/260721-mmf-fix-mimecast-blast-radius-scope/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
---
|
||||
phase: quick-260721-mmf
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- lib/services/mimecast-blast-radius.ts
|
||||
- lib/services/mimecast-blast-radius.test.ts
|
||||
autonomous: true
|
||||
requirements: [QUICK-MMF-01]
|
||||
must_haves:
|
||||
truths:
|
||||
- "searchDeliveredMessages is called with from+subject+start+end only — never scoped by a single recipient (to)"
|
||||
- "getHeldMessages is called with start+end only — never scoped by a single recipient"
|
||||
- "A delivered-message result set containing multiple distinct `to` addresses yields multiple distinct perRecipient entries, all counted toward matched/delivered"
|
||||
- "The domainsMatch(h.from, input.sender) post-filter still guards held rows against unrelated tenant traffic"
|
||||
- "BlastRadiusInput.recipient remains a required field and the reporter is still represented in perRecipient"
|
||||
artifacts:
|
||||
- path: "lib/services/mimecast-blast-radius.ts"
|
||||
provides: "Tenant-wide fan-out query scoped by sender+subject+date-window"
|
||||
contains: "searchDeliveredMessages"
|
||||
- path: "lib/services/mimecast-blast-radius.test.ts"
|
||||
provides: "Coverage asserting no to/recipient params and multi-recipient fan-out"
|
||||
key_links:
|
||||
- from: "lib/services/mimecast-blast-radius.ts"
|
||||
to: "mimecast-client.searchDeliveredMessages"
|
||||
via: "fan-out call without `to`"
|
||||
pattern: "searchDeliveredMessages"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Fix the Mimecast blast-radius fan-out so it reflects the true blast radius across ALL
|
||||
recipients of a reported phish, not just the single reporter's mailbox.
|
||||
|
||||
Purpose: `getBlastRadius()` currently forwards `input.recipient` into both Mimecast
|
||||
search calls (`to:` on searchDeliveredMessages, `recipient:` on getHeldMessages),
|
||||
scoping the whole fan-out to one address. The counts (matched/delivered/held) and
|
||||
`perRecipient` therefore only ever describe the reporter — dramatically understating
|
||||
blast radius. Both params are OPTIONAL on the client; dropping them lets Mimecast
|
||||
return every message matching sender+subject+date-window across the tenant.
|
||||
|
||||
Output: Corrected fan-out query in `mimecast-blast-radius.ts` plus updated/extended
|
||||
tests. No UI or type-shape changes; `recipient` stays a required input.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@lib/services/mimecast-blast-radius.ts
|
||||
@lib/services/mimecast-blast-radius.test.ts
|
||||
|
||||
<interfaces>
|
||||
<!-- Confirmed contracts from mimecast-client.ts — both scoping params are OPTIONAL. -->
|
||||
searchDeliveredMessages({ to?, from?, subject?, start?, end?, ... }): requires at least
|
||||
one of to/from/subject/senderIP/url. sender+subject+date-window satisfies this.
|
||||
getHeldMessages({ recipient?, start?, end?, ... }): recipient optional; no server-side
|
||||
sender filter — relies on caller-side domainsMatch() post-filter.
|
||||
|
||||
<!-- Call site in campaign-classifier.ts (line ~350) is UNCHANGED — it still passes
|
||||
recipient: primaryReport.requesterEmail. recipient stays a required input. -->
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Broaden the fan-out query to the whole tenant</name>
|
||||
<files>lib/services/mimecast-blast-radius.ts</files>
|
||||
<behavior>
|
||||
- searchDeliveredMessages is invoked with { from, subject, start, end } and NO `to` key.
|
||||
- getHeldMessages is invoked with { start, end } and NO `recipient` key.
|
||||
- Delivered rows carrying several distinct `to` values each become a distinct
|
||||
perRecipient entry with the correct status; matched = non-rejected delivered + relevant held.
|
||||
- domainsMatch(h.from, input.sender) still filters held rows before they count.
|
||||
- input.recipient is still added to perRecipient as 'unknown' when no row matched it.
|
||||
</behavior>
|
||||
<action>
|
||||
In `getBlastRadius()`, edit the `Promise.all` fan-out block (currently lines ~142-152).
|
||||
Remove `to: input.recipient` from the `client.searchDeliveredMessages({...})` argument
|
||||
so it passes only `from: input.sender`, `subject: input.subject`, `start: startStr`,
|
||||
`end: endStr`. Remove `recipient: input.recipient` from the `client.getHeldMessages({...})`
|
||||
argument so it passes only `start: startStr`, `end: endStr`. Leave everything else intact:
|
||||
the `deliveredResult.error` guard, the `relevantHeldRows` domainsMatch post-filter (now
|
||||
the sole scoping mechanism for held rows), the per-recipient merge Map, and the
|
||||
`if (!perRecipientMap.has(input.recipient))` fallback that keeps the reporter represented.
|
||||
Do NOT change `BlastRadiusInput` — `recipient` stays required per T-17-01 and is still
|
||||
used to label the reporter in the merge. Update the inline comment on the searchDeliveredMessages
|
||||
call (and the "Per-recipient merge" comment block) to note the query is now tenant-wide
|
||||
across all recipients, scoped by sender+subject+date-window. Confirm the merge logic makes
|
||||
no single-recipient assumption — the Map already keys on `row.to`, so multiple distinct
|
||||
`to` values produce multiple entries with no drop/overwrite bug (held-over-delivered
|
||||
override per same recipient is intended and stays).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty 2>&1 | grep -i "mimecast-blast-radius" || echo "no type errors in target file"</automated>
|
||||
</verify>
|
||||
<done>searchDeliveredMessages called without `to`; getHeldMessages called without `recipient`; type-check clean; merge logic unchanged and multi-recipient-safe.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Update tests for the broadened fan-out</name>
|
||||
<files>lib/services/mimecast-blast-radius.test.ts</files>
|
||||
<behavior>
|
||||
- The existing "calls getHeldMessages with the SAME start/end window" test asserts
|
||||
getHeldMessages receives { start, end } with NO recipient key, and asserts
|
||||
searchDeliveredMessages receives NO `to` key.
|
||||
- A new test drives searchDeliveredMessages with 3+ distinct `to` addresses and asserts
|
||||
all appear in perRecipient and count toward matched/delivered.
|
||||
</behavior>
|
||||
<action>
|
||||
In `mimecast-blast-radius.test.ts`, update the test titled "calls getHeldMessages with
|
||||
the SAME start/end window passed to searchDeliveredMessages" (lines ~287-301): change the
|
||||
getHeldMessages assertion from `toHaveBeenCalledWith({ recipient: ..., start, end })` to
|
||||
assert it is called WITHOUT recipient — use `expect(getHeldMessagesMock).toHaveBeenCalledWith({ start: expectedStart, end: expectedEnd })`
|
||||
(exact object, no recipient). For searchDeliveredMessages, add an assertion that the call
|
||||
argument does NOT contain `to` — e.g. capture `searchDeliveredMessagesMock.mock.calls[0][0]`
|
||||
and `expect(arg).not.toHaveProperty('to')`, while keeping the existing start/end/from/subject
|
||||
objectContaining check. Then add a NEW test, e.g. "returns every distinct recipient from a
|
||||
multi-recipient delivered result (true blast radius)": mock searchDeliveredMessages with three
|
||||
delivered rows sharing BASE_INPUT.sender/subject but distinct `to` addresses
|
||||
(reporter@..., coworker-a@..., coworker-b@wulfconsulting.test), all status 'Delivered'; assert
|
||||
the result has delivered === 3, matched === 3, and perRecipient (via arrayContaining) includes
|
||||
all three with status 'delivered'. Reuse the fixture shape from the existing merge test.
|
||||
Leave the other tests (unavailable, cache hit, injected client, Bug 1 guard, unrelated-sender
|
||||
held filter) unchanged.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm test -- mimecast-blast-radius</automated>
|
||||
</verify>
|
||||
<done>All getBlastRadius tests pass, including the updated no-recipient/no-to assertions and the new multi-recipient fan-out test.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Pulse → Mimecast tenant API | Search query params determine which tenant messages are returned |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-17-01 | Information Disclosure | getBlastRadius fan-out | mitigate | Query still requires sender+subject+date-window (never bare date-range). Dropping `to`/`recipient` broadens scope within one campaign's sender+subject, not to unrelated traffic. `recipient` stays a required input to preserve the validation contract. |
|
||||
| T-17-02 | Information Disclosure | error logging | accept | Existing catch logs `err.message` only, never response bodies — unchanged by this fix. |
|
||||
| quick-mmf-SC | Tampering | npm/pip installs | accept | No new dependencies added. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm test -- mimecast-blast-radius` passes.
|
||||
- `npx tsc --noEmit --pretty` reports no new errors.
|
||||
- `grep -n "to: input.recipient\|recipient: input.recipient" lib/services/mimecast-blast-radius.ts` returns nothing (both scoping params removed from API calls).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- searchDeliveredMessages and getHeldMessages are no longer scoped to a single recipient.
|
||||
- A multi-recipient delivered result produces multiple perRecipient entries and correct counts.
|
||||
- No changes to BlastRadiusInput, campaign-classifier, or evidence-card.tsx.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260721-mmf-fix-mimecast-blast-radius-scope/260721-mmf-SUMMARY.md` when done.
|
||||
</output>
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
phase: quick-260721-mmf
|
||||
plan: 01
|
||||
subsystem: mimecast-blast-radius
|
||||
tags: [mimecast, phishing, blast-radius, bugfix]
|
||||
key-decisions:
|
||||
- Dropped `to`/`recipient` from both fan-out calls entirely rather than making them optional-but-still-passed — Mimecast's client already treats both params as optional, so omitting them is the minimal change that broadens scope without touching BlastRadiusInput or the client contract
|
||||
- Kept domainsMatch(h.from, input.sender) as the sole scoping mechanism for held rows (T-17-01) since getHeldMessages has no server-side sender filter — unchanged from the prior fix in 260718-7v8
|
||||
- recipient stays a required BlastRadiusInput field and is still forced into perRecipientMap as 'unknown' when no row matches it, preserving the reporter-always-represented guarantee
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick Task 260721-mmf: Fix Mimecast blast-radius query scope
|
||||
|
||||
**One-liner:** Removed the accidental single-recipient scoping (`to`/`recipient` params) from both Mimecast fan-out calls in `getBlastRadius()`, so blast-radius counts and `perRecipient` now reflect every recipient of a reported phishing campaign across the tenant, not just the original reporter's mailbox.
|
||||
|
||||
## What was done
|
||||
|
||||
### Task 1 — Broaden the fan-out query (`lib/services/mimecast-blast-radius.ts`, commit `58202e0`)
|
||||
- Removed `to: input.recipient` from the `client.searchDeliveredMessages({...})` call — now passes only `from: input.sender`, `subject: input.subject`, `start: startStr`, `end: endStr`.
|
||||
- Removed `recipient: input.recipient` from the `client.getHeldMessages({...})` call — now passes only `start: startStr`, `end: endStr`.
|
||||
- Left the `deliveredResult.error` guard, the `relevantHeldRows` domainsMatch post-filter, the per-recipient merge `Map`, and the `perRecipientMap.has(input.recipient)` reporter-fallback all unchanged — the Map already keyed on `row.to`, so no merge-logic change was needed for multi-recipient correctness.
|
||||
- Updated the inline comments above the fan-out `Promise.all` and above the per-recipient merge block to document that the query is now tenant-wide, scoped only by sender+subject+date-window, and that the Map naturally produces one entry per distinct recipient.
|
||||
- `BlastRadiusInput` was not touched — `recipient` remains required (T-17-01) and is still used to label the reporter in the merge.
|
||||
|
||||
### Task 2 — Update tests for the broadened fan-out (`lib/services/mimecast-blast-radius.test.ts`, commit `534eda3`)
|
||||
- Updated the "calls getHeldMessages with the SAME start/end window..." test: `getHeldMessages` assertion changed from `{ recipient, start, end }` to an exact `{ start, end }` object (no `recipient` key); added a new assertion that the `searchDeliveredMessages` call argument does not have a `to` property.
|
||||
- Added a new test, "returns every distinct recipient from a multi-recipient delivered result (true blast radius)": mocks `searchDeliveredMessages` with three delivered rows sharing the same sender/subject but three distinct `to` addresses (reporter, coworker-a, coworker-b), and asserts `delivered === 3`, `matched === 3`, and all three recipients appear in `perRecipient` with status `'delivered'`.
|
||||
- All other existing tests (unavailable, cache hit, injected tenant client, Bug 1 defense-in-depth, unrelated-sender held filter, same-domain held override) left unchanged and still pass.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx vitest run lib/services/mimecast-blast-radius.test.ts` — 13/13 tests passed.
|
||||
- `npx tsc --noEmit --pretty` — clean, no errors anywhere in the repo.
|
||||
- `grep -n "to: input.recipient\|recipient: input.recipient" lib/services/mimecast-blast-radius.ts` — no matches (both scoping params fully removed from the API calls).
|
||||
|
||||
## Deviations from plan
|
||||
|
||||
None. Both tasks executed exactly as planned.
|
||||
|
||||
## Environment note (execution anomaly, not a code deviation)
|
||||
|
||||
This quick task's executor session was dispatched expecting an isolated git worktree (`/opt/stacks/pulse/.claude/worktrees/agent-a136e97a6eadf5305`), matching the standard `worktree_branch_check` protocol. During setup it was discovered that no such worktree actually existed on disk or in git's internal worktree registry (`git worktree list`) — the executor was, in practice, operating directly against the main checkout at `/opt/stacks/pulse` on `master`. The coordinator confirmed this was the intended non-worktree-isolation fallback path for this quick task (equivalent to `workflow.use_worktrees=false`) and explicitly approved committing directly to `master`, consistent with the plain sequential-commit pattern already used for quick task `260721-fy8` immediately prior in this same repo. No work was lost: the one `git reset --hard` run during verification was a no-op (master was already at the expected pre-dispatch-plan commit `f50b8a3a`, confirmed via `git reflog`), and the working tree was clean throughout. Both task commits (`58202e0`, `534eda3`) landed directly on `master` as a result.
|
||||
|
||||
## Self-Check
|
||||
|
||||
- FOUND: lib/services/mimecast-blast-radius.ts (modified, commit 58202e0)
|
||||
- FOUND: lib/services/mimecast-blast-radius.test.ts (modified, commit 534eda3)
|
||||
- FOUND commit 58202e0 in git log
|
||||
- FOUND commit 534eda3 in git log
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
|
@ -293,13 +293,85 @@ describe('getBlastRadius', () => {
|
|||
expect(searchDeliveredMessagesMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ start: expectedStart, end: expectedEnd })
|
||||
);
|
||||
const deliveredArg = searchDeliveredMessagesMock.mock.calls[0][0];
|
||||
expect(deliveredArg).not.toHaveProperty('to');
|
||||
|
||||
expect(getHeldMessagesMock).toHaveBeenCalledWith({
|
||||
recipient: BASE_INPUT.recipient,
|
||||
start: expectedStart,
|
||||
end: expectedEnd,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns every distinct recipient from a multi-recipient delivered result (true blast radius)', 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: '',
|
||||
},
|
||||
{
|
||||
id: 'd2',
|
||||
status: 'Delivered',
|
||||
subject: BASE_INPUT.subject,
|
||||
from: BASE_INPUT.sender,
|
||||
fromEnv: BASE_INPUT.sender,
|
||||
to: 'coworker-a@wulfconsulting.test',
|
||||
toDisplay: 'Coworker A',
|
||||
received: '2026-07-14T12:05:00+0000',
|
||||
senderIP: '1.2.3.4',
|
||||
spamScore: 0,
|
||||
detectionLevel: 'none',
|
||||
attachments: false,
|
||||
route: 'inbound',
|
||||
info: '',
|
||||
},
|
||||
{
|
||||
id: 'd3',
|
||||
status: 'Delivered',
|
||||
subject: BASE_INPUT.subject,
|
||||
from: BASE_INPUT.sender,
|
||||
fromEnv: BASE_INPUT.sender,
|
||||
to: 'coworker-b@wulfconsulting.test',
|
||||
toDisplay: 'Coworker B',
|
||||
received: '2026-07-14T12:10:00+0000',
|
||||
senderIP: '1.2.3.4',
|
||||
spamScore: 0,
|
||||
detectionLevel: 'none',
|
||||
attachments: false,
|
||||
route: 'inbound',
|
||||
info: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getBlastRadius(BASE_INPUT);
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
if (result.status === 'ok') {
|
||||
expect(result.delivered).toBe(3);
|
||||
expect(result.matched).toBe(3);
|
||||
expect(result.perRecipient).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ recipient: BASE_INPUT.recipient, status: 'delivered' },
|
||||
{ recipient: 'coworker-a@wulfconsulting.test', status: 'delivered' },
|
||||
{ recipient: 'coworker-b@wulfconsulting.test', status: 'delivered' },
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('excludes an in-window held row from an UNRELATED sender domain from held/matched and perRecipient', async () => {
|
||||
searchDeliveredMessagesMock.mockResolvedValue({
|
||||
messages: [
|
||||
|
|
|
|||
|
|
@ -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<string, 'delivered' | 'held' | 'rejected' | 'unknown'>();
|
||||
for (const row of nonRejectedDeliveredRows) {
|
||||
if (row.to) perRecipientMap.set(row.to, 'delivered');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue