---
phase: quick-260717-a19
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - lib/services/campaign-classifier.ts
  - lib/services/campaign-classifier.test.ts
  - lib/services/phishing-eml-service.ts
  - lib/services/phishing-eml-service.test.ts
  - lib/services/webhook-service.ts
  - scripts/reclassify-seubert-campaigns.ts
autonomous: true
requirements: [PHISH-FIX-ALLOWLIST, PHISH-FIX-PARSE-RETRY, PHISH-FIX-IDEMPOTENCY, PHISH-RECLASSIFY]

must_haves:
  truths:
    - "The 3 confirmed KnowBe4 lookalike domains classify as USER_AWARENESS, not UNWANTED"
    - "parseAndStoreMessage returns early without a second messages row when one already exists for the report"
    - "A ticket.update webhook re-attempts parsing when a flagged report still has no messages row and the company's auto_parse gate is on"
    - "The 6 stale Seubert campaigns are reclassified against the corrected allowlist with before/after verdicts reported"
  artifacts:
    - path: "lib/services/campaign-classifier.ts"
      provides: "Expanded knowbe4 domain allowlist"
      contains: "customer-portal.info"
    - path: "lib/services/phishing-eml-service.ts"
      provides: "Idempotency existence guard in parseAndStoreMessage"
      contains: "already-parsed"
    - path: "lib/services/webhook-service.ts"
      provides: "Retry-parse on ticket.update for unparsed flagged reports"
    - path: "scripts/reclassify-seubert-campaigns.ts"
      provides: "One-off reclassification script for the 6 affected campaigns"
  key_links:
    - from: "lib/services/webhook-service.ts"
      to: "parseAndStoreMessage"
      via: "retry path on WebhookEventType.UPDATE"
      pattern: "parseAndStoreMessage"
    - from: "lib/services/phishing-eml-service.ts"
      to: "messages table"
      via: "SELECT id FROM messages WHERE report_id existence check"
      pattern: "report_id"
---

<objective>
Fix three verified defects in the phishing-triage pipeline discovered while
investigating Seubert ticket 699456, then reclassify the 6 affected campaigns.

Purpose: Confirmed KnowBe4 simulation campaigns are being routed to technician
review (UNWANTED) instead of auto-acknowledged (USER_AWARENESS) because of a
data gap in the sim-vendor allowlist, a timing race that permanently starves
the classifier of parsed .eml evidence, and a missing idempotency guard that
blocks safe retries. This undermines the milestone goal of eliminating
technician work for confirmed simulations.

Output: Updated allowlist data + test (Defect 1), idempotency guard + test
(Defect 3), a retry-on-update parse path reusing existing webhook update-event
traffic (Defect 2), and a one-off reclassification script for the 6 stale
Seubert campaigns.
</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
@./CLAUDE.md

<investigation_findings>
Direct DB inspection of 8 Seubert phishing reports from a single burst on
2026-07-16/17 showed all 8 share an identical KnowBe4 URL fingerprint —
`https://{domain}/render-template/?csu={token}&status_id=n` and
`https://{domain}/tracking/?csu={token}&status_id=a` — differing only by
domain + csu token. Domains observed:
- it-support.care (already allowlisted, confirmed KnowBe4)
- customer-portal.info (tickets 699421, 699422, 699456) — MISSING
- cloud-service-care.com (tickets 699419, 699433) — MISSING
- bankonlinesupport.com (ticket 699435) — MISSING

The `parseAndStoreMessage` call fired within ~1-2s of ticket.created returned a
silent no-op (attachment not yet available via Autotask getAttachments), and
calling the same function minutes later succeeded — there is no retry. Ticket
699456 received 5+ ticket.update webhook events in the minutes after creation.
</investigation_findings>

<interfaces>
From lib/services/campaign-classifier.ts:
```typescript
export const KNOWN_SIMULATION_SENDERS: readonly { vendor: string; domains: readonly string[] }[]
export function domainMatchesAllowlist(domain: string): boolean   // exact-or-subdomain, do NOT change
export function classifyCampaign(campaignId: string): Promise<ClassifyResult>  // ClassifyResult.verdict, .id
```

From lib/services/phishing-eml-service.ts:
```typescript
export interface ParseAndStoreResult { stored: boolean; messageId?: string; reason?: string; }
export async function parseAndStoreMessage(input: { reportId: string; ticketId: number }): Promise<ParseAndStoreResult>
```

From lib/services/phishing-automation-gate.ts:
```typescript
export async function getCompanyAutomationGate(companyId: number | null): Promise<{ autoParse: boolean; autoClassify: boolean; autoReport: boolean }>
```

reports table: id (UUID), ticket_id (BIGINT, UNIQUE), company_id, campaign_id (UUID)
messages table: id (UUID), report_id (UUID FK)
webhook types: WebhookEventType.CREATE | WebhookEventType.UPDATE, WebhookEntityType.TICKETS
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add 3 confirmed KnowBe4 domains to simulation allowlist (Defect 1)</name>
  <files>lib/services/campaign-classifier.ts, lib/services/campaign-classifier.test.ts</files>
  <read_first>
    - lib/services/campaign-classifier.ts lines 33-56 (KNOWN_SIMULATION_SENDERS + domainMatchesAllowlist)
    - lib/services/campaign-classifier.test.ts lines 41-77 (existing allowlist / domainMatchesAllowlist / isKnownSimulationSender tests)
  </read_first>
  <behavior>
    - domainMatchesAllowlist('customer-portal.info') === true
    - domainMatchesAllowlist('cloud-service-care.com') === true
    - domainMatchesAllowlist('bankonlinesupport.com') === true
    - Subdomain still matches: domainMatchesAllowlist('mail.customer-portal.info') === true
    - Spoof guard still holds: domainMatchesAllowlist('customer-portal.info.attacker.net') === false
  </behavior>
  <action>
    In `KNOWN_SIMULATION_SENDERS`, extend ONLY the `knowbe4` vendor entry's
    `domains` array to include `'customer-portal.info'`,
    `'cloud-service-care.com'`, and `'bankonlinesupport.com'` alongside the
    existing `'it-support.care'`. This is a pure data update — do NOT modify
    `domainMatchesAllowlist`, `isKnownSimulationSender`, or any matching logic
    (they already do exact-or-subdomain matching correctly, guarded by T-19-01
    tests). Update the inline comment on the knowbe4 entry to note these 3
    domains were confirmed via the shared /render-template/ + /tracking/ URL
    fingerprint across Seubert tickets 699419/699421/699422/699433/699435/699456
    on 2026-07-16/17. In campaign-classifier.test.ts, add assertions to the
    `domainMatchesAllowlist` describe block covering the 5 behaviors above.
  </action>
  <verify>
    <automated>cd /opt/stacks/pulse && npx vitest run lib/services/campaign-classifier.test.ts</automated>
  </verify>
  <done>All 3 new domains (and a subdomain of one) match the allowlist; the suffix-spoof case still returns false; existing tests still pass.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Add idempotency guard (Defect 3) + retry-parse on ticket.update (Defect 2)</name>
  <files>lib/services/phishing-eml-service.ts, lib/services/phishing-eml-service.test.ts, lib/services/webhook-service.ts</files>
  <read_first>
    - lib/services/phishing-eml-service.ts lines 49-95 (parseAndStoreMessage entry + no-op return shapes)
    - lib/services/phishing-eml-service.test.ts lines 12-130 (mock setup; note beforeEach default `queryMock.mockResolvedValue({ rows: [{ id: 'message-uuid-1' }] })`)
    - lib/services/webhook-service.ts lines 94-126 (event switch + fire-and-forget CREATE-only triggers)
    - lib/services/webhook-service.ts lines 462-537 (triggerPhishingDetection + runGatedPhishingStages auto_parse path)
  </read_first>
  <behavior>
    - parseAndStoreMessage returns `{ stored: false, reason: 'already-parsed' }` and performs NO getAttachments/getAttachmentContent/INSERT when a messages row already exists for the reportId.
    - parseAndStoreMessage happy path still returns `{ stored: true, messageId }` when no existing messages row.
    - On a ticket.update webhook, when a reports row exists for that ticket_id, has no linked messages row, and the company's auto_parse gate is on → parseAndStoreMessage is called exactly once.
    - On a ticket.update webhook where the report already has a messages row → parseAndStoreMessage is NOT called (the existence check short-circuits).
    - On a ticket.update webhook for a ticket with no reports row → no phishing work is triggered.
  </behavior>
  <action>
    DEFECT 3 (idempotency), in phishing-eml-service.ts: at the very top of the
    `try` block in `parseAndStoreMessage` (before the `getAttachments` call, ~line
    58), add an existence check: `SELECT id FROM messages WHERE report_id = $1
    LIMIT 1` with `[reportId]`. If `rows.length > 0`, return `{ stored: false,
    reason: 'already-parsed' }` immediately — mirroring the existing
    `{ stored: false, reason: ... }` no-op shape. Do not add ON CONFLICT to the
    INSERT (the guard is the fix).

    DEFECT 3 tests, in phishing-eml-service.test.ts: NOTE the beforeEach default
    `queryMock.mockResolvedValue({ rows: [{ id: 'message-uuid-1' }] })` — with the
    new leading SELECT, this default now makes EVERY existing happy-path test
    short-circuit as 'already-parsed' and fail. Fix by making the existence check
    return empty for the pre-existing tests: change the default mock (or use
    `queryMock.mockImplementation`) so a query whose SQL contains
    `FROM messages WHERE report_id` resolves to `{ rows: [] }` while INSERT ...
    RETURNING calls still resolve to `{ rows: [{ id: 'message-uuid-1' }] }`. Then
    add ONE new test: existence check returns a row → assert result equals
    `{ stored: false, reason: 'already-parsed' }`, and assert getAttachmentsMock
    was NOT called and no `INSERT INTO messages` call occurred (reuse the existing
    `callsContaining` helper).

    DEFECT 2 (retry on update), in webhook-service.ts: add a new private method
    `retryPhishingParseOnUpdate(payload: AutotaskWebhookPayload): Promise<void>`
    that: (1) SELECTs `id::text, company_id` from `reports WHERE ticket_id = $1`
    using `payload.entityId`; return if no row (not a flagged phishing ticket).
    (2) SELECTs `id FROM messages WHERE report_id = $1 LIMIT 1`; return if a row
    exists (already parsed). (3) calls `getCompanyAutomationGate(company_id)` and
    returns unless `gate.autoParse` is true. (4) calls
    `parseAndStoreMessage({ reportId, ticketId: Number(payload.entityId) })`
    inside its own try/catch (log on error, never throw). Do NOT run detection,
    grouping, classify, or report on update — only the missing-EML retry-parse.

    Wire it in the fire-and-forget block (lines ~118-126): the existing CREATE
    branch keeps calling `triggerWorkflowEngine` + `triggerPhishingDetection`.
    Add a sibling branch: when `payload.entityType === WebhookEntityType.TICKETS
    && payload.eventType === WebhookEventType.UPDATE`, call
    `this.retryPhishingParseOnUpdate(payload).catch(err => console.error('[WEBHOOK] Phishing retry-parse error:', err))`.
    parseAndStoreMessage is now idempotent (Defect 3), so repeated update events
    are safe.
  </action>
  <verify>
    <automated>cd /opt/stacks/pulse && npx vitest run lib/services/phishing-eml-service.test.ts && npx tsc --noEmit --pretty</automated>
  </verify>
  <done>parseAndStoreMessage short-circuits with reason 'already-parsed' when a messages row exists; existing eml-service tests pass with the adjusted mock; webhook update events invoke retry-parse only for flagged, unparsed, auto_parse-gated tickets; tsc clean.</done>
</task>

<task type="auto">
  <name>Task 3: One-off reclassify script for the 6 stale Seubert campaigns</name>
  <files>scripts/reclassify-seubert-campaigns.ts</files>
  <read_first>
    - lib/services/campaign-classifier.ts lines 451-526 (classifyCampaign signature + ClassifyResult)
    - Existing scripts/*.ts for the `postgresClient` + `POSTGRES_HOST=localhost npx tsx` run convention
  </read_first>
  <action>
    Create a one-off ops script (NOT a test, matches scripts/ convention). It must
    run AFTER Task 1's allowlist fix is committed (it depends on the new code path).

    For the 6 affected tickets [699419, 699421, 699422, 699433, 699435, 699456]:
    (1) Look up each campaign_id LIVE at execution time via
    `SELECT campaign_id FROM reports WHERE ticket_id = $1` — do NOT hardcode
    campaign UUIDs. Skip with a warning if campaign_id is null.
    (2) Read the current/latest verdict for that campaign_id:
    `SELECT verdict FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1`
    (this is the "before" verdict).
    (3) Call `classifyCampaign(campaignId)` (appends a fresh classifications row —
    D-02 append-only, correct behavior; the newest row becomes current).
    (4) Print a per-ticket line: ticketId, campaignId, before verdict → after
    verdict (result.verdict), and confidence.
    (5) Print a summary count of how many flipped to USER_AWARENESS.
    Guard the campaign UUIDs against duplicates (699421+699422 and 699419+699433
    may share a campaign) — dedupe campaign_ids before classifying so a shared
    campaign isn't classified twice, but still report per-ticket mapping.
    Exit cleanly (process.exit(0)) so the pg pool doesn't hang the process.
  </action>
  <verify>
    <automated>cd /opt/stacks/pulse && npx tsc --noEmit --pretty 2>&1 | grep -v '^#' | grep -c "scripts/reclassify-seubert-campaigns" | grep -q '^0$'</automated>
  </verify>
  <done>Script type-checks clean; running `POSTGRES_HOST=localhost npx tsx scripts/reclassify-seubert-campaigns.ts` prints before/after verdicts for all 6 tickets and the flip-count summary. (Live DB run is a human verification step — the automated gate only proves the script compiles.)</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Autotask webhook → webhook-service | Untrusted external POST; already HMAC-verified upstream (middleware/handler). |
| Parsed .eml content → DB | Message content is persisted, never dereferenced (T-16-03 invariant preserved). |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-A19-01 | Tampering | Allowlist data (KNOWN_SIMULATION_SENDERS) | mitigate | Only add exact confirmed domains; keep exact-or-subdomain matching (domainMatchesAllowlist untouched) so suffix-spoof `customer-portal.info.attacker.net` still fails — covered by test. |
| T-A19-02 | Denial of Service | retry-parse on every ticket.update | accept | Retry short-circuits immediately once a messages row exists (Defect 3 guard); no new polling/cron; reuses existing update traffic. Bounded to one parseAndStoreMessage attempt per update, gated on auto_parse. |
| T-A19-03 | Elevation/Repudiation | classifyCampaign append-only history | accept | Reclassification appends new classifications rows (D-02), never overwrites — full audit trail preserved. |
</threat_model>

<verification>
- `npx vitest run lib/services/campaign-classifier.test.ts lib/services/phishing-eml-service.test.ts` passes.
- `npx tsc --noEmit --pretty` is clean.
- `domainMatchesAllowlist` still rejects suffix-spoofed domains.
- No new messages row is created on a second parseAndStoreMessage call for the same report.
- Reclassification script prints before/after verdicts for all 6 tickets.
</verification>

<success_criteria>
- The 3 confirmed KnowBe4 domains classify as USER_AWARENESS via the allowlist short-circuit.
- parseAndStoreMessage is idempotent (returns `already-parsed`, no duplicate messages row).
- ticket.update webhooks retry parsing for flagged, unparsed, auto_parse-gated tickets — with no new cron/queue.
- The 6 Seubert campaigns are reclassified against the corrected allowlist with reported before/after verdicts.
</success_criteria>

<output>
Create `.planning/quick/260717-a19-fix-phishing-simulation-vendor-allowlist/260717-a19-SUMMARY.md` when done.
</output>
