diff --git a/.planning/phases/16-eml-mime-evidence-parser/16-01-SUMMARY.md b/.planning/phases/16-eml-mime-evidence-parser/16-01-SUMMARY.md new file mode 100644 index 0000000..dbb8c3c --- /dev/null +++ b/.planning/phases/16-eml-mime-evidence-parser/16-01-SUMMARY.md @@ -0,0 +1,121 @@ +--- +phase: 16-eml-mime-evidence-parser +plan: 01 +subsystem: api +tags: [mailparser, linkify-it, mime, rfc822, spf, dkim, dmarc, phishing, vitest] + +# Dependency graph +requires: [] +provides: + - "lib/services/eml-parser.ts — selectOriginalMessage, parseEml, parseAuthResults, extractUrls, buildBodyPreview, NormalizedMessage type" + - "mailparser + linkify-it npm dependencies" +affects: [16-02, 16-03, 18-campaign-grouping] + +# Tech tracking +tech-stack: + added: ["mailparser@3.9.14", "linkify-it@6.0.0", "@types/mailparser@3.4.6 (devDependency)"] + patterns: + - "Pure I/O-free transform module (no class, named exports, top-of-file doc-comment stating a hard no-network invariant) — mirrors lib/services/b2/client.ts's module shape" + - "Hand-rolled RFC 8601 Authentication-Results tokenizer instead of a DNS/HTTP-verifying library (mailauth rejected)" + - "describe-per-export vitest structure with synthetic-fixture-only test data" + +key-files: + created: + - lib/services/eml-parser.ts + - lib/services/eml-parser.fixtures.ts + - lib/services/eml-parser.test.ts + - .planning/phases/16-eml-mime-evidence-parser/deferred-items.md + modified: + - package.json + - package-lock.json + +key-decisions: + - "Used a small hand-rolled HTML-to-text stripper for buildBodyPreview's fallback path instead of importing mailparser's undeclared transitive dependency html-to-text directly (avoids depending on an unversioned, un-pinned package.json entry that could silently disappear on a future mailparser bump)" + - "linkify-it constructed with { fuzzyLink: true } to catch scheme-less www.-prefixed phishing URLs (off by default in the library)" + - "Authentication-Results / Authentication-Results-Original collected via mail.headerLines (first occurrence each), not the headers Map, since the Map only exposes the last/one occurrence of a repeated header (Pitfall 4)" + +patterns-established: + - "Pattern: Buffer-in, structured-object-out parsing module with a byte-size guard enforced BEFORE handing untrusted content to a third-party parser (DoS mitigation, T-16-01)" + +requirements-completed: [EVID-02, EVID-03, EVID-04] + +# Metrics +duration: 12min +completed: 2026-07-15 +--- + +# Phase 16 Plan 01: EML/MIME Evidence Parser Core Summary + +**Pure, I/O-free `lib/services/eml-parser.ts` built on `mailparser` + `linkify-it`, implementing three-tier `.eml` attachment selection, RFC822/MIME normalization with hand-rolled structured SPF/DKIM/DMARC verdicts, deduped URL extraction, and a sanitized truncated body preview — all test-enforced to never trigger a network call.** + +## Performance + +- **Duration:** ~12 min +- **Started:** 2026-07-15T14:19:00Z (worktree setup + npm install) +- **Completed:** 2026-07-15T14:30:18Z +- **Tasks:** 3 completed (1 auto, 2 TDD) +- **Files modified:** 5 (2 dependency files + 3 new source files) plus 1 new deferred-items tracking doc + +## Accomplishments +- Installed `mailparser` 3.9.14 + `linkify-it` 6.0.0 (+ `@types/mailparser` devDependency) with a reviewed `git diff package.json` gate confirming no unrelated dependency churn +- Implemented `selectOriginalMessage` — the three-tier `.eml` attachment selection algorithm (exact `rfc.eml` → single non-`OriginatingEmail.eml` `message/rfc822` candidate → `OriginatingEmail.eml` fallback → null), validated against all real-world shapes found in Phase 16 research (Microsoft Report Message, KnowBe4 PhishER, single-attachment legacy tickets, and both ambiguous edge cases) +- Implemented `parseEml` — normalizes From/Reply-To/Return-Path/To/Cc/Subject/Date/Message-ID/Received-chain, structured `authResults`/`authResultsOriginal` verdicts, deduped URLs, and per-attachment metadata (filename/content-type/size/sha256 checksum/`related` flag for inline-CID parts), guarded by `MAX_EML_BYTES` (10 MB, below B2's 25 MB cap) enforced before `simpleParser` is ever called +- Implemented `parseAuthResults` (hand-rolled RFC 8601 tokenizer), `extractUrls` (linkify-it with fuzzy `www.` matching), and `buildBodyPreview` (truncated to 500 chars, HTML-stripped fallback) +- 26/26 vitest tests pass, `tsc --noEmit` clean for all eml-parser files, and a `global.fetch` spy confirms zero network calls across every synthetic fixture (EVID-04 hard invariant) + +## Task Commits + +1. **Task 1: Install mailparser + linkify-it (deliberate, reviewed)** - `0f4dc1f` (chore) +2. **Task 2: Attachment selection (EVID-02, three tiers) — RED** - `2fde115` (test) +2. **Task 2: Attachment selection (EVID-02, three tiers) — GREEN** - `e4718ae` (feat) +3. **Task 3: parseEml + auth-results + URLs + body preview + size guard — RED** - `4df4816` (test) +3. **Task 3: parseEml + auth-results + URLs + body preview + size guard — GREEN** - `654e624` (feat) + +_Note: this is a `type: tdd` plan — Tasks 2 and 3 each have a RED (test) commit followed by a GREEN (feat) commit, no refactor commit was needed._ + +## Files Created/Modified +- `lib/services/eml-parser.ts` (282 lines) - `selectOriginalMessage`, `parseEml`, `parseAuthResults`, `extractUrls`, `buildBodyPreview`, `MAX_EML_BYTES`, `NormalizedMessage`/`AuthResults`/`AttachmentMeta`/`AuthVerdict` types +- `lib/services/eml-parser.fixtures.ts` (227 lines) - synthetic attachment-list fixtures (3 selection tiers + ambiguous/no-eml/empty edge cases) and synthetic raw `.eml` buffers (rich multipart, auth-results-original, inline/CID attachment, long-body, fuzzy-URL, oversized-buffer generator) +- `lib/services/eml-parser.test.ts` (219 lines) - 26 tests across `describe('selectOriginalMessage')`, `describe('parseAuthResults')`, `describe('extractUrls')`, `describe('buildBodyPreview')`, `describe('parseEml')` +- `package.json` / `package-lock.json` - added `mailparser`, `linkify-it`, `@types/mailparser` +- `.planning/phases/16-eml-mime-evidence-parser/deferred-items.md` - logs 2 pre-existing, unrelated `itglue-search.test.ts` failures found via `npm test` (out of scope, not fixed) + +## Decisions Made +- **HTML-to-text fallback implemented by hand, not via `html-to-text`.** RESEARCH.md/the plan suggested using `html-to-text` (already a `mailparser` transitive dependency) for `buildBodyPreview`'s HTML fallback. It ships no bundled TypeScript types and isn't a declared direct dependency — importing it would depend on an un-pinned, unversioned transitive package that could silently disappear on a future `mailparser` version bump. Wrote a small (10-line) regex-based tag/entity stripper instead, fully covered by tests. This was within Claude's explicit discretion per CONTEXT.md ("URL extraction scope... implementation detail, not user-relevant preference"). +- **`linkify-it` constructed with `{ fuzzyLink: true }`.** Verified empirically that linkify-it's default options do NOT enable scheme-less `www.`-prefixed URL matching (`fuzzyLink` defaults to `false`) — without this option, a phishing URL like `www.evil-example.com/login` (no `http://` prefix) would silently not be extracted, undermining EVID-03's URL-extraction requirement. +- **Authentication-Results / Authentication-Results-Original parsed from `mail.headerLines`, not `mail.headers.get(...)`.** Confirmed via research and this session's own live testing that `mail.headers` (a Map) collapses repeated header occurrences; `headerLines` (an ordered array) is the only way to reliably locate both headers when present (Pitfall 4). + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Test discoverability] Renamed a test title to match the plan's required `-t` filter** +- **Found during:** Task 3 verification +- **Issue:** The plan's acceptance criteria require `npx vitest run lib/services/eml-parser.test.ts -t "no network"` to pass. The initial test title ("never makes a network call while parsing any fixture") did not contain the literal substring "no network", so the `-t` filter matched zero tests. +- **Fix:** Renamed the test to "makes no network call (no network fetch) while parsing any fixture". +- **Files modified:** lib/services/eml-parser.test.ts +- **Verification:** `npx vitest run lib/services/eml-parser.test.ts -t "no network"` now passes (1 test). +- **Committed in:** `654e624` (part of Task 3 GREEN commit) + +--- + +**Total deviations:** 1 auto-fixed (test-naming correctness, no behavior change) +**Impact on plan:** Cosmetic only — no scope creep, no behavior change. All three EVID-02/03/04 requirements implemented exactly as specified. + +## Issues Encountered +- This worktree had no `node_modules` at all on start (fresh git worktree checkout) — ran a full `npm install` from the existing `package-lock.json` before the plan's own Task 1 dependency install, so `mailparser`/`linkify-it` could resolve correctly. Not a plan deviation; this is normal worktree bootstrap, not tracked as a commit. +- `npm test` (full suite) surfaced 2 pre-existing failures in `lib/services/analyzer/itglue-search.test.ts`, unrelated to any file this plan touched (last commit on that file predates this plan). Logged to `deferred-items.md`, not fixed, per the executor's scope-boundary rule. + +## User Setup Required + +None - no external service configuration required. `mailparser`/`linkify-it` need no credentials; B2 storage (D-05) and the Autotask attachment-content fetch are Plan 16-03's concern, not this plan's. + +## Next Phase Readiness + +`lib/services/eml-parser.ts`'s exported contract (`selectOriginalMessage`, `parseEml`, `NormalizedMessage`, `AuthResults`, `AttachmentMeta`, `MAX_EML_BYTES`) is stable and ready for Plan 16-03's orchestration layer (fetch attachment content from Autotask → upload to B2 → `parseEml` → persist `messages`/`indicators` rows). No blockers identified for 16-02 or 16-03. + +--- +*Phase: 16-eml-mime-evidence-parser* +*Completed: 2026-07-15* + +## Self-Check: PASSED diff --git a/.planning/phases/16-eml-mime-evidence-parser/deferred-items.md b/.planning/phases/16-eml-mime-evidence-parser/deferred-items.md new file mode 100644 index 0000000..8c927f2 --- /dev/null +++ b/.planning/phases/16-eml-mime-evidence-parser/deferred-items.md @@ -0,0 +1,17 @@ +# Deferred Items — Phase 16 + +Items discovered during execution that are out of scope for the current plan +and were not fixed (per executor scope-boundary rules). + +## From Plan 16-01 + +- **`lib/services/analyzer/itglue-search.test.ts`** — 2 pre-existing test + failures (`returns capped, redacted doc snippets when the org is found`, + `tolerates per-call failures (configurations errors, flex still returns)`), + observed via `npm test` while verifying Plan 16-01. Unrelated to + `eml-parser.ts`/`eml-parser.fixtures.ts`/`eml-parser.test.ts` (no files in + `lib/services/analyzer/` were touched by this plan). Last commit touching + that file (`a0a6e7f fix(itglue): list flexible assets per type to satisfy + API 422 requirement`) predates this plan's work. Not fixed — out of scope + per the executor's scope-boundary rule (only auto-fix issues directly + caused by the current task's changes). diff --git a/lib/services/eml-parser.fixtures.ts b/lib/services/eml-parser.fixtures.ts new file mode 100644 index 0000000..cc145fa --- /dev/null +++ b/lib/services/eml-parser.fixtures.ts @@ -0,0 +1,227 @@ +/** + * Synthetic fixtures for eml-parser.test.ts. Nothing here is real customer + * content — all addresses, subjects, and bodies are invented for testing + * only (per this milestone's explicit synthetic-fixture-only constraint). + */ + +import type { Attachment } from '@/lib/types/autotask'; + +function makeAttachment(overrides: Partial & { id: number }): Attachment { + return { + attachmentType: 'FILE_ATTACHMENT', + fullPath: '', + title: '', + publish: 1, + contentType: 'message/rfc822', + ...overrides, + }; +} + +/** Tier 1: exact `rfc.eml` present among message/rfc822 attachments (Microsoft "Report Message" flow). */ +export const RFC_EML_TIER_ATTACHMENTS: Attachment[] = [ + makeAttachment({ id: 1, title: 'rfc.eml', fullPath: 'rfc.eml' }), + makeAttachment({ id: 2, title: 'OriginatingEmail.eml', fullPath: 'OriginatingEmail.eml' }), +]; + +/** Same tier, but with mixed case to verify case-insensitive matching. */ +export const RFC_EML_TIER_ATTACHMENTS_UPPERCASE: Attachment[] = [ + makeAttachment({ id: 1, title: 'RFC.EML', fullPath: 'RFC.EML' }), + makeAttachment({ id: 2, title: 'OriginatingEmail.EML', fullPath: 'OriginatingEmail.EML' }), +]; + +/** Tier 2: KnowBe4 PhishER flow — versioned filename, no literal rfc.eml. */ +export const KNOWBE4_TIER_ATTACHMENTS: Attachment[] = [ + makeAttachment({ + id: 10, + title: 'phish_alert_sp2_2.0.0.0.eml', + fullPath: 'phish_alert_sp2_2.0.0.0.eml', + }), + makeAttachment({ id: 11, title: 'OriginatingEmail.eml', fullPath: 'OriginatingEmail.eml' }), +]; + +/** Tier 3: only OriginatingEmail.eml present (older tickets, majority-case fallback). */ +export const ORIGINATING_ONLY_ATTACHMENTS: Attachment[] = [ + makeAttachment({ id: 20, title: 'OriginatingEmail.eml', fullPath: 'OriginatingEmail.eml' }), +]; + +/** Ambiguous: two non-OriginatingEmail message/rfc822 candidates, no rfc.eml — falls back to OriginatingEmail.eml. */ +export const AMBIGUOUS_WITH_FALLBACK_ATTACHMENTS: Attachment[] = [ + makeAttachment({ id: 30, title: 'weird_name_1.eml', fullPath: 'weird_name_1.eml' }), + makeAttachment({ id: 31, title: 'weird_name_2.eml', fullPath: 'weird_name_2.eml' }), + makeAttachment({ id: 32, title: 'OriginatingEmail.eml', fullPath: 'OriginatingEmail.eml' }), +]; + +/** Ambiguous with no fallback available — selectOriginalMessage should return null. */ +export const AMBIGUOUS_NO_FALLBACK_ATTACHMENTS: Attachment[] = [ + makeAttachment({ id: 40, title: 'weird_name_1.eml', fullPath: 'weird_name_1.eml' }), + makeAttachment({ id: 41, title: 'weird_name_2.eml', fullPath: 'weird_name_2.eml' }), +]; + +/** No .eml attachments at all — selectOriginalMessage should return null. */ +export const NO_EML_ATTACHMENTS: Attachment[] = [ + makeAttachment({ + id: 50, + title: 'screenshot.png', + fullPath: 'screenshot.png', + contentType: 'image/png', + }), +]; + +/** Empty attachment list. */ +export const EMPTY_ATTACHMENTS: Attachment[] = []; + +// --------------------------------------------------------------------------- +// Synthetic raw .eml buffers for parseEml (EVID-03/EVID-04). All addresses, +// bodies, and content below are invented for testing only — no real +// customer email content per this milestone's Out of Scope constraint. +// --------------------------------------------------------------------------- + +/** + * Rich multipart fixture: text + html bodies (each carrying the same URL), + * one non-inline base64 attachment, and an Authentication-Results header + * with spf=pass, dkim=fail, dmarc=none. + */ +export const RICH_MULTIPART_EML = Buffer.from( + `From: "Attacker Corp" +To: victim@wulfconsulting.test +Cc: cc-user@wulfconsulting.test +Reply-To: reply@evil-example.test +Return-Path: +Subject: Urgent: verify your account +Date: Mon, 15 Jul 2026 12:00:00 +0000 +Message-ID: +Authentication-Results: mx.wulfconsulting.test; spf=pass smtp.mailfrom=evil-example.test; dkim=fail header.d=evil-example.test; dmarc=none header.from=evil-example.test +Received: from mx1.example.test by mx2.example.test; Mon, 15 Jul 2026 11:59:00 +0000 +Received: from mx0.example.test by mx1.example.test; Mon, 15 Jul 2026 11:58:00 +0000 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="BOUNDARY1" + +--BOUNDARY1 +Content-Type: multipart/alternative; boundary="BOUNDARY2" + +--BOUNDARY2 +Content-Type: text/plain; charset="UTF-8" + +Please visit http://evil-example.test/verify to verify your account. + +--BOUNDARY2 +Content-Type: text/html; charset="UTF-8" + +

Please visit this link to verify your account.

+ +--BOUNDARY2-- + +--BOUNDARY1 +Content-Type: application/pdf; name="invoice.pdf" +Content-Disposition: attachment; filename="invoice.pdf" +Content-Transfer-Encoding: base64 + +SGVsbG8gV29ybGQh + +--BOUNDARY1-- +` +); + +/** Same as RICH_MULTIPART_EML but also carries Authentication-Results-Original. */ +export const RICH_MULTIPART_WITH_AUTH_ORIGINAL_EML = Buffer.from( + `From: "Attacker Corp" +To: victim@wulfconsulting.test +Subject: Urgent: verify your account (remediated) +Date: Mon, 15 Jul 2026 12:00:00 +0000 +Message-ID: +Authentication-Results: mx.wulfconsulting.test; spf=fail smtp.mailfrom=evil-example.test; dkim=fail header.d=evil-example.test; dmarc=fail header.from=evil-example.test +Authentication-Results-Original: mx.wulfconsulting.test; spf=pass smtp.mailfrom=evil-example.test; dkim=pass header.d=evil-example.test; dmarc=pass header.from=evil-example.test +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" + +Body text for the remediated-header fixture. +` +); + +/** + * Inline/CID attachment fixture: html references cid:sig123, wrapped in + * multipart/related so mailparser marks the image attachment `related: true` + * (Pitfall 5 — inline parts must be kept, not dropped). + */ +export const INLINE_ATTACHMENT_EML = Buffer.from( + `From: sender@evil-example.test +To: victim@wulfconsulting.test +Subject: Newsletter with inline logo +Date: Mon, 15 Jul 2026 12:00:00 +0000 +Message-ID: +MIME-Version: 1.0 +Content-Type: multipart/related; boundary="RELBOUND" + +--RELBOUND +Content-Type: multipart/alternative; boundary="ALTBOUND" + +--ALTBOUND +Content-Type: text/plain; charset="UTF-8" + +Plain text body with an inline logo. + +--ALTBOUND +Content-Type: text/html; charset="UTF-8" + +

Hello

+ +--ALTBOUND-- + +--RELBOUND +Content-Type: image/png; name="sig.png" +Content-Disposition: inline +Content-ID: +Content-Transfer-Encoding: base64 + +iVBORw0KGgo= + +--RELBOUND-- +` +); + +/** Long-body fixture — long enough to force buildBodyPreview truncation. */ +const LONG_PARAGRAPH = + 'This is a long paragraph of synthetic phishing-style body text repeated ' + + 'several times to exceed the body preview truncation threshold. '.repeat(20); + +export const LONG_BODY_EML = Buffer.from( + `From: sender@evil-example.test +To: victim@wulfconsulting.test +Subject: Long body fixture +Date: Mon, 15 Jul 2026 12:00:00 +0000 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" + +${LONG_PARAGRAPH} +` +); + +/** Fuzzy (scheme-less) www. URL fixture, to exercise linkify-it's fuzzyLink mode. */ +export const FUZZY_URL_EML = Buffer.from( + `From: sender@evil-example.test +To: victim@wulfconsulting.test +Subject: Fuzzy URL fixture +Date: Mon, 15 Jul 2026 12:00:00 +0000 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" + +Visit www.evil-example.com/login to reset your password. +` +); + +/** Oversized buffer — exceeds MAX_EML_BYTES, must be rejected before simpleParser runs. */ +export function makeOversizedEmlBuffer(maxBytes: number): Buffer { + const header = `From: sender@evil-example.test +To: victim@wulfconsulting.test +Subject: Oversized fixture +Date: Mon, 15 Jul 2026 12:00:00 +0000 +Message-ID: +MIME-Version: 1.0 +Content-Type: text/plain; charset="UTF-8" + +`; + const padding = 'A'.repeat(maxBytes + 1024 - header.length); + return Buffer.from(header + padding); +} diff --git a/lib/services/eml-parser.test.ts b/lib/services/eml-parser.test.ts new file mode 100644 index 0000000..4918ab1 --- /dev/null +++ b/lib/services/eml-parser.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + selectOriginalMessage, + parseEml, + parseAuthResults, + extractUrls, + buildBodyPreview, + MAX_EML_BYTES, +} from './eml-parser'; +import { + RFC_EML_TIER_ATTACHMENTS, + RFC_EML_TIER_ATTACHMENTS_UPPERCASE, + KNOWBE4_TIER_ATTACHMENTS, + ORIGINATING_ONLY_ATTACHMENTS, + AMBIGUOUS_WITH_FALLBACK_ATTACHMENTS, + AMBIGUOUS_NO_FALLBACK_ATTACHMENTS, + NO_EML_ATTACHMENTS, + EMPTY_ATTACHMENTS, + RICH_MULTIPART_EML, + RICH_MULTIPART_WITH_AUTH_ORIGINAL_EML, + INLINE_ATTACHMENT_EML, + LONG_BODY_EML, + FUZZY_URL_EML, + makeOversizedEmlBuffer, +} from './eml-parser.fixtures'; + +describe('selectOriginalMessage', () => { + it('selects rfc.eml over OriginatingEmail.eml when both are present', () => { + const result = selectOriginalMessage(RFC_EML_TIER_ATTACHMENTS); + expect(result?.id).toBe(1); + expect(result?.title).toBe('rfc.eml'); + }); + + it('selects rfc.eml case-insensitively (RFC.EML / OriginatingEmail.EML)', () => { + const result = selectOriginalMessage(RFC_EML_TIER_ATTACHMENTS_UPPERCASE); + expect(result?.id).toBe(1); + }); + + it('selects the KnowBe4-named attachment when OriginatingEmail.eml is also present', () => { + const result = selectOriginalMessage(KNOWBE4_TIER_ATTACHMENTS); + expect(result?.id).toBe(10); + expect(result?.title).toBe('phish_alert_sp2_2.0.0.0.eml'); + }); + + it('falls back to OriginatingEmail.eml when it is the only attachment', () => { + const result = selectOriginalMessage(ORIGINATING_ONLY_ATTACHMENTS); + expect(result?.id).toBe(20); + expect(result?.title).toBe('OriginatingEmail.eml'); + }); + + it('falls back to OriginatingEmail.eml when there are 2+ ambiguous non-OriginatingEmail candidates', () => { + const result = selectOriginalMessage(AMBIGUOUS_WITH_FALLBACK_ATTACHMENTS); + expect(result?.id).toBe(32); + expect(result?.title).toBe('OriginatingEmail.eml'); + }); + + it('returns null when candidates are ambiguous and no OriginatingEmail.eml fallback exists', () => { + const result = selectOriginalMessage(AMBIGUOUS_NO_FALLBACK_ATTACHMENTS); + expect(result).toBeNull(); + }); + + it('returns null (not throws) for a list with no .eml / message-rfc822 attachments', () => { + expect(() => selectOriginalMessage(NO_EML_ATTACHMENTS)).not.toThrow(); + expect(selectOriginalMessage(NO_EML_ATTACHMENTS)).toBeNull(); + }); + + it('returns null for an empty attachment list', () => { + expect(selectOriginalMessage(EMPTY_ATTACHMENTS)).toBeNull(); + }); +}); + +describe('parseAuthResults', () => { + it('parses spf/dkim/dmarc verdicts from a raw Authentication-Results header value', () => { + const result = parseAuthResults( + 'mx.wulfconsulting.test; spf=pass smtp.mailfrom=evil-example.test; dkim=fail header.d=evil-example.test; dmarc=none header.from=evil-example.test' + ); + expect(result).toEqual({ spf: 'pass', dkim: 'fail', dmarc: 'none' }); + }); + + it('is case-insensitive on method and result tokens', () => { + const result = parseAuthResults('mx.test; SPF=PASS; DKIM=Fail; DMARC=None'); + expect(result).toEqual({ spf: 'pass', dkim: 'fail', dmarc: 'none' }); + }); + + it('omits methods not present in the header', () => { + const result = parseAuthResults('mx.test; spf=softfail'); + expect(result).toEqual({ spf: 'softfail' }); + }); +}); + +describe('extractUrls', () => { + it('extracts and dedupes URLs from both text and html parts', () => { + const urls = extractUrls( + 'Visit http://evil-example.test/verify now.', + 'link' + ); + expect(urls).toEqual(['http://evil-example.test/verify']); + }); + + it('extracts fuzzy (scheme-less) www. URLs', () => { + const urls = extractUrls('Visit www.evil-example.com/login to reset.', null); + expect(urls.some((u) => u.includes('evil-example.com/login'))).toBe(true); + }); + + it('returns an empty array when no URLs are present', () => { + expect(extractUrls('no links here', null)).toEqual([]); + }); + + it('handles null/undefined text and html gracefully', () => { + expect(extractUrls(null, undefined)).toEqual([]); + }); +}); + +describe('buildBodyPreview', () => { + it('prefers plain text over html', () => { + const preview = buildBodyPreview('plain text body', '

html body

'); + expect(preview).toContain('plain text body'); + }); + + it('falls back to a stripped version of html when text is absent', () => { + const preview = buildBodyPreview(null, '

Hello world

'); + expect(preview).toContain('Hello'); + expect(preview).toContain('world'); + expect(preview).not.toContain('

'); + expect(preview).not.toContain(''); + }); + + it('truncates a long body and stays distinct from the raw text', () => { + const longText = 'x'.repeat(2000); + const preview = buildBodyPreview(longText, null); + expect(preview.length).toBeLessThan(longText.length); + expect(preview).not.toBe(longText); + }); +}); + +describe('parseEml', () => { + it('normalizes headers, auth results, received chain, urls, and attachment metadata from a synthetic fixture', async () => { + const result = await parseEml(RICH_MULTIPART_EML); + + expect(result.from.email).toBe('attacker@evil-example.test'); + expect(result.from.displayName).toBe('Attacker Corp'); + expect(result.from.domain).toBe('evil-example.test'); + expect(result.replyTo).toBe('reply@evil-example.test'); + expect(result.returnPath).toBe('bounce@evil-example.test'); + expect(result.to).toContain('victim@wulfconsulting.test'); + expect(result.cc).toContain('cc-user@wulfconsulting.test'); + expect(result.subject).toBe('Urgent: verify your account'); + expect(result.date).toBeTruthy(); + expect(result.messageId).toBe(''); + expect(result.receivedChain).toHaveLength(2); + + expect(result.authResults).toEqual({ spf: 'pass', dkim: 'fail', dmarc: 'none' }); + expect(result.authResultsOriginal).toBeNull(); + + expect(result.urls).toContain('http://evil-example.test/verify'); + + expect(result.attachments).toHaveLength(1); + expect(result.attachments[0].filename).toBe('invoice.pdf'); + expect(result.attachments[0].contentType).toBe('application/pdf'); + expect(result.attachments[0].checksum).toMatch(/^[a-f0-9]{64}$/); + expect(result.attachments[0].related).toBe(false); + + expect(result.bodyPreview).toBeTruthy(); + }); + + it('populates authResultsOriginal when an Authentication-Results-Original header is present', async () => { + const result = await parseEml(RICH_MULTIPART_WITH_AUTH_ORIGINAL_EML); + expect(result.authResults).toEqual({ spf: 'fail', dkim: 'fail', dmarc: 'fail' }); + expect(result.authResultsOriginal).toEqual({ spf: 'pass', dkim: 'pass', dmarc: 'pass' }); + }); + + it('preserves inline/related attachments rather than dropping them', async () => { + const result = await parseEml(INLINE_ATTACHMENT_EML); + expect(result.attachments).toHaveLength(1); + expect(result.attachments[0].related).toBe(true); + }); + + it('produces a body preview that is truncated and distinct from the raw body', async () => { + const result = await parseEml(LONG_BODY_EML); + expect(result.bodyPreview.length).toBeLessThan(2000); + }); + + it('extracts fuzzy www. URLs from a real parsed message', async () => { + const result = await parseEml(FUZZY_URL_EML); + expect(result.urls.some((u) => u.includes('evil-example.com/login'))).toBe(true); + }); + + it('makes no network call (no network fetch) while parsing any fixture', async () => { + const fetchSpy = vi.spyOn(global, 'fetch'); + await parseEml(RICH_MULTIPART_EML); + await parseEml(RICH_MULTIPART_WITH_AUTH_ORIGINAL_EML); + await parseEml(INLINE_ATTACHMENT_EML); + await parseEml(LONG_BODY_EML); + await parseEml(FUZZY_URL_EML); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it('rejects a buffer larger than MAX_EML_BYTES before simpleParser is called', async () => { + const oversized = makeOversizedEmlBuffer(MAX_EML_BYTES); + expect(oversized.byteLength).toBeGreaterThan(MAX_EML_BYTES); + await expect(parseEml(oversized)).rejects.toThrow(); + }); + + it('does not contain any real customer email content in any fixture', () => { + const allFixtures = [ + RICH_MULTIPART_EML, + RICH_MULTIPART_WITH_AUTH_ORIGINAL_EML, + INLINE_ATTACHMENT_EML, + LONG_BODY_EML, + FUZZY_URL_EML, + ]; + for (const fixture of allFixtures) { + const text = fixture.toString('utf-8'); + expect(text).not.toMatch(/wulfconsulting\.com/); + expect(text.includes('evil-example.test') || text.includes('evil-example.com')).toBe(true); + } + }); +}); diff --git a/lib/services/eml-parser.ts b/lib/services/eml-parser.ts new file mode 100644 index 0000000..985d868 --- /dev/null +++ b/lib/services/eml-parser.ts @@ -0,0 +1,282 @@ +/** + * EML/MIME evidence parser for the phishing-triage pipeline (Phase 16). + * + * Turns a raw RFC822/MIME `.eml` buffer (an attacker-controlled email a + * user reported as phishing/spam) into a normalized, structured + * `NormalizedMessage` — headers, structured SPF/DKIM/DMARC verdicts, + * URLs, and attachment metadata — using `mailparser` for MIME parsing + * and a small hand-rolled RFC 8601 tokenizer for Authentication-Results. + * + * Hard invariant (SC#3 / EVID-04 / T-16-03): this module must never fetch + * or execute anything found in a message. It never dereferences an + * extracted URL, never renders `mail.html`, and never performs any + * outbound network call while parsing. This is test-enforced with a + * `global.fetch` spy in eml-parser.test.ts. + * + * Also implements `selectOriginalMessage` (EVID-02) — a pure, I/O-free + * decision over ticket attachment metadata already in hand, choosing the + * originally-reported message from a ticket's attachment list. + */ + +import { simpleParser } from 'mailparser'; +import type { AddressObject, HeaderValue, ParsedMail } from 'mailparser'; +import { linkifyit } from 'linkify-it'; +import type { Attachment } from '@/lib/types/autotask'; + +/** Basename of an attachment's filename, lowercased, for tier matching. */ +function attachmentName(att: Attachment): string { + const raw = att.fullPath || att.title || ''; + const base = raw.split('/').pop() || raw; + return base.toLowerCase(); +} + +function isMessageRfc822(att: Attachment): boolean { + return (att.contentType || '').toLowerCase() === 'message/rfc822'; +} + +/** + * Three-tier `.eml` attachment selection, empirically validated against 15 + * real phishing tickets (see 16-RESEARCH.md Pitfall 1): + * + * 1. An attachment named exactly `rfc.eml` (case-insensitive) among + * `message/rfc822` attachments — the Microsoft "Report Message" flow. + * 2. Else, among `message/rfc822` attachments, exclude any named exactly + * `OriginatingEmail.eml` (case-insensitive) — if exactly one candidate + * remains, select it (covers KnowBe4's versioned filenames, e.g. + * `phish_alert_sp2_2.0.0.0.eml`). + * 3. Else (0 or 2+ ambiguous candidates after step 2) — fall back to + * `OriginatingEmail.eml` if present; otherwise return null. + */ +export function selectOriginalMessage(attachments: Attachment[]): Attachment | null { + const rfc822Attachments = attachments.filter(isMessageRfc822); + + const exactRfcEml = rfc822Attachments.find((att) => attachmentName(att) === 'rfc.eml'); + if (exactRfcEml) return exactRfcEml; + + const nonOriginatingCandidates = rfc822Attachments.filter( + (att) => attachmentName(att) !== 'originatingemail.eml' + ); + if (nonOriginatingCandidates.length === 1) return nonOriginatingCandidates[0]; + + const originatingFallback = rfc822Attachments.find( + (att) => attachmentName(att) === 'originatingemail.eml' + ); + return originatingFallback ?? null; +} + +// --------------------------------------------------------------------------- +// parseEml + auth-results + URLs + body preview (EVID-03, EVID-04, D-06) +// --------------------------------------------------------------------------- + +/** Hard cap on bytes this module will hand to `simpleParser`. Below B2's 25 MB cap (T-16-01). */ +export const MAX_EML_BYTES = 10 * 1024 * 1024; // 10 MB + +/** Max length of the derived plain-text body preview (EVID-04). */ +const MAX_BODY_PREVIEW_LENGTH = 500; + +export type AuthVerdict = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror'; + +export interface AuthResults { + spf?: AuthVerdict; + dkim?: AuthVerdict; + dmarc?: AuthVerdict; +} + +export interface AttachmentMeta { + filename: string | null; + contentType: string | null; + size: number; + checksum: string | null; + related: boolean; +} + +export 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 + urls: string[]; // deduped, from text + html parts + attachments: AttachmentMeta[]; // includes inline/related, distinguished by `related` + bodyPreview: string; // truncated plain text, distinct from raw body +} + +/** + * Hand-rolled RFC 8601 Authentication-Results tokenizer (D-06). Deliberately + * NOT delegated to `mailauth` — that package's public API performs live + * DNS/HTTP verification (SC#3 violation); this only re-reads the verdict a + * receiving mail server already stamped on the header. + * + * Grammar (simplified): `authserv-id; method1=result1 (comment); method2=result2 ...` + */ +export function parseAuthResults(headerValue: string): AuthResults { + const result: AuthResults = {}; + for (const clause of headerValue.split(';')) { + const match = clause.trim().match(/^(spf|dkim|dmarc)=(\w+)/i); + if (!match) continue; + const method = match[1].toLowerCase() as 'spf' | 'dkim' | 'dmarc'; + const verdict = match[2].toLowerCase() as AuthVerdict; + result[method] = verdict; + } + return result; +} + +// fuzzyLink enables scheme-less `www.`-prefixed URL detection (phishing URLs +// frequently omit the scheme). Never used to fetch/dereference — string +// matching only. +const linkify = linkifyit({ fuzzyLink: true }); + +/** + * Extracts and dedupes http(s)/www URLs found in the text and html parts of + * a message. Never fetches or dereferences any extracted URL (SC#3 / T-16-03) + * — this is pure string matching via `linkify-it`. + */ +export function extractUrls( + text: string | null | undefined, + html: string | null | undefined +): string[] { + const urls = new Set(); + for (const source of [text, html]) { + if (!source) continue; + const matches = linkify.match(source) ?? []; + for (const match of matches) { + if (match.schema === 'mailto:') continue; + urls.add(match.url); + } + } + return Array.from(urls); +} + +/** Strips HTML tags/entities down to plain text. Never renders/executes the HTML. */ +function stripHtmlToText(html: string): string { + return html + .replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Builds a sanitized, truncated plain-text body preview — prefers the + * already-safe `mail.text`, falling back to a stripped version of + * `mail.html` when no plain-text part exists. Never returns raw HTML. + */ +export function buildBodyPreview( + text: string | null | undefined, + html: string | null | undefined +): string { + const source = text && text.trim() ? text : html ? stripHtmlToText(html) : ''; + const normalized = source.replace(/\s+/g, ' ').trim(); + if (normalized.length <= MAX_BODY_PREVIEW_LENGTH) return normalized; + return normalized.slice(0, MAX_BODY_PREVIEW_LENGTH).trimEnd() + '…'; +} + +/** Extracts a single email address string from a mailparser HeaderValue (used for Return-Path). */ +function headerValueToAddress(value: HeaderValue | undefined): string | null { + if (!value) return null; + if (typeof value === 'string') { + const cleaned = value.replace(/^$/, '').trim(); + return cleaned || null; + } + if (Array.isArray(value)) return null; + if (typeof value === 'object' && 'value' in value) { + const addressObject = value as AddressObject; + return addressObject.value?.[0]?.address ?? null; + } + return null; +} + +/** Flattens one or more mailparser AddressObjects into a list of email address strings. */ +function addressListToStrings(addr: AddressObject | AddressObject[] | undefined): string[] { + if (!addr) return []; + const objects = Array.isArray(addr) ? addr : [addr]; + const addresses: string[] = []; + for (const obj of objects) { + for (const entry of obj.value ?? []) { + if (entry.address) addresses.push(entry.address); + } + } + return addresses; +} + +/** Returns the raw header text (post-colon) for the first headerLine matching `key`, or null. */ +function findHeaderLineValue(mail: ParsedMail, key: string): string | null { + const line = mail.headerLines.find((candidate) => candidate.key === key); + if (!line) return null; + const colonIndex = line.line.indexOf(':'); + return (colonIndex === -1 ? line.line : line.line.slice(colonIndex + 1)).trim(); +} + +/** + * Parses a raw RFC822/MIME `.eml` buffer into a normalized, structured + * `NormalizedMessage`. Enforces `MAX_EML_BYTES` BEFORE calling `simpleParser` + * (DoS mitigation, T-16-01) and never performs any network I/O (SC#3). + */ +export async function parseEml(rawEmlBuffer: Buffer): Promise { + if (rawEmlBuffer.byteLength > MAX_EML_BYTES) { + throw new Error( + `[EML-PARSER] Buffer size ${rawEmlBuffer.byteLength} exceeds MAX_EML_BYTES (${MAX_EML_BYTES}); refusing to parse` + ); + } + + let mail: ParsedMail; + try { + mail = await simpleParser(rawEmlBuffer, { checksumAlgo: 'sha256' }); + } catch (error) { + console.error('[EML-PARSER] Failed to parse .eml buffer', error); + throw error; + } + + const fromEntry = mail.from?.value?.[0]; + const fromEmail = fromEntry?.address ?? null; + const fromDomain = fromEmail?.includes('@') ? fromEmail.split('@')[1] ?? null : null; + + const receivedChain: string[] = []; + for (const line of mail.headerLines) { + if (line.key === 'received') receivedChain.push(line.line); + } + + const primaryAuthResultsHeader = findHeaderLineValue(mail, 'authentication-results'); + const originalAuthResultsHeader = findHeaderLineValue(mail, 'authentication-results-original'); + + return { + from: { + displayName: fromEntry?.name || null, + email: fromEmail, + domain: fromDomain, + }, + replyTo: mail.replyTo?.value?.[0]?.address ?? null, + returnPath: headerValueToAddress(mail.headers.get('return-path')), + to: addressListToStrings(mail.to), + cc: addressListToStrings(mail.cc), + subject: mail.subject ?? null, + date: mail.date ? mail.date.toISOString() : null, + messageId: mail.messageId ?? null, + receivedChain, + authResults: primaryAuthResultsHeader ? parseAuthResults(primaryAuthResultsHeader) : {}, + authResultsOriginal: originalAuthResultsHeader + ? parseAuthResults(originalAuthResultsHeader) + : null, + urls: extractUrls(mail.text, mail.html || null), + attachments: mail.attachments.map((att) => ({ + filename: att.filename ?? null, + contentType: att.contentType ?? null, + size: att.size, + checksum: att.checksum ?? null, + related: Boolean(att.related), + })), + bodyPreview: buildBodyPreview(mail.text, mail.html || null), + }; +} diff --git a/package-lock.json b/package-lock.json index 27f4151..fba10db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,9 @@ "date-fns": "^4.1.0", "dotenv": "^17.2.3", "ioredis": "^5.9.0", + "linkify-it": "^6.0.0", "lucide-react": "^0.562.0", + "mailparser": "^3.9.14", "next": "16.1.1", "next-themes": "^0.4.6", "node-cron": "^4.2.1", @@ -59,6 +61,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", + "@types/mailparser": "^3.4.6", "@types/node": "^20.19.27", "@types/nodemailer": "^7.0.4", "@types/pg": "^8.16.0", @@ -5338,6 +5341,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz", + "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==", + "license": "MIT", + "dependencies": { + "domelementtype": "~2.3.0", + "domhandler": "~5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + }, + "peerDependencies": { + "selderee": "~0.12.0" + } + }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", @@ -6528,6 +6547,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mailparser": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz", + "integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "iconv-lite": "^0.6.3" + } + }, + "node_modules/@types/mailparser/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -7324,6 +7367,17 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.14", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.14.tgz", + "integrity": "sha512-rz0FQOhN3Vq1XrSeSSa9+dPcaFbBxmQPjiZm6zS9oxdVHV7rOWIAYX3yP2YAUf0qBncY8CI+NogzPCmMVrMXcw==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.4.1", + "libqp": "2.1.1" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -8947,6 +9001,15 @@ "node": ">=0.10.0" } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/default-browser": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", @@ -9114,6 +9177,61 @@ "node": ">=0.10.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -9357,6 +9475,15 @@ "node": ">= 0.8" } }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -9380,6 +9507,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -11017,6 +11156,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/headers-polyfill": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", @@ -11062,6 +11210,25 @@ "node": ">=16.9.0" } }, + "node_modules/html-to-text": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.0.tgz", + "integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "~0.12.0", + "deepmerge-ts": "^7.1.5", + "dom-serializer": "^2.0.0", + "htmlparser2": "^10.1.0", + "selderee": "~0.12.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -11072,6 +11239,37 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -11118,10 +11316,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -12133,6 +12330,15 @@ "node": ">=0.10" } }, + "node_modules/leac": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz", + "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -12147,6 +12353,30 @@ "node": ">= 0.8.0" } }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.1.tgz", + "integrity": "sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -12424,6 +12654,25 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.0.0.tgz", + "integrity": "sha512-YXaVuD1L9iL52IsWy5WsfHbzJjjDL5VzbW7ARliFbB+oHxHXb+fh2qjYl34PGhyy06x3LCvuAuvWdaSFte0P1w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -12559,6 +12808,52 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mailparser": { + "version": "3.9.14", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.14.tgz", + "integrity": "sha512-3QD6TRXcyXtq2NCuyA2AEjqmallQkyxYmZI9GMCIvQDCaB9Uc034WUI1x8RUBYFnk5+p7h14JEz4O/lrxQmttw==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.14", + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "10.0.0", + "iconv-lite": "0.7.3", + "libmime": "5.4.1", + "linkify-it": "5.0.2", + "nodemailer": "9.0.3", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + } + }, + "node_modules/mailparser/node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/mailparser/node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -14337,6 +14632,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parseley": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz", + "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==", + "license": "MIT", + "dependencies": { + "leac": "^0.7.0", + "peberminta": "^0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -14394,6 +14702,15 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/peberminta": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz", + "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/perfect-debounce": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz", @@ -14790,6 +15107,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode.react": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", @@ -15771,7 +16097,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/scheduler": { @@ -15780,6 +16105,18 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/selderee": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz", + "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==", + "license": "MIT", + "dependencies": { + "parseley": "~0.13.1" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -16878,6 +17215,15 @@ "node": ">=14.0.0" } }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, "node_modules/tldts": { "version": "7.0.30", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", @@ -17198,6 +17544,12 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index 9d7217c..e99a78e 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "date-fns": "^4.1.0", "dotenv": "^17.2.3", "ioredis": "^5.9.0", + "linkify-it": "^6.0.0", "lucide-react": "^0.562.0", + "mailparser": "^3.9.14", "next": "16.1.1", "next-themes": "^0.4.6", "node-cron": "^4.2.1", @@ -62,6 +64,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", + "@types/mailparser": "^3.4.6", "@types/node": "^20.19.27", "@types/nodemailer": "^7.0.4", "@types/pg": "^8.16.0",