---
phase: quick-260717-a19
plan: 01
subsystem: phishing
tags: [phishing, classifier, webhook, eml-parser, allowlist]
key-decisions:
  - Added 3 confirmed KnowBe4 lookalike domains (customer-portal.info, cloud-service-care.com, bankonlinesupport.com) to the existing knowbe4 vendor entry rather than creating a new vendor or a URL-pattern-based detector — confirmed via identical /render-template/?csu=X&status_id=n + /tracking/?csu=X&status_id=a URL fingerprint shared with the already-allowlisted it-support.care
  - parseAndStoreMessage idempotency guard implemented as a leading existence check (SELECT id FROM messages WHERE report_id) rather than an ON CONFLICT — the guard is also the fix, no schema change needed
  - Auto-parse retry reuses existing ticket.update webhook traffic rather than adding a new cron/polling mechanism — bounded to one parseAndStoreMessage attempt per update event, gated on auto_parse and short-circuited by the idempotency guard once evidence exists
key-files:
  created:
    - scripts/reclassify-seubert-campaigns.ts (one-off ops script, not committed as permanent app code)
  modified:
    - lib/services/campaign-classifier.ts (KNOWN_SIMULATION_SENDERS knowbe4 domains expanded)
    - lib/services/campaign-classifier.test.ts (allowlist coverage for 3 new domains + subdomain + suffix-spoof rejection)
    - lib/services/phishing-eml-service.ts (idempotency existence guard in parseAndStoreMessage)
    - lib/services/phishing-eml-service.test.ts (mock adjusted for new leading query; new already-parsed test)
    - lib/services/webhook-service.ts (retryPhishingParseOnUpdate wired into ticket.update fire-and-forget path)
---

# Quick Task 260717-a19: Fix phishing simulation-vendor allowlist gaps, auto-parse timing race, and parseAndStoreMessage idempotency

**One-liner:** Closed three verified defects in the phishing-triage pipeline (missing KnowBe4 allowlist domains, a silent auto-parse timing race with no retry, and a non-idempotent parse function) discovered while investigating Seubert ticket 699456, then reclassified the 6 campaigns those defects had misclassified — all 6 flipped from UNWANTED to USER_AWARENESS.

## What was done

### Task 1 — Simulation-vendor allowlist gap (Defect 1)
`KNOWN_SIMULATION_SENDERS`'s `knowbe4` vendor entry in `lib/services/campaign-classifier.ts` was extended from `['it-support.care']` to include `customer-portal.info`, `cloud-service-care.com`, and `bankonlinesupport.com`. Confirmed via direct DB inspection: all 8 Seubert phishing reports from the 2026-07-16/17 burst shared an identical URL fingerprint (`/render-template/?csu={token}&status_id=n` + `/tracking/?csu={token}&status_id=a`), differing only by sender domain and token — a platform fingerprint for KnowBe4's rotating lookalike-domain simulation templates. `domainMatchesAllowlist()` (exact-or-subdomain matching) was left untouched — this was a pure data update. Added test coverage for exact match, subdomain match, and suffix-spoof rejection (`customer-portal.info.attacker.net` still correctly fails) on all 3 new domains.

### Task 2 — Idempotency guard (Defect 3) + retry-on-update (Defect 2)
`parseAndStoreMessage()` in `lib/services/phishing-eml-service.ts` now does a leading `SELECT id FROM messages WHERE report_id = $1 LIMIT 1` before any Autotask attachment fetch; if a row already exists it returns `{ stored: false, reason: 'already-parsed' }` immediately, matching the function's existing no-op return shape. This closes the duplicate-`messages`-row risk and is the safety prerequisite for Defect 2.

`webhook-service.ts` gained a new private method `retryPhishingParseOnUpdate()`, wired into the `ticket.update` fire-and-forget path: for a ticket that already has a `reports` row, has no linked `messages` row yet, and whose company has `auto_parse` gated on, it calls `parseAndStoreMessage` once. This closes the timing race where the original `ticket.created` webhook fires `parseAndStoreMessage` within ~1-2 seconds of ticket creation — before Autotask's attachment is reliably available — with no prior retry path. Ticket update events (confirmed via logs to arrive reliably within seconds-to-minutes of every ticket creation) now serve as the natural retry, made safe by the Defect 3 idempotency guard. No new cron/polling infrastructure was added. Detection, grouping, classify, and report stages are unchanged on update events — only the missing-EML retry-parse was added.

Test mock adjustment: `phishing-eml-service.test.ts`'s `beforeEach` default (`queryMock.mockResolvedValue({ rows: [{ id: 'message-uuid-1' }] })`) would have made the new leading existence-check short-circuit every pre-existing happy-path test as `already-parsed`. Fixed by branching the mock so `FROM messages WHERE report_id` queries return `{ rows: [] }` by default while `INSERT ... RETURNING` still resolves normally; added one new test asserting the already-parsed short-circuit (no `getAttachments`/`getAttachmentContent`/`INSERT` calls).

### Task 3 — Reclassify the 6 affected Seubert campaigns
Created `scripts/reclassify-seubert-campaigns.ts` (one-off ops script per repo convention, not permanent app code) that looks up each affected ticket's `campaign_id` live (no hardcoded UUIDs), reads the prior verdict, calls `classifyCampaign()` (D-02 append-only — appends a new `classifications` row, never overwrites), and prints a before → after report. Ran live against Postgres:

| Ticket | Campaign | Before | After | Confidence |
|--------|----------|--------|-------|------------|
| 699419 | a74edbea-4c41-4197-b431-d2c037ae07f1 | UNWANTED | USER_AWARENESS | 1 |
| 699421 | 1d1d778d-cf36-4bc4-bbe8-e11d23c3c190 | UNWANTED | USER_AWARENESS | 1 |
| 699422 | ab6f095d-4199-460d-bded-86d3dfc4bbfe | UNWANTED | USER_AWARENESS | 1 |
| 699433 | db03aaa2-b2a1-4959-b411-608381cd5f8f | UNWANTED | USER_AWARENESS | 1 |
| 699435 | 6a8fdb5c-aee9-4262-b695-7378a22bda83 | UNWANTED | USER_AWARENESS | 1 |
| 699456 | 5cdb31a3-64c9-4237-8edc-7e317f4f614c | UNWANTED | USER_AWARENESS | 1 |

All 6 flipped to USER_AWARENESS. Each ticket mapped to a distinct campaign_id (no dedup collisions in practice, though the script's dedup logic handles the shared-campaign case).

## Verification

- `npx vitest run lib/services/campaign-classifier.test.ts lib/services/phishing-eml-service.test.ts` — 53/53 passing (post-merge re-run by orchestrator)
- `npx tsc --noEmit --pretty` — clean (post-merge re-run by orchestrator)
- Live reclassification run confirmed all 6 target campaigns now correctly read USER_AWARENESS

## Deviations from plan

- The dispatched worktree's branch was initially stale (missing the source files this task needed to modify, per the executor's own report) — corrected via the plan's own worktree-branch-check protocol (hard-reset to the orchestrator-supplied base commit) before starting Task 1. No work was lost; this ran before any task commits.
- **Orchestrator note (not an executor deviation):** this SUMMARY.md file itself was lost when the orchestrator removed the git worktree without first rescuing the uncommitted file (the standard cleanup script's rescue-before-remove step was skipped during a manual cleanup). Reconstructed after the fact from the executor's final report and the actual `git show` output of its three commits — content should be accurate but is not verbatim the original.
