# Phase 18: Campaign Grouping & Phishing Analysis API - Context

**Gathered:** 2026-07-15
**Status:** Ready for planning

<domain>
## Phase Boundary

Duplicate reports of the same phishing/spam campaign are automatically
grouped into a shared `campaigns` row (tiered key: Message-ID, then
attachment-hash/URL-domain + subject + sender + time-window, then sender +
normalized subject + client + time-window), accumulating linked reports and
recipients over time. An operator can trigger on-demand analysis of one
specific ticket (`POST /api/phishing/tickets/{ticket_id}/analyze`) or browse
campaigns (`GET /api/phishing/campaigns`, `GET /api/phishing/campaigns/{id}`)
through a properly access-controlled `/api/phishing/*` surface — the first
API routes in this milestone, establishing the auth convention every later
phishing endpoint (Phases 19-21) must also follow.

</domain>

<decisions>
## Implementation Decisions

### Grouping Trigger Point
- **D-01:** Campaign grouping runs automatically as part of the same
  detection path Phase 15's `phishing-detector.ts` already uses (webhook
  fire-and-forget `ticket.created` handler + the scheduled cron sweep) — the
  moment a `reports` row is created or updated, grouping runs against it so a
  campaign forms/accumulates without waiting for anyone to call the API. The
  new `POST /api/phishing/tickets/{ticket_id}/analyze` endpoint (DETECT-03)
  calls the SAME shared grouping function on-demand for one ticket — not a
  separate/duplicate implementation. Rationale: success criterion #2
  ("accumulates... as new duplicate reports arrive over time") implies
  grouping must happen automatically, not only when explicitly requested.

### Grouping Parameters
- **D-02:** Time window for the fallback tiers (attachment-hash/URL-domain +
  subject + sender, and sender + normalized-subject + client) is **24
  hours**. Rationale: catches same-day mass-phishing blasts (the realistic
  case — one attacker campaign, multiple people reporting the same day)
  without over-grouping unrelated reports that happen to share sender+subject
  weeks apart.
- **D-03:** Subject normalization for the fallback tiers: strip leading
  `Re:`/`Fwd:`/`Fw:` prefixes (case-insensitive, repeated occurrences),
  lowercase, trim whitespace. Standard email-threading normalization, nothing
  more elaborate.

### Campaign Merge Behavior
- **D-04:** Campaigns are never merged into each other. Each new report
  attaches to at most one existing campaign (the single best/first match by
  the tiered key) or creates a new one. If a report's tiered key
  theoretically matches two distinct existing open campaigns, this is
  treated as a rare edge case — do NOT implement multi-campaign-merge logic
  (transactional reassignment of reports/messages/indicators/classifications
  across campaigns) in this phase. Attach to the first/best match found and
  move on; revisit only if this proves to be a real, recurring problem in
  practice.

### Permission Model
- **D-05:** Add a new `phishing` resource to `lib/permissions.ts`'s
  `statement` with the FULL action set from the milestone spec now —
  `phishing: ["read", "analyze", "approve", "remediate"]` — but this phase
  only GRANTS `read` and `analyze` in role definitions:
  - `superAdminRole` / `adminRole`: `phishing: ["read", "analyze"]`
  - `userRole`: `phishing: ["read"]` (can browse campaigns, cannot trigger
    on-demand analysis)
  `approve`/`remediate` are declared in the statement (so the vocabulary
  exists) but ungranted to any role until Phase 20 wires them up. Rationale:
  establishes the full permission vocabulary once so Phase 20 only adds role
  grants, not new statement keys — avoids touching the same statement block
  twice across phases.

### Route Auth Granularity
- **D-06:** Every `/api/phishing/*` route uses the fine-grained
  `requirePermission()` check from day one, not plain `requireAuth()`:
  - `GET /api/phishing/campaigns` and `GET /api/phishing/campaigns/{id}` →
    `requirePermission('phishing', 'read')`
  - `POST /api/phishing/tickets/{ticket_id}/analyze` →
    `requirePermission('phishing', 'analyze')`
  This establishes the exact auth convention (per-route, per-action
  permission check) that Phase 19-21's endpoints must copy. Since `userRole`
  gets `read` granted per D-05, this doesn't restrict any current user from
  browsing — it wires the fine-grained check through now rather than
  retrofitting it later.

### Automatic-Path EML Parsing Scope
- **D-07 (added post-research):** Research found that `parseAndStoreMessage()`
  (Phase 16's `.eml` parser — the only writer of `messages`/`indicators` rows,
  i.e. the only source of Message-ID and attachment-hash/URL data) has zero
  callers anywhere in the codebase; Phase 16's own plan doc explicitly
  earmarked this phase's `/analyze` endpoint as its first real caller.
  Decision: do **NOT** wire `parseAndStoreMessage()` into the automatic
  webhook/cron path in this phase. The automatic path (webhook `ticket.created`
  + cron sweep) calls `groupReportIntoCampaign()` using only whatever
  `reports`/`contacts` data already exists — which means Tier 1 (Message-ID)
  and Tier 2 (attachment-hash/URL-domain) can only ever match for a ticket
  that has already been through an explicit `POST /analyze` call at least
  once; the automatic path effectively only reaches **Tier 3**
  (sender+normalized-subject+client+time-window) until then. This must be
  documented explicitly in code comments (not silently built around) since it
  changes what "grouped automatically" means in practice versus D-01's
  original framing. Rationale: matches Phase 16's explicit design intent and
  keeps the automatic webhook/cron path cheap — no extra Autotask
  attachment-content fetch + optional B2 upload firing on every single
  `ticket.created` event, most of which aren't reviewed by anyone.
- **D-08 (added post-research):** `groupReportIntoCampaign()` short-circuits
  immediately (does nothing) if `reports.campaign_id IS NOT NULL` when called
  from the automatic webhook/cron path — avoids redundant tier-query work on
  every 7-day cron sweep pass for reports already grouped. The `/analyze`
  route calls the function WITHOUT this short-circuit (always re-runs full
  tiered matching) — an explicit operator request may follow a fresh
  `/analyze` call that just populated `messages`/`indicators` for the first
  time, and deserves the chance to upgrade an existing Tier-3-only grouping
  to a Tier-1 match if a shared Message-ID is now available.

### Claude's Discretion (explicitly deferred to research + planner)
- **Exact SQL/query shape for `GET /api/phishing/campaigns/{id}`'s nested
  response** (linked reports, messages, indicators, classification history)
  — single JOIN-heavy query vs. multiple queries assembled in application
  code. Follow whatever the existing `entity-sync.ts` / DetailModal-backing
  API routes in this codebase already do for similarly-shaped nested detail
  responses.
- **Exact shape of the tiered-key matching queries** (how "attachment-hash/
  URL-domain" is queried against the `indicators` table's `indicator_type`
  values `'attachment_hash'`/`'url'`/`'sender'` from Phase 16, how URL-domain
  is extracted from a full URL string) — implementation detail, resolve via
  research against the actual `indicators` schema and data shapes Phase 16
  produces.
- **Response body shape for `POST /analyze`** — should return the resulting
  campaign linkage (campaign ID, grouping method used, whether a new
  campaign was created vs. an existing one was matched) — exact field names
  are planner's call, following the project's camelCase API response
  convention.
- **Where the shared grouping function lives** (new file e.g.
  `lib/services/campaign-grouping-service.ts` vs. extending
  `phishing-detector.ts`) — planner's call, following whatever composition
  pattern is cleanest given `phishing-detector.ts`'s current size and
  responsibilities.
- **Whether `campaigns.status` transitions in this phase** (e.g., does
  grouping ever set/change `status` beyond the migration's default `'open'`)
  — not mentioned in success criteria; likely out of scope for this phase
  (status transitions are Phase 19/20's classification/remediation concern),
  but confirm during planning.

</decisions>

<canonical_refs>
## Canonical References

**Downstream agents MUST read these before planning or implementing.**

### Phase 15 detection path (grouping hooks into this)
- `lib/services/phishing-detector.ts` — `detectPhishingTicket()`,
  `gatherTicketEvidence()`, the `content_hash` idempotency pattern, and the
  `reports` upsert (`ON CONFLICT (ticket_id) DO UPDATE`) this phase's
  grouping call attaches to.
- `lib/services/webhook-service.ts` — the `ticket.created` fire-and-forget
  hook where Phase 15 wired in detection; this phase's grouping call should
  follow the same fire-and-forget wiring point (per D-01).
- `lib/services/sync-scheduler.ts` — the cron sweep pattern (`pax8-daily`-
  style schedule row) Phase 15 added for reconciliation; grouping runs on
  the same sweep per D-01.

### Phase 16 evidence (grouping keys off this)
- `lib/services/phishing-eml-service.ts` — writes `indicators` rows with
  `indicator_type` values `'attachment_hash'`, `'url'`, `'sender'` (confirmed
  by reading the file directly) — the CAMP-01 fallback-tier keys come from
  these rows.
- `lib/services/eml-parser.ts` — `NormalizedMessage`'s `Message-ID` field
  (via `parseEml`) — the CAMP-01 primary-tier key.

### Auth/permissions (this phase establishes the phishing resource here)
- `lib/permissions.ts` — `statement`, `superAdminRole`, `adminRole`,
  `userRole` — add the new `phishing` resource here (D-05). Existing
  `rmm: ["read", "execute"]` resource is the closest analog for a
  read/action-verb permission shape.
- `lib/auth-utils.ts` — `requirePermission(resource, action)` signature this
  phase's routes call (D-06). See `app/api/admin/rmm/settings/route.ts` for
  the exact call-and-early-return pattern:
  `const { error } = await requirePermission('rmm', 'read'); if (error) return error;`

### Schema (read/write for this phase)
- `migrations/097_phishing_triage_schema.sql` — `campaigns` table
  (`campaign_key`, `group_method`, `first_seen_at`, `last_seen_at`,
  `report_count`, `status`), `reports.campaign_id` (nullable FK, populated
  starting this phase per the migration's own comment).

### Prior phase decisions (for consistency)
- `.planning/phases/15-data-model-detection-ticket-evidence/15-CONTEXT.md`
  (D-01: webhook + cron "primary path, cron reconciles" pattern; D-04:
  content-hash idempotency) — this phase's grouping trigger follows the same
  dual-path wiring instinct.
- `.planning/phases/17-mimecast-blast-radius-lookup/17-CONTEXT.md` (D-05:
  single-tenant-for-v1, documented-limitation-over-silent-gap pattern) —
  shows the project's general preference for "simple now, documented
  limitation, defer complexity" when a rare edge case (like campaign
  merging) would add substantial implementation cost.

</canonical_refs>

<code_context>
## Existing Code Insights

### Reusable Assets
- `phishing-detector.ts`'s `computePhishingContentHash()` — precedent for a
  small pure hashing/normalization helper; the new subject-normalization
  function (D-03) should follow the same style.
- `requirePermission()` from `lib/auth-utils.ts` — used as-is, no changes
  needed to the helper itself, only new resource/action strings.

### Established Patterns
- Factory/service convention: pure functions in `lib/services/`, called from
  both a webhook handler and a cron-scheduled sync route — same shape
  `phishing-detector.ts` already established, this phase extends it rather
  than inventing a new wiring pattern.
- `ON CONFLICT ... DO UPDATE` upsert pattern (`reports` table) — the
  campaign accumulation logic (D-01, CAMP-02) likely needs a similar
  upsert-or-create-then-link pattern against `campaigns`.

### Integration Points
- No `/api/phishing/*` routes exist yet in this repo — this phase creates
  the first ones. No existing route to pattern-match against inside this
  specific path; use `app/api/admin/rmm/settings/route.ts` (or similar
  `requirePermission`-gated routes) as the general Next.js route + auth
  pattern instead.

</code_context>

<specifics>
## Specific Ideas

No specific UI/output-format requirements — this is a backend abstraction +
API surface with no UI in this phase (`UI hint: no` per ROADMAP.md).

</specifics>

<deferred>
## Deferred Ideas

- **Multi-campaign merge logic** — deferred per D-04. If a report's tiered
  key ever legitimately matches two distinct existing campaigns in practice
  (not just in theory), building proper merge/reassignment logic is future
  work, not blocking this phase.
- **`campaigns.status` transition logic** (open → closed/resolved etc.) —
  out of scope for this phase; belongs to Phase 19 (classification) or
  Phase 20 (remediation).
- **approve/remediate permission grants** — the `phishing` resource's
  `approve`/`remediate` actions are declared in the statement (D-05) but not
  granted to any role until Phase 20.

None — discussion otherwise stayed within phase scope.

</deferred>

---

*Phase: 18-campaign-grouping-phishing-analysis-api*
*Context gathered: 2026-07-15*
