---
phase: 16-eml-mime-evidence-parser
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
  - package.json
  - package-lock.json
  - lib/services/eml-parser.ts
  - lib/services/eml-parser.fixtures.ts
  - lib/services/eml-parser.test.ts
autonomous: true
requirements: [EVID-02, EVID-03, EVID-04]
user_setup: []

must_haves:
  truths:
    - "Given a multi-attachment list containing rfc.eml and OriginatingEmail.eml, selection returns rfc.eml"
    - "Given a KnowBe4-shaped list (phish_alert_sp2_2.0.0.0.eml + OriginatingEmail.eml, both message/rfc822), selection returns the non-OriginatingEmail attachment"
    - "Given only OriginatingEmail.eml, selection returns OriginatingEmail.eml"
    - "Parsing a synthetic .eml buffer yields normalized From/displayName/senderEmail/senderDomain/Reply-To/Return-Path/To/Cc/Subject/Date/Message-ID/Received-chain, structured SPF/DKIM/DMARC verdicts, extracted URLs, and per-attachment name/content-type/size/sha256"
    - "No network call (fetch) occurs during parseEml on any synthetic fixture"
    - "Body preview is plain-text, truncated, and distinct from the raw HTML/text body"
    - "A .eml buffer larger than the size guard is rejected before simpleParser is called"
  artifacts:
    - path: "lib/services/eml-parser.ts"
      provides: "parseEml, selectOriginalMessage, parseAuthResults, extractUrls, buildBodyPreview + NormalizedMessage type"
      min_lines: 120
    - path: "lib/services/eml-parser.test.ts"
      provides: "EVID-02/03/04 vitest coverage incl. no-network spy and 3-tier selection"
      min_lines: 100
    - path: "lib/services/eml-parser.fixtures.ts"
      provides: "synthetic .eml buffers + attachment-metadata lists for all three selection tiers"
      min_lines: 40
  key_links:
    - from: "lib/services/eml-parser.ts"
      to: "mailparser"
      via: "simpleParser import with checksumAlgo sha256"
      pattern: "from 'mailparser'"
    - from: "lib/services/eml-parser.test.ts"
      to: "global.fetch"
      via: "vi.spyOn assertion of zero calls"
      pattern: "spyOn\\(global, 'fetch'\\)"
---

<objective>
Add the two npm dependencies (`mailparser`, `linkify-it`) and build the pure,
I/O-free EML parser module that is the heart of Phase 16 — attachment selection
(EVID-02, three tiers), RFC822/MIME normalization with structured auth verdicts
(EVID-03, D-06), and a sanitized truncated body preview that never triggers a
network call (EVID-04).

Purpose: Everything downstream (Plan 03 orchestration → messages/indicators rows,
Phase 18 campaign grouping) keys on this module's normalized output. It must be
correct, fully test-covered against synthetic fixtures only, and provably free of
outbound I/O.
Output: `lib/services/eml-parser.ts`, `lib/services/eml-parser.fixtures.ts`,
`lib/services/eml-parser.test.ts`, plus the two dependencies in package.json.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/16-eml-mime-evidence-parser/16-RESEARCH.md
@.planning/phases/16-eml-mime-evidence-parser/16-PATTERNS.md
@lib/services/b2/client.ts
@lib/services/b2/client.test.ts
@lib/services/phishing-detector.test.ts
@lib/types/autotask.ts

<interfaces>
<!-- Contracts this plan CREATES. Downstream plans (03) and Phase 18 consume these. -->

Attachment (existing, lib/types/autotask.ts): { id:number; attachmentType; fullPath:string; title:string; publish; data?:string; contentType?:string; createDate?; creatorResourceID? }

New exports from lib/services/eml-parser.ts (define these — they are the contract):

- type AuthVerdict = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror'
- interface AuthResults { spf?: AuthVerdict; dkim?: AuthVerdict; dmarc?: AuthVerdict }
- interface AttachmentMeta { filename: string | null; contentType: string | null; size: number; checksum: string | null; related: boolean }
- interface NormalizedMessage {
    from: { displayName: string | null; email: string | null; domain: string | null };
    replyTo: string | null;
    returnPath: string | null;
    to: string[];
    cc: string[];
    subject: string | null;
    date: string | null;              // ISO 8601 or null
    messageId: string | null;
    receivedChain: string[];          // ordered, outermost-first as encountered in headerLines
    authResults: AuthResults;         // parsed from the primary Authentication-Results header
    authResultsOriginal: AuthResults | null; // parsed from Authentication-Results-Original if present (Open Question 1)
    urls: string[];                   // deduped, from text + html parts
    attachments: AttachmentMeta[];    // includes inline/related, distinguished by `related`
    bodyPreview: string;              // truncated plain text, distinct from raw body
  }
- function selectOriginalMessage(attachments: Attachment[]): Attachment | null
- async function parseEml(rawEmlBuffer: Buffer): Promise<NormalizedMessage>
- function parseAuthResults(headerValue: string): AuthResults
- function extractUrls(text: string | null | undefined, html: string | null | undefined): string[]
- function buildBodyPreview(text: string | null | undefined, html: string | null | undefined): string
- const MAX_EML_BYTES: number   // size guard, below B2's 25 MB cap
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Install mailparser + linkify-it (deliberate, reviewed)</name>
  <files>package.json, package-lock.json</files>
  <read_first>
    - package.json (confirm neither dependency is already present; confirm current dependency block shape)
    - .planning/phases/16-eml-mime-evidence-parser/16-RESEARCH.md (Package Legitimacy Audit section + the "Process note for the planner" about cwd drift)
  </read_first>
  <action>
    From the repo root `/opt/stacks/pulse`, run `npm install mailparser linkify-it` as a single deliberate command (NOT inside a throwaway verification script). Target versions confirmed clean by the research slopcheck audit: mailparser 3.9.14, linkify-it 6.0.0 (both MIT, no postinstall scripts, [OK] verdict). Also install the matching `@types/mailparser` and `@types/linkify-it` devDependencies if the packages do not ship their own bundled types (check `npm view <pkg> types` first). After installing, run `git diff --stat package.json` and confirm ONLY `mailparser` and `linkify-it` (plus their `@types/*` if needed) were added to `dependencies`/`devDependencies` — no unrelated churn from a cwd that drifted. If `git diff package.json` shows any package other than these being added, revert with `git checkout -- package.json package-lock.json` and re-run from the confirmed repo root.
  </action>
  <verify>
    <!-- Two INDEPENDENT checks. The require-check exits non-zero on its own failure
         (not swallowed by the diff-check's `|| echo clean` fallback), and the
         diff-check's fallback only reports on the diff-check itself. -->
    <automated>cd /opt/stacks/pulse && node -e "require('mailparser'); require('linkify-it'); console.log('deps-ok')" || { echo "DEP_REQUIRE_FAILED"; exit 1; }
git -C /opt/stacks/pulse diff package.json | grep -E '^\+' | grep -vE 'mailparser|linkify-it|@types/(mailparser|linkify-it)|^\+\+\+' | grep -qE '"[a-z]' && { echo "UNEXPECTED_DEP"; exit 1; } || echo "clean"</automated>
  </verify>
  <done>Both packages import at runtime; `git diff package.json` added lines contain only mailparser, linkify-it, and optionally their @types — the guard command prints "clean" (no UNEXPECTED_DEP).</done>
  <acceptance_criteria>
    - `require('mailparser')` and `require('linkify-it')` both succeed (node exits 0, prints "deps-ok")
    - `package.json` diff introduces exactly mailparser + linkify-it (+ optional @types) and no other new dependency keys
    - `package-lock.json` is updated consistently (npm install completed without error)
  </acceptance_criteria>
</task>

<task type="tdd">
  <name>Task 2: Attachment selection (EVID-02, three tiers) — RED then GREEN</name>
  <files>lib/services/eml-parser.ts, lib/services/eml-parser.fixtures.ts, lib/services/eml-parser.test.ts</files>
  <read_first>
    - .planning/phases/16-eml-mime-evidence-parser/16-RESEARCH.md (Common Pitfalls → Pitfall 1: the exact three-tier selection algorithm empirically validated against 15 tickets)
    - .planning/phases/16-eml-mime-evidence-parser/16-PATTERNS.md (eml-parser.ts module-shape analog + describe-per-export test style)
    - lib/services/b2/client.ts (module conventions: top-of-file doc comment stating the no-I/O invariant, named exports, no class, `_XXX_INTERNALS` test export)
    - lib/services/phishing-detector.test.ts (describe-per-export + plain-language `it` titles)
    - lib/types/autotask.ts lines 287-297 (Attachment interface — title/fullPath/contentType/id fields to select on)
  </read_first>
  <behavior>
    - Tier 1: given attachments where one is named exactly `rfc.eml` (case-insensitive) among `message/rfc822` content-type attachments → selectOriginalMessage returns that attachment
    - Tier 2 (KnowBe4): given `phish_alert_sp2_2.0.0.0.eml` + `OriginatingEmail.eml`, both `message/rfc822`, no `rfc.eml` → returns the non-`OriginatingEmail.eml` candidate (the phish_alert one) since exactly one candidate remains after excluding OriginatingEmail.eml
    - Tier 3 (fallback): given only `OriginatingEmail.eml` → returns OriginatingEmail.eml
    - Ambiguous: given two non-OriginatingEmail `message/rfc822` candidates and no `rfc.eml` → falls back to OriginatingEmail.eml if present, else returns null
    - Empty / no `.eml`: given an attachment list with no `message/rfc822` and no `.eml` names → returns null
    - Case-insensitivity: `RFC.EML`, `OriginatingEmail.EML` match their tiers
  </behavior>
  <action>
    Create `lib/services/eml-parser.ts` with the module doc-comment (provenance: mailparser + hand-rolled RFC 8601 parsing; state the "never fetches or executes anything found in a message" invariant up front, mirroring b2/client.ts's doc comment). Export `selectOriginalMessage(attachments: Attachment[]): Attachment | null` implementing the three-tier algorithm from RESEARCH.md Pitfall 1 exactly: (1) exact `rfc.eml` case-insensitive among `message/rfc822`; (2) else the single non-`OriginatingEmail.eml` `message/rfc822` candidate; (3) else `OriginatingEmail.eml` if present; else null. Match filename on both `title` and `fullPath` (use whichever carries the name), lowercased. Treat content-type case-insensitively. Create `lib/services/eml-parser.fixtures.ts` exporting attachment-metadata list fixtures for all three tiers plus the ambiguous and no-eml cases (each a minimal `Attachment[]` with id/title/fullPath/contentType). Create `lib/services/eml-parser.test.ts` with a `describe('selectOriginalMessage', ...)` block covering every case in the behavior list — write the tests FIRST (RED: they fail against a stub), then implement (GREEN). Commit RED then GREEN separately per TDD convention.
  </action>
  <verify>
    <automated>cd /opt/stacks/pulse && npx vitest run lib/services/eml-parser.test.ts -t "selectOriginalMessage" && npx tsc --noEmit --pretty 2>&1 | (grep -i eml-parser && exit 1 || echo tsc-ok)</automated>
  </verify>
  <done>All selectOriginalMessage tests pass (rfc.eml, KnowBe4, fallback, ambiguous→OriginatingEmail, no-eml→null, case-insensitive); tsc reports no errors for eml-parser files.</done>
  <acceptance_criteria>
    - `npx vitest run lib/services/eml-parser.test.ts -t "selects rfc.eml"` passes
    - `npx vitest run lib/services/eml-parser.test.ts -t "KnowBe4"` passes
    - `npx vitest run lib/services/eml-parser.test.ts -t "fallback"` passes
    - selectOriginalMessage returns null (not throws) for a no-`.eml` list — asserted by a test
    - eml-parser.ts contains a top-of-file doc comment that literally states the never-fetch/never-execute invariant (grep-checkable string, e.g. "never fetch")
    - No fenced-code selection heuristic beyond the three tiers; tier order matches RESEARCH.md Pitfall 1
  </acceptance_criteria>
</task>

<task type="tdd">
  <name>Task 3: parseEml + auth-results + URLs + body preview + size guard (EVID-03, EVID-04, D-06)</name>
  <files>lib/services/eml-parser.ts, lib/services/eml-parser.fixtures.ts, lib/services/eml-parser.test.ts</files>
  <read_first>
    - lib/services/eml-parser.ts (current state after Task 2 — extend the same module)
    - .planning/phases/16-eml-mime-evidence-parser/16-RESEARCH.md (Pattern 2 mailparser usage, Pattern 3 hand-rolled Authentication-Results, Pitfall 4 multiple Authentication-Results headers, Pitfall 5 inline/CID attachments, Code Examples: no-network spy + sha256 checksum, Open Questions 1-3, Security Domain size-guard threat)
    - .planning/phases/16-eml-mime-evidence-parser/16-PATTERNS.md (network-call-spy pattern + describe-per-export test style)
  </read_first>
  <behavior>
    - parseEml(buffer) on a synthetic multipart/mixed fixture (text + html + one base64 attachment + Authentication-Results header carrying spf/dkim/dmarc) returns a NormalizedMessage with: from.displayName, from.email, from.domain (domain = substring after '@' of the sender email), replyTo, returnPath, to[], cc[], subject, date (ISO or null), messageId
    - authResults contains structured verdicts {spf,dkim,dmarc} matching the fixture header (e.g. spf: 'pass', dkim: 'fail', dmarc: 'none') — parsed via parseAuthResults, NOT raw header text
    - authResultsOriginal is populated when the fixture also has an Authentication-Results-Original header, null otherwise (iterate mail.headerLines, not just the headers Map, to catch repeated/family headers — Pitfall 4)
    - receivedChain is an ordered string[] of every Received header line
    - urls is a deduped list containing every http/https/www URL present in both the text and html parts (extractUrls via linkify-it), and never triggers a fetch
    - attachments[] carries filename/contentType/size/checksum (sha256 hex, 64 chars) for the base64 attachment; the `related` flag distinguishes inline/CID parts (Pitfall 5) — keep both, do not drop inline
    - bodyPreview is derived from mail.text (falling back to html-to-text of mail.html), truncated to a fixed max length, and is strictly shorter than / distinct from the raw body for a long-body fixture
    - parseEml never calls global.fetch (asserted with a spy) on any fixture (EVID-04 / SC#3)
    - parseEml (or a pre-check) throws or rejects a buffer larger than MAX_EML_BYTES BEFORE calling simpleParser (DoS size guard)
  </behavior>
  <action>
    Extend `lib/services/eml-parser.ts` to implement `parseEml`, `parseAuthResults`, `extractUrls`, `buildBodyPreview`, and export `MAX_EML_BYTES` (set a few MB, e.g. 10 * 1024 * 1024 — below b2/client.ts's MAX_DOWNLOAD_BYTES of 25 MB). parseEml: guard `buffer.byteLength > MAX_EML_BYTES` first (throw a clear Error before any parsing — this is the DoS mitigation for untrusted MIME); then call `simpleParser(buffer, { checksumAlgo: 'sha256' })`; map mailparser output into NormalizedMessage per the interfaces block. parseAuthResults: hand-rolled RFC 8601 tokenizer per RESEARCH.md Pattern 3 — split the header value on ';', match `(spf|dkim|dmarc)=<result>` case-insensitively, return the structured verdicts; expose the internal clause-splitter via `_EML_PARSER_INTERNALS` if it needs isolated test access (matching b2/client.ts's `_B2_INTERNALS` convention). Collect Authentication-Results-family headers by iterating `mail.headerLines` (Pitfall 4); parse the primary Authentication-Results into `authResults` and Authentication-Results-Original (if present) into `authResultsOriginal` (Open Question 1 recommendation). extractUrls: use linkify-it with `.tlds()` fuzzy matching enabled, scan mail.text and mail.html, dedupe, and NEVER fetch any extracted URL. buildBodyPreview: prefer mail.text; fall back to html-to-text (already a mailparser transitive dep) of mail.html; truncate to a fixed cap; this is stored distinct from the raw body (raw body is Plan 03's B2 concern, never persisted to Postgres). Keep inline/`related` attachments with the `related` flag rather than dropping them (Pitfall 5). Extend eml-parser.fixtures.ts with a rich multipart fixture (text+html+base64 attachment+Authentication-Results w/ spf pass, dkim fail, dmarc none; a second fixture adding Authentication-Results-Original; a long-body fixture for the preview-truncation test; an oversized buffer for the size-guard test). Add the corresponding `describe('parseEml', ...)`, `describe('parseAuthResults', ...)`, `describe('extractUrls', ...)`, `describe('buildBodyPreview', ...)` blocks including the no-network `vi.spyOn(global, 'fetch')` assertion. Write tests RED first, then implement GREEN. Use `[EML-PARSER]` as the console.error tag for any caught error path (e.g. malformed MIME).
  </action>
  <verify>
    <automated>cd /opt/stacks/pulse && npx vitest run lib/services/eml-parser.test.ts && npx tsc --noEmit --pretty 2>&1 | (grep -i eml-parser && exit 1 || echo tsc-ok)</automated>
  </verify>
  <done>Full eml-parser.test.ts suite passes: normalized headers, structured spf/dkim/dmarc verdicts, Received chain, deduped URLs, attachment sha256 metadata (incl. related flag), truncated body preview distinct from raw body, no-network spy asserts zero fetch calls, and oversized buffer is rejected before simpleParser. tsc clean for eml-parser files.</done>
  <acceptance_criteria>
    - `npx vitest run lib/services/eml-parser.test.ts -t "normalizes"` passes (headers + auth + received + urls + attachment meta)
    - `npx vitest run lib/services/eml-parser.test.ts -t "no network"` passes (fetch spy called 0 times)
    - `npx vitest run lib/services/eml-parser.test.ts -t "body preview"` passes (truncated + distinct from raw)
    - A test asserts parseEml rejects/throws on a buffer > MAX_EML_BYTES before simpleParser runs
    - A test asserts authResults.spf/dkim/dmarc are structured verdict strings (not the raw header)
    - A test asserts attachments[].checksum is a 64-char lowercase hex sha256 string
    - A test asserts an inline/`related`-flagged attachment is preserved with related === true
    - `npx vitest run lib/services/eml-parser.test.ts` exits 0 with no real customer email content in any fixture (all synthetic)
  </acceptance_criteria>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Autotask attachment bytes → eml-parser | Attacker-controlled RFC822/MIME bytes (a reported phishing email) cross into Pulse's parser |
| npm registry → build | Third-party parsing libraries enter the trusted build |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-16-01 | Denial of Service | parseEml over untrusted MIME | mitigate | Size guard: reject buffer > MAX_EML_BYTES (a few MB, below B2's 25 MB cap) BEFORE calling simpleParser; rely on mailparser's defensive multipart parsing rather than hand-rolled boundary splitting |
| T-16-02 | Spoofing | forged From/Reply-To/Return-Path | mitigate | Parse Authentication-Results into structured SPF/DKIM/DMARC verdicts (D-06) and carry them in NormalizedMessage alongside From, so Phase 19 never trusts display name alone |
| T-16-03 | Tampering / Info Disclosure (SSRF-adjacent) | extractUrls / buildBodyPreview / parseEml | mitigate | Hard invariant, test-enforced: no code path fetches or executes any URL found in the message. `vi.spyOn(global, 'fetch')` asserts zero calls during parseEml; mailparser verified I/O-free; extractUrls only string-matches, never dereferences; mail.html is never rendered or fetched |
| T-16-SC | Tampering | npm installs (mailparser, linkify-it) | mitigate | Research slopcheck audit: both [OK] (MIT, 15yr / markdown-it ecosystem, no postinstall scripts). Install is a deliberate reviewed task with a `git diff package.json` gate asserting only the two intended deps were added. Neither is [ASSUMED]/[SUS] → no blocking human checkpoint required |
</threat_model>

<verification>
- `npx vitest run lib/services/eml-parser.test.ts` — full new suite green
- `npx tsc --noEmit --pretty` — no type errors introduced
- `git diff package.json` — only mailparser + linkify-it (+ optional @types) added
- No fixture contains real customer email (synthetic-only per REQUIREMENTS.md Out of Scope)
</verification>

<success_criteria>
- EVID-02: three-tier selection (rfc.eml → non-OriginatingEmail message/rfc822 → OriginatingEmail.eml) all covered and passing
- EVID-03: normalized headers + structured SPF/DKIM/DMARC verdicts + Received chain + URLs + attachment metadata (name/content-type/size/sha256) produced from a synthetic fixture
- EVID-04: sanitized truncated body preview distinct from raw body; zero network calls during parsing (spy-asserted)
- DoS size guard rejects oversized buffers before parsing
</success_criteria>

<output>
Create `.planning/phases/16-eml-mime-evidence-parser/16-01-SUMMARY.md` when done
</output>
