- requirePermission('phishing','read') gate (ACCESS-01)
- UUID-validated id (400 on malformed), 404 when campaign absent
- bulk-fetch reports/messages/indicators via ANY($1::uuid[]) keyed by parent id array (device-link-conflicts pattern)
- requesterEmail derived via reports.requester_contact_id -> contacts join (campaigns has no recipients column)
- messages.subject pulled from headers->>'subject' JSONB (no subject column)
- classifications included in shape (Phase 19 stub, expected empty)
- webhook-service.ts: triggerPhishingDetection calls groupReportIntoCampaign
with skipIfAlreadyGrouped:true after a flagged detection (D-01, D-08)
- phishing-sweep-service.ts: per-ticket sweep loop calls the same, inside the
existing try/catch so a grouping failure counts against result.errors
without aborting the sweep
- statement gets the full D-05 vocabulary now: read/analyze/approve/remediate
- superAdminRole and adminRole grant read+analyze
- userRole grants read only (cannot trigger /analyze)
- approve/remediate declared but ungranted to any role until Phase 20
- Tiered find-or-create inside postgresClient.transaction (Pitfall 2 —
campaigns.campaign_key has no UNIQUE constraint): Tier 1 Message-ID,
Tier 2 attachment-hash/URL-domain + subject + sender + 24h, Tier 3
sender + normalized subject + client + 24h (CAMP-01)
- Match path bumps report_count/last_seen_at and links reports.campaign_id
without creating a second campaign; no-match path inserts a new
campaigns row keyed by the strongest available tier signal (CAMP-02)
- skipIfAlreadyGrouped short-circuits before the transaction (D-08); the
/analyze route path always re-runs full tiered matching
- Every tier query excludes the report's own id (r.id != $n) so a
self-match against a report's own messages/indicators can never
double-increment its already-linked campaign on re-run
- D-07 doc comment states the Tier-3-only automatic-path limitation:
parseAndStoreMessage is not wired into the webhook/cron path this phase
Mark BLAST-01/BLAST-02 complete in REQUIREMENTS.md and add the plan's
SUMMARY.md documenting getBlastRadius() delivery.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Add getBlastRadius(): never-throwing orchestration that fans out to
searchDeliveredMessages + getHeldMessages + getThreatEvents (D-01,
unconditional fan-out) and merges into normalized matched/delivered/
held/rejected/clicked counts + perRecipient status array
- Config gate (BLAST-02): returns status:'unavailable' reason:'not_configured'
synchronously when Mimecast is unconfigured, never constructs the client
- Redis-backed 5-min cache (D-04) via redis-client.ts, short-circuits before
any MimecastClient call on hit
- clicked derived best-effort from getThreatEvents() analysis[] (D-02);
documents the /api/ttp/url/get-logs limitation in code
- Documents D-05 known limitation: single global getMimecastClient() only,
not per-company mimecast_tenants
- Unrecognized delivered-message status strings treated conservatively as
non-rejected (A3 unconfirmed enum), raw values logged at debug level
- Add lib/services/mimecast-blast-radius.test.ts covering config gate,
cache-hit short-circuit, fan-out merge, never-throw-on-error, and
unknown-recipient classification
- Log pre-existing unrelated itglue-search.test.ts failures to
deferred-items.md (out of scope for this plan)
- Add isMimecastConfigured() to lib/services/mimecast-client.ts mirroring
the pax8-factory.ts is<Name>Configured() convention
- Add _resetMimecastClient() test seam so tests can isolate env-var state
- Add lib/services/mimecast-client.test.ts covering config gate + throw/cache
behavior of getMimecastClient()
- mocks postgresClient, getAutotaskClient, and B2 client (isB2Configured/presignUpload); no real Postgres/Autotask/B2 credentials or network
- happy path: one messages insert with D-06 auth verdicts in headers, indicators inserted
- no-eml no-op: NO_EML_ATTACHMENTS -> stored:false, no messages insert
- B2 unconfigured: raw_ref null, no presignUpload/PUT
- B2 configured: raw_ref = phishing/{reportId}/{attachmentId}.eml, exactly one PUT
- no-network invariant: fetch never called with a message-body URL from the fixture
- attachment_hash indicator metadata carries filename/contentType/size/related
- all fixtures reused from eml-parser.fixtures.ts (synthetic only)