# 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
