docs(16): add pattern map

This commit is contained in:
lorentz 2026-07-15 09:55:39 -04:00
parent 91bac62dd8
commit 94b53c11d0

View file

@ -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<Buffer> {
...
}
```
`eml-parser.ts` should export `parseEml(buffer: Buffer): Promise<NormalizedMessage>`, `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<Attachment[]> {
const url = `${this.config.apiUrl}/${entityName}/${entityId}/Attachments`;
const response = await this.makeApiCall<ApiResponse<Attachment>>(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<Attachment | null> {
const url = `${this.config.apiUrl}/${entityName}/${entityId}/Attachments/${attachmentId}`;
const response = await this.makeApiCall<ApiResponse<Attachment>>(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<Buffer> {
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<DetectPhishingResult> {
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 `is<Name>Configured()` 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