chore: merge executor worktree (worktree-agent-a692342f48398a2c2)

This commit is contained in:
lorentz 2026-07-15 10:32:38 -04:00
commit 49dd22bbd6
7 changed files with 1226 additions and 5 deletions

View file

@ -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

View file

@ -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).

View file

@ -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<Attachment> & { 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" <attacker@evil-example.test>
To: victim@wulfconsulting.test
Cc: cc-user@wulfconsulting.test
Reply-To: reply@evil-example.test
Return-Path: <bounce@evil-example.test>
Subject: Urgent: verify your account
Date: Mon, 15 Jul 2026 12:00:00 +0000
Message-ID: <rich1@evil-example.test>
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"
<html><body><p>Please visit <a href="http://evil-example.test/verify">this link</a> to verify your account.</p></body></html>
--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" <attacker@evil-example.test>
To: victim@wulfconsulting.test
Subject: Urgent: verify your account (remediated)
Date: Mon, 15 Jul 2026 12:00:00 +0000
Message-ID: <rich2@evil-example.test>
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: <inline1@evil-example.test>
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"
<html><body><p>Hello</p><img src="cid:sig123"></body></html>
--ALTBOUND--
--RELBOUND
Content-Type: image/png; name="sig.png"
Content-Disposition: inline
Content-ID: <sig123>
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: <long1@evil-example.test>
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: <fuzzy1@evil-example.test>
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: <oversized1@evil-example.test>
MIME-Version: 1.0
Content-Type: text/plain; charset="UTF-8"
`;
const padding = 'A'.repeat(maxBytes + 1024 - header.length);
return Buffer.from(header + padding);
}

View file

@ -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.',
'<a href="http://evil-example.test/verify">link</a>'
);
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', '<p>html body</p>');
expect(preview).toContain('plain text body');
});
it('falls back to a stripped version of html when text is absent', () => {
const preview = buildBodyPreview(null, '<p>Hello <b>world</b></p>');
expect(preview).toContain('Hello');
expect(preview).toContain('world');
expect(preview).not.toContain('<p>');
expect(preview).not.toContain('<b>');
});
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('<rich1@evil-example.test>');
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);
}
});
});

282
lib/services/eml-parser.ts Normal file
View file

@ -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<string>();
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(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/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(/^</, '').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<NormalizedMessage> {
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),
};
}

362
package-lock.json generated
View file

@ -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",

View file

@ -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",