GREEN: parseEml normalizes headers (From/Reply-To/Return-Path/To/Cc/ Subject/Date/Message-ID), builds the ordered Received chain from mail.headerLines, and maps mailparser attachments to AttachmentMeta (name/content-type/size/sha256 checksum, related flag preserved for inline/CID parts per Pitfall 5). parseAuthResults hand-rolls RFC 8601 Authentication-Results parsing (spf/dkim/dmarc verdicts) rather than using mailauth, which performs live DNS/HTTP verification (SC#3 violation). Both Authentication-Results and Authentication-Results-Original are read via mail.headerLines (Pitfall 4 — headers Map only exposes one occurrence of a repeated header) and parsed into distinct authResults/authResultsOriginal fields. extractUrls uses linkify-it with fuzzyLink enabled (scheme-less www. URLs) scanning both text and html parts, deduped, never dereferenced. buildBodyPreview prefers mail.text, falling back to a small hand-rolled HTML-to-text stripper (not the undeclared transitive html-to-text dependency — see SUMMARY deviations) when only HTML exists; truncated to 500 chars. MAX_EML_BYTES (10 MB, below B2's 25 MB cap) is enforced before simpleParser is ever called (T-16-01 DoS guard). 26/26 tests pass; tsc clean for eml-parser files; full npm test run confirms 2 pre-existing itglue-search.test.ts failures are unrelated (logged to deferred-items.md).
219 lines
8.4 KiB
TypeScript
219 lines
8.4 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|