diff --git a/.planning/phases/16-eml-mime-evidence-parser/16-PATTERNS.md b/.planning/phases/16-eml-mime-evidence-parser/16-PATTERNS.md new file mode 100644 index 0000000..c0c5b1a --- /dev/null +++ b/.planning/phases/16-eml-mime-evidence-parser/16-PATTERNS.md @@ -0,0 +1,473 @@ +# Phase 16: EML/MIME Evidence Parser - Pattern Map + +**Mapped:** 2026-07-15 +**Files analyzed:** 6 (2 new services, 1 new test, 2 modified services, 1 new migration) +**Analogs found:** 6 / 6 + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|--------------------|------|-----------|-----------------|----------------| +| `lib/services/eml-parser.ts` | utility/transform | transform (buffer → normalized object, no I/O) | `lib/services/b2/client.ts` (pure-function module shape, no class) | role-match (no MIME-parsing precedent exists; borrow module conventions, not domain logic) | +| `lib/services/eml-parser.test.ts` | test | transform | `lib/services/b2/client.test.ts` (fixture + vitest structure) / `lib/services/phishing-detector.test.ts` (describe-per-export style) | exact (test conventions) | +| `lib/services/autotask-client.ts` (add `getAttachmentContent`) | service (client method) | request-response | same file's `getAttachments()` (line 424) and `uploadAttachment()` (line 383) | exact | +| `lib/services/b2/client.ts` (add `EML_OBJECT_KEY_REGEX` + parameterized validation) | service (storage client) | file-I/O | same file's `OBJECT_KEY_REGEX` + `presignDownload`/`presignUpload`/`downloadToBuffer` (lines 31, 145-205) | exact | +| `lib/services/phishing-eml-service.ts` | service (orchestration) | event-driven / CRUD (fetch → transform → persist) | `lib/services/phishing-detector.ts` (`gatherTicketEvidence` + `detectPhishingTicket`) | exact | +| `migrations/099_indicators_metadata.sql` | migration | batch (DDL) | `migrations/083_add_user_timezone.sql` (single additive `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`) | exact | + +## Pattern Assignments + +### `lib/services/eml-parser.ts` (utility/transform, new capability — no direct analog) + +**Analog:** `lib/services/b2/client.ts` for *module shape* (top-of-file doc comment explaining provenance/why, named exported pure functions, no class, a `_XXX_INTERNALS` object at the bottom for test-only access to non-exported helpers). Do **not** borrow B2's domain logic — MIME parsing has no existing analog in this codebase; use `mailparser` per RESEARCH.md Pattern 2/3. + +**Module doc-comment + no-I/O framing pattern** (`lib/services/b2/client.ts` lines 1-11): +```typescript +/** + * Backblaze B2 client (S3-compatible) for the LogLift evidence pipeline. + * + * Implements AWS Signature Version 4 presigned URLs (matches the n8n + * collector's expectations) for both downloads (Pulse fetching uploaded + * payloads) and uploads (Pulse handing the collector a presigned PUT + * target so the script doesn't carry credentials). + * + * Port of the SigV4 implementation from `docs/LogLift Review.json` — + * battle-tested in production via the existing n8n flow. + */ +``` +Mirror this shape for `eml-parser.ts`: explain provenance (mailparser + hand-rolled RFC 8601 parsing), and explicitly state the "never fetches/executes anything" invariant (SC#3) directly in the doc comment, the same way B2's comment states its SigV4/security intent up front. + +**Named-export, no-class pattern** (`lib/services/b2/client.ts` lines 145-161, 167-205): +```typescript +export function presignDownload( + objectKey: string, + expiresInSeconds = 600, + cfg: B2Config = getB2Config() +): string { + if (!OBJECT_KEY_REGEX.test(objectKey)) throw new B2InvalidObjectKeyError(objectKey); + return presign({ method: 'GET', objectKey, expiresInSeconds, config: cfg }); +} + +export async function downloadToBuffer( + objectKey: string, + cfg: B2Config = getB2Config() +): Promise { + ... +} +``` +`eml-parser.ts` should export `parseEml(buffer: Buffer): Promise`, `selectOriginalMessage(attachments): Attachment | null`, `parseAuthResults(headerValue: string)`, `extractUrls(text, html)`, `buildBodyPreview(text, html)` — each a standalone named function, not methods on a class (matches every function-style service in `lib/services/` that isn't a stateful client like `AutotaskClient`/`B2Config`). + +**Test-only internals export pattern** (`lib/services/b2/client.ts` lines 207-211): +```typescript +// Test-only exports. +export const _B2_INTERNALS = { + deriveSigningKey, + presign, +}; +``` +Use the same convention if any helper inside `eml-parser.ts` needs test access but shouldn't be part of the public API (e.g. the RFC 8601 tokenizer's internal clause-splitting step). + +**Error handling / logging convention** (project-wide, see Shared Patterns below) — `console.error()` with a bracketed component tag, matching `phishing-detector.ts` line 153: `console.error('[PHISHING-DETECT] Failed to fetch attachments for ticket', ticket.id, error);`. Use a `[EML-PARSER]` tag for this file's caught errors (e.g. malformed MIME, oversized buffer guard). + +**Concrete parsing pattern to follow (from RESEARCH.md, verified live)** — import at top: +```typescript +import { simpleParser } from 'mailparser'; + +const mail = await simpleParser(rawEmlBuffer, { checksumAlgo: 'sha256' }); +``` +And the hand-rolled Authentication-Results tokenizer (RESEARCH.md Pattern 3) — no existing codebase analog, write it as a small pure function following the same "explain the RFC in a comment, then a small loop" style as `phishing-detector.ts`'s `matchesPhishingPatterns` (lines 42-51: comment explaining the matching rule, then a short, easy-to-read loop/filter, no regex-heavy cleverness beyond what's required). + +--- + +### `lib/services/eml-parser.test.ts` (test) + +**Analog:** `lib/services/b2/client.test.ts` (fixture-object + `describe`/`it` structure, deterministic time-stubbing pattern if needed) and `lib/services/phishing-detector.test.ts` (one `describe` block per exported function, plain-language `it` titles matching the requirement wording). + +**Imports + fixture pattern** (`lib/services/b2/client.test.ts` lines 1-17): +```typescript +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + OBJECT_KEY_REGEX, + presignDownload, + presignUpload, + B2InvalidObjectKeyError, + type B2Config, + _B2_INTERNALS, +} from './client'; + +const FIXTURE_CFG: B2Config = { + keyId: 'AKIA-FIXTURE', + secret: 'sec-fixture', + bucket: 'wulf-audits', + region: 'us-west-002', + endpoint: 's3.us-west-002.backblazeb2.com', +}; +``` +Mirror this: define one or more synthetic `.eml` buffer fixtures as module-level constants (per RESEARCH.md Wave 0 Gaps: rfc.eml+OriginatingEmail.eml pair, KnowBe4 pair, OriginatingEmail.eml-only, and a multipart fixture with an Authentication-Results header) rather than inlining raw strings in every `it()`. + +**describe-per-export + plain-language `it` pattern** (`lib/services/phishing-detector.test.ts` lines 1-20): +```typescript +import { describe, it, expect } from 'vitest'; +import { + KNOWN_PHISHING_PATTERNS, + matchesPhishingPatterns, + computePhishingContentHash, +} from './phishing-detector'; + +describe('KNOWN_PHISHING_PATTERNS', () => { + it('has exactly 8 locked patterns', () => { + expect(KNOWN_PHISHING_PATTERNS).toHaveLength(8); + }); +}); + +describe('matchesPhishingPatterns', () => { + it('flags a title containing "Phishing Report"', () => { + const result = matchesPhishingPatterns('Fwd: Phishing Report', null); + expect(result.flagged).toBe(true); + expect(result.matched).toContain('Phishing Report'); + }); + ... +``` +Apply this directly to the EVID-02/03/04 → test map in RESEARCH.md (`describe('selectOriginalMessage', ...)`, `describe('parseEml', ...)`, with `it` titles like `'selects rfc.eml over OriginatingEmail.eml'`, `'selects the KnowBe4-named attachment when OriginatingEmail.eml is also present'`, `'falls back to OriginatingEmail.eml when it is the only attachment'`). + +**Network-call-spy pattern** (RESEARCH.md Code Examples, verified live — no existing codebase precedent for a `fetch` spy, but `vi.spyOn`/`vi.stubGlobal` usage already exists in `b2/client.test.ts` lines 51-66 for `Date` stubbing — same vitest API family): +```typescript +it('never makes a network call while parsing', async () => { + const fetchSpy = vi.spyOn(global, 'fetch'); + await simpleParser(syntheticEmlBuffer, { checksumAlgo: 'sha256' }); + expect(fetchSpy).not.toHaveBeenCalled(); +}); +``` + +--- + +### `lib/services/autotask-client.ts` — add `getAttachmentContent()` (service/client method, request-response) + +**Analog:** same file's `getAttachments()` (lines 424-436) for the GET-call shape, and `uploadAttachment()` (lines 383-422) for the "check `response.item`/`response.items` explicitly, throw or return null" convention. + +**Imports** (lines 1-16, unchanged — add nothing new unless the `Attachment` type needs no changes; it doesn't): +```typescript +import { + AutotaskConfig, + AutotaskHeaders, + QueryParams, + ApiResponse, + ApiError, + Resource, + Ticket, + Task, + Company, + ConfigurationItem, + Attachment, + EntityField, + PicklistValue, + AutotaskTimeEntry, +} from '@/lib/types/autotask'; +``` + +**Existing sibling method to model the new one on** (lines 424-436 — note the `response.items || []` read, NOT `.item`): +```typescript +async getAttachments( + entityName: string, + entityId: number +): Promise { + const url = `${this.config.apiUrl}/${entityName}/${entityId}/Attachments`; + + const response = await this.makeApiCall>(url, { + method: 'GET', + headers: this.getAuthHeaders(), + }); + + return response.items || []; +} +``` + +**Critical gotcha the new method must NOT copy** — `getEntityById` (line 164-ish) and `uploadAttachment` (line 417) both read `response.item`: +```typescript +// uploadAttachment, line 417 — reads .item (singular), correct for THAT endpoint: +if (!response.item) { + throw new Error('Failed to upload attachment'); +} +return response.item; +``` +RESEARCH.md confirmed live that `GET Tickets/{id}/Attachments/{attachmentId}` returns `{ items: [...] }` (plural/array), same shape as the list call, NOT `{ item: {...} }`. The new method must read `response.items?.[0] ?? null`, matching `getAttachments`'s `response.items` access, not `uploadAttachment`'s `response.item` access. Implementation to add, per RESEARCH.md Pattern 1 (place immediately after `getAttachments`, ~line 436): +```typescript +async getAttachmentContent( + entityName: string, + entityId: number, + attachmentId: number +): Promise { + const url = `${this.config.apiUrl}/${entityName}/${entityId}/Attachments/${attachmentId}`; + const response = await this.makeApiCall>(url, { + method: 'GET', + headers: this.getAuthHeaders(), + }); + // NOTE: response.items (array), NOT response.item — confirmed live against + // the real Autotask API (list-call convention, not entity-by-ID convention). + return response.items?.[0] ?? null; +} +``` + +**Error handling** — inherited for free from `makeApiCall` (lines 44-82), which every existing method (including `getAttachments`) already delegates to for HTTP-status/JSON-parse error handling. No new try/catch needed in the new method itself, matching the existing sibling methods' style (none of `getAttachments`/`uploadAttachment`/`getEntityById` wrap their own `makeApiCall` invocation in try/catch — they let `makeApiCall`'s own catch-and-rethrow propagate). + +--- + +### `lib/services/b2/client.ts` — add `EML_OBJECT_KEY_REGEX` + parameterized validation (service/storage client, file-I/O) + +**Analog:** the file's own existing `OBJECT_KEY_REGEX` + `B2InvalidObjectKeyError` + `presignDownload`/`presignUpload`/`downloadToBuffer` (lines 28-52, 145-205). This is a same-file addition, not a new-file analog search — the pattern to copy is explicitly "don't touch the existing regex/behavior, add a parallel one." + +**Existing regex + guard pattern to parallel** (lines 28-48): +```typescript +/** + * Object-key shape we accept from inbound webhooks. Path-traversal guard + * — must be `{client_id_or_uuid}/{computer_name}/eventlogs_{timestamp}.json.gz`. + */ +export const OBJECT_KEY_REGEX = + /^[A-Za-z0-9_-]+\/[A-Za-z0-9_.-]+\/eventlogs_[0-9_]+\.json\.gz$/; + +export class B2NotConfiguredError extends Error { + constructor() { + super( + 'Backblaze B2 is not configured. Set B2_KEY_ID + B2_APP_KEY (and optionally B2_BUCKET / B2_REGION / B2_ENDPOINT).' + ); + this.name = 'B2NotConfiguredError'; + } +} + +export class B2InvalidObjectKeyError extends Error { + constructor(objectKey: string) { + super(`Invalid object key shape: ${objectKey.slice(0, 200)}`); + this.name = 'B2InvalidObjectKeyError'; + } +} +``` +Add a sibling constant immediately after `OBJECT_KEY_REGEX` (per the project's own skill doc, quoted directly in RESEARCH.md: *"If you need a new payload type, add a new key regex + a new transport rather than loosening the existing one."*): +```typescript +/** + * Object-key shape for phishing-triage raw .eml evidence (Phase 16, D-05). + * Kept separate from OBJECT_KEY_REGEX (LogLift eventlogs shape) per this + * project's own evidence-storage skill doc — never loosen the LogLift regex + * to accommodate an unrelated payload type. + */ +export const EML_OBJECT_KEY_REGEX = + /^phishing\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\.eml$/; +``` + +**Functions to parameterize (default stays `OBJECT_KEY_REGEX` for existing LogLift call sites — zero behavior change for them)** — current signatures (lines 145-161, 167-171): +```typescript +export function presignDownload( + objectKey: string, + expiresInSeconds = 600, + cfg: B2Config = getB2Config() +): string { + if (!OBJECT_KEY_REGEX.test(objectKey)) throw new B2InvalidObjectKeyError(objectKey); + return presign({ method: 'GET', objectKey, expiresInSeconds, config: cfg }); +} + +export function presignUpload( + objectKey: string, + expiresInSeconds = 1800, + cfg: B2Config = getB2Config() +): string { + if (!OBJECT_KEY_REGEX.test(objectKey)) throw new B2InvalidObjectKeyError(objectKey); + return presign({ method: 'PUT', objectKey, expiresInSeconds, config: cfg }); +} + +export async function downloadToBuffer( + objectKey: string, + cfg: B2Config = getB2Config() +): Promise { + const url = presignDownload(objectKey, 600, cfg); + ... +``` +Add an optional trailing `keyRegex: RegExp = OBJECT_KEY_REGEX` parameter to each (RESEARCH.md's exact prescribed approach — "parameterize... default stays OBJECT_KEY_REGEX for LogLift call sites"). Every existing call site (`lib/services/rmm/executor.ts:337` `presignUpload(objectKey, 1800)`) keeps compiling and behaving identically since the new param is optional-with-default. + +**New self-PUT pattern this phase introduces (no existing codebase precedent — first server-side PUT, not handed to an external collector)** — from RESEARCH.md Pattern 4, to be used in `phishing-eml-service.ts`, not in `b2/client.ts` itself: +```typescript +// Existing precedent (lib/services/rmm/executor.ts:337) hands the signed URL +// to an EXTERNAL collector script to PUT. Phase 16 is the first case of +// Pulse's own server code PUTting bytes to B2 itself: +import { presignUpload, EML_OBJECT_KEY_REGEX } from '@/lib/services/b2/client'; + +const objectKey = `phishing/${reportId}/${attachmentId}.eml`; +const url = presignUpload(objectKey, 1800, undefined, EML_OBJECT_KEY_REGEX); // exact param order is plan-time discretion +const res = await fetch(url, { method: 'PUT', body: rawEmlBuffer }); +if (!res.ok) throw new Error(`B2 PUT ${objectKey} failed: ${res.status}`); +``` + +**Configuration gate to reuse** (line 50-52 — already exported, use as-is, don't reimplement): +```typescript +export function isB2Configured(): boolean { + return !!(process.env.B2_KEY_ID && process.env.B2_APP_KEY); +} +``` + +--- + +### `lib/services/phishing-eml-service.ts` (service/orchestration, event-driven → CRUD) + +**Analog:** `lib/services/phishing-detector.ts` — specifically `gatherTicketEvidence` (fetch-metadata → transform → return) and `detectPhishingTicket` (fetch → idempotency check → persist) for the overall shape; `lib/services/phishing-sweep-service.ts` for the "delegate to the shared core, don't duplicate logic" framing if this service is ever invoked from more than one caller. + +**Imports pattern** (`lib/services/phishing-detector.ts` lines 15-17): +```typescript +import { createHash } from 'crypto'; +import { postgresClient } from './postgres-client'; +import { getAutotaskClient } from './autotask-factory'; +``` +`phishing-eml-service.ts` should follow the same shape, adding: +```typescript +import { postgresClient } from './postgres-client'; +import { getAutotaskClient } from './autotask-factory'; +import { presignUpload, isB2Configured, EML_OBJECT_KEY_REGEX } from './b2/client'; +import { parseEml, selectOriginalMessage } from './eml-parser'; +``` + +**Fetch-metadata-then-transform pattern** (`lib/services/phishing-detector.ts` lines 144-155 — try/catch around the Autotask call, log with a bracketed tag, degrade gracefully rather than throwing): +```typescript +let attachments: EvidenceAttachment[] = []; +try { + const rawAttachments = await getAutotaskClient().getAttachments('Tickets', ticket.id); + attachments = rawAttachments.map((attachment) => ({ + fullPath: attachment.fullPath, + title: attachment.title, + contentType: attachment.contentType, + })); +} catch (error) { + console.error('[PHISHING-DETECT] Failed to fetch attachments for ticket', ticket.id, error); + attachments = []; +} +``` +Use this exact try/catch + bracketed-tag-log + graceful-degrade shape for the new service's `getAttachmentContent()` call and B2 upload step — RESEARCH.md's Pitfall 3 explicitly calls for gating the B2-upload step behind `isB2Configured()` the same way other optional integrations are gated elsewhere; this file is the concrete "elsewhere." + +**Idempotent upsert-by-natural-key pattern** (`lib/services/phishing-detector.ts` lines 210-229 — `INSERT ... ON CONFLICT (...) DO UPDATE SET ... RETURNING`): +```typescript +const upsertResult = await postgresClient.query<{ id: string }>( + `INSERT INTO reports ( + ticket_id, ticket_number, company_id, company_name, requester_contact_id, + created_by_contact_id, title, description, matched_patterns, content_hash, evidence + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11::jsonb) + ON CONFLICT (ticket_id) DO UPDATE SET + ticket_number = EXCLUDED.ticket_number, + ... + RETURNING id::text AS id`, + [ /* params */ ] +); +``` +Apply the same `INSERT ... ON CONFLICT ... RETURNING id::text AS id` shape when the new service writes the `messages` row (linked via `report_id`, per CONTEXT.md's Integration Points) and the `indicators` rows — match `messages`/`indicators`' actual column list from `migrations/097_phishing_triage_schema.sql` (`messages.headers/urls/attachments/body_preview/raw_ref`; `indicators.indicator_type/value/metadata`). + +**Top-level orchestration entry-point pattern** (`lib/services/phishing-detector.ts` lines 176-183, 245-248 — public function name mirrors the phase's verb, try/catch wraps the whole body, rethrow on failure so the caller (webhook/sweep) can log): +```typescript +export async function detectPhishingTicket( + ticket: DetectableTicket +): Promise { + const { flagged, matched } = matchesPhishingPatterns(ticket.title, ticket.description); + if (!flagged) { + return { flagged: false }; + } + ... + } catch (error) { + console.error('[PHISHING-DETECT] Failed to detect/persist report for ticket', ticket.id, error); + throw error; + } +} +``` +Model the new service's main export (e.g. `parseAndStoreMessage(reportId, ticketId): Promise<...>`) on this: early-return on a "nothing to do" case (no `.eml` attachment found, per EVID-02's fallback chain), single top-level try/catch, bracketed-tag `console.error`, rethrow. + +--- + +### `migrations/099_indicators_metadata.sql` (migration, batch DDL) + +**Analog:** `migrations/083_add_user_timezone.sql` — closest existing precedent for a single additive `ALTER TABLE ... ADD COLUMN` migration (084/085 follow the identical shape for other single-column additions). + +**Full pattern to copy** (`migrations/083_add_user_timezone.sql`, all 27 lines): +```sql +-- ============================================================================= +-- Per-user IANA timezone (Phase 7.1 — TZ-01) +-- ============================================================================= +-- Adds a `timezone` column to the Better Auth "user" table so day/week +-- boundary math (dashboards, ticket filters, finance, engagement) can be +-- computed against the viewer's zone instead of server UTC. +-- +-- Storage zone for every existing TIMESTAMP / TIMESTAMPTZ column is unchanged. +-- Only display/range-bucketing logic in subsequent plans reads this column. +-- +-- The SQL default here is the literal 'UTC'. The application-level default +-- (process.env.DEFAULT_TIMEZONE || 'UTC') is enforced by Better Auth's +-- additionalField `defaultValue` in lib/auth.ts so new sessions see the env- +-- driven value even if a row was created without it. +-- ============================================================================= + +ALTER TABLE "user" + ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT 'UTC'; + +-- Backfill any rows that may have been created with NULL (defensive — the +-- DEFAULT clause above covers new inserts, but on managed Postgres a column +-- added with DEFAULT may briefly show NULL in flight on some replicas). +UPDATE "user" SET timezone = 'UTC' WHERE timezone IS NULL; + +COMMENT ON COLUMN "user".timezone IS + 'IANA timezone string (e.g. America/New_York). Storage timezone for all date columns remains UTC; this column only affects display and range-bucketing.'; +``` +Apply directly, adjusted for D-07's exact ask (`ALTER TABLE indicators ADD COLUMN metadata JSONB` — no `NOT NULL`/no default per CONTEXT.md's literal wording, no backfill needed since JSONB is nullable by default and no existing rows exist yet per the migration-097 stub). Follow the same doc-comment shape: banner, phase/decision-ID reference, one-sentence purpose, one-sentence "what this does NOT change," and a `COMMENT ON COLUMN` for future readers (matches every other single-column-add migration in this repo — 083/084/085). + +Cross-reference `migrations/097_phishing_triage_schema.sql` lines 100-108 for the exact current `indicators` table shape this migration extends: +```sql +CREATE TABLE IF NOT EXISTS indicators ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID REFERENCES messages(id), + indicator_type TEXT NOT NULL, + value TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +--- + +## Shared Patterns + +### Error handling / logging +**Source:** `lib/services/phishing-detector.ts` lines 152-155, 245-248 +**Apply to:** `eml-parser.ts`, `phishing-eml-service.ts`, the new `AutotaskClient` method (implicitly, via `makeApiCall`'s existing catch) +```typescript +} catch (error) { + console.error('[PHISHING-DETECT] Failed to detect/persist report for ticket', ticket.id, error); + throw error; +} +``` +Convention: bracketed component tag (`[EML-PARSER]`, `[PHISHING-EML]`), context values after the message string (not string-interpolated), rethrow at the top-level orchestration boundary so callers can decide whether to fail the whole operation or degrade gracefully (per Pitfall 3's guidance on the B2-unconfigured case specifically). + +### Optional-integration configuration gate +**Source:** `lib/services/b2/client.ts` lines 50-52 +**Apply to:** `phishing-eml-service.ts`'s B2 upload step +```typescript +export function isB2Configured(): boolean { + return !!(process.env.B2_KEY_ID && process.env.B2_APP_KEY); +} +``` +Same shape as every other `isConfigured()` factory helper in `lib/services/` (per CLAUDE.md's External Integrations table) — check before attempting the B2 PUT, log + skip (or flag the report) rather than throwing, since B2 credentials are confirmed absent from this dev `.env` (RESEARCH.md Pitfall 3). + +### Idempotent upsert via `ON CONFLICT ... RETURNING` +**Source:** `lib/services/phishing-detector.ts` lines 210-229 +**Apply to:** `phishing-eml-service.ts`'s `messages`/`indicators` writes +```sql +INSERT INTO reports (...) +VALUES (...) +ON CONFLICT (ticket_id) DO UPDATE SET ... , updated_at = NOW() +RETURNING id::text AS id +``` +`messages`/`indicators` don't yet have a natural unique key defined in migration 097 (no `uq_messages_report_id` constraint) — decide at plan time whether re-parsing the same report should upsert-by-`report_id` (adding a unique constraint) or simply insert a new row each time; either way, follow the `RETURNING id::text AS id` convention for the UUID primary key (matches every UUID-PK table write in this codebase, avoiding raw-UUID vs. string type mismatches in TypeScript). + +### snake_case DB → camelCase API boundary +**Source:** CLAUDE.md convention, reflected throughout `lib/services/` +**Apply to:** any code in `phishing-eml-service.ts` that shapes data for eventual API consumption (Phase 18, out of scope here, but the `NormalizedMessage` shape `eml-parser.ts` returns should already use camelCase internally since it's a TS interface, while the SQL columns it's persisted into stay snake_case — no ORM auto-mapping, manual transform at the query-building step, exactly as `phishing-detector.ts`'s `EvidencePayload`/`gatherTicketEvidence` already does). + +## No Analog Found + +None — every file in scope has at least a role-match analog in the codebase (MIME parsing itself has no domain-logic precedent, as RESEARCH.md notes, but the *module conventions* to follow are covered above). + +## Metadata + +**Analog search scope:** `lib/services/` (top-level + `b2/`, `rmm/`), `migrations/` +**Files scanned:** `lib/services/phishing-detector.ts`, `lib/services/phishing-detector.test.ts`, `lib/services/phishing-sweep-service.ts`, `lib/services/b2/client.ts`, `lib/services/b2/client.test.ts`, `lib/services/autotask-client.ts`, `lib/services/rmm/executor.ts` (grep only), `lib/types/autotask.ts`, `migrations/097_phishing_triage_schema.sql`, `migrations/098_phishing_sweep_schedule.sql`, `migrations/083_add_user_timezone.sql` +**Pattern extraction date:** 2026-07-15