Initial Commit
This commit is contained in:
74
packages/communication/emails/dmarc/README.md
Normal file
74
packages/communication/emails/dmarc/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# @suaveplan/dmarc - DMARC report parser — parses aggregate (RUA) and forensic (RUF) DMARC XML reports into typed structures
|
||||
|
||||
## Summary
|
||||
|
||||
DMARC report parser for `@suaveplan` — parses aggregate (RUA) and forensic (RUF) DMARC XML reports into validated, strongly-typed structures. Accepts raw XML strings or gzip-compressed binary blobs.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [gzip](./src/decoders/gzip.md) — Provides gzip decompression and input-decoding utilities for `@suaveplan/dmarc`. DMARC aggregate and forensic reports a…
|
||||
- [xml](./src/decoders/xml.md) — Wraps `fast-xml-parser` with a fixed, strict configuration tuned for DMARC report XML. The module isolates all parser c…
|
||||
- [errors](./src/errors/dmarc-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [dmarc-error](./src/errors/dmarc-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [dmarc-otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [aggregate](./src/parsers/aggregate.md) — Implements `parseDmarcAggregate`, the primary entry point for converting raw DMARC aggregate (RUA) report data into val…
|
||||
- [forensic](./src/parsers/forensic.md) — Implements `parseDmarcForensic`, the entry point for parsing DMARC forensic (RUF) reports. Forensic reports notify doma…
|
||||
<!-- end:auto-docs --> - [dmarc-error](./src/errors/dmarc-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [dmarc-otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [aggregate](./src/parsers/aggregate.md) — Implements `parseDmarcAggregate`, the primary entry point for converting raw DMARC aggregate (RUA) report data into val…
|
||||
- [forensic](./src/parsers/forensic.md) — Implements `parseDmarcForensic`, the entry point for parsing DMARC forensic (RUF) reports. Forensic reports notify doma…
|
||||
- [dmarc-error](./src/errors/dmarc-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [dmarc-otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [aggregate](./src/parsers/aggregate.md) — Implements `parseDmarcAggregate`, the primary entry point for converting raw DMARC aggregate (RUA) report data into val…
|
||||
- [forensic](./src/parsers/forensic.md) — Implements `parseDmarcForensic`, the entry point for parsing DMARC forensic (RUF) reports. Forensic reports notify doma…
|
||||
- [dmarc-error](./src/errors/dmarc-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [dmarc-otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [aggregate](./src/parsers/aggregate.md) — Implements `parseDmarcAggregate`, the primary entry point for converting raw DMARC aggregate (RUA) report data into val…
|
||||
- [forensic](./src/parsers/forensic.md) — Implements `parseDmarcForensic`, the entry point for parsing DMARC forensic (RUF) reports. Forensic reports notify doma…
|
||||
- [dmarc-error](./src/errors/dmarc-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [dmarc-otel](./src/otel/dmarc-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [aggregate](./src/parsers/aggregate.md) — Implements `parseDmarcAggregate`, the primary entry point for converting raw DMARC aggregate (RUA) report data into val…
|
||||
- [forensic](./src/parsers/forensic.md) — Implements `parseDmarcForensic`, the entry point for parsing DMARC forensic (RUF) reports. Forensic reports notify doma…
|
||||
|
||||
## Packages
|
||||
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| [`src/parsers/aggregate.ts`](src/parsers/aggregate.md) | Parse DMARC aggregate (RUA) reports |
|
||||
| [`src/parsers/forensic.ts`](src/parsers/forensic.md) | Parse DMARC forensic (RUF) reports |
|
||||
| [`src/decoders/gzip.ts`](src/decoders/gzip.md) | Gzip detection and decompression |
|
||||
| [`src/decoders/xml.ts`](src/decoders/xml.md) | XML parsing via fast-xml-parser |
|
||||
| [`src/errors/dmarc-error.ts`](src/errors/dmarc-error.md) | Typed error class and code registry |
|
||||
| [`src/otel/dmarc-otel.ts`](src/otel/dmarc-otel.md) | OpenTelemetry metrics |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { parseDmarcAggregate, parseDmarcForensic } from "@suaveplan/dmarc";
|
||||
|
||||
// Parse an aggregate report from an XML string
|
||||
const aggregate = parseDmarcAggregate(xmlString);
|
||||
console.log(aggregate.reportMetadata.orgName);
|
||||
console.log(aggregate.record[0].row.sourceIp);
|
||||
|
||||
// Parse from gzip-compressed bytes (auto-detected)
|
||||
const { gzipSync, strToU8 } = await import("fflate");
|
||||
const compressed = gzipSync(strToU8(xmlString));
|
||||
const report = parseDmarcAggregate(compressed);
|
||||
|
||||
// Parse a forensic report
|
||||
const forensic = parseDmarcForensic(forensicXml);
|
||||
console.log(forensic.feedback.policyPublished.domain);
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```ts
|
||||
import { DmarcError, DMARC_CODES } from "@suaveplan/dmarc";
|
||||
|
||||
try {
|
||||
parseDmarcAggregate(input);
|
||||
} catch (err) {
|
||||
if (err instanceof DmarcError) {
|
||||
switch (err.code) {
|
||||
case DMARC_CODES.PARSE_ERROR: // XML could not be parsed
|
||||
case DMARC_CODES.INVALID_REPORT: // Schema validation failed
|
||||
case DMARC_CODES.UNSUPPORTED_ENCODING: // Gzip decompression failed
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
112
packages/communication/emails/dmarc/package.json
Normal file
112
packages/communication/emails/dmarc/package.json
Normal file
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"name": "@suaveplan/dmarc",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "DMARC report parser — parses aggregate (RUA) and forensic (RUF) DMARC XML reports into typed structures",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/dmarc-error.d.ts",
|
||||
"default": "./dist/errors/dmarc-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/dmarc-error.d.ts",
|
||||
"default": "./dist/errors/dmarc-error.js"
|
||||
}
|
||||
},
|
||||
"./schemas/aggregate": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/aggregate-report.schema.d.ts",
|
||||
"default": "./dist/schemas/aggregate-report.schema.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/schemas/aggregate-report.schema.d.ts",
|
||||
"default": "./dist/schemas/aggregate-report.schema.js"
|
||||
}
|
||||
},
|
||||
"./schemas/forensic": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/forensic-report.schema.d.ts",
|
||||
"default": "./dist/schemas/forensic-report.schema.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/schemas/forensic-report.schema.d.ts",
|
||||
"default": "./dist/schemas/forensic-report.schema.js"
|
||||
}
|
||||
},
|
||||
"./parsers/aggregate": {
|
||||
"import": {
|
||||
"types": "./dist/parsers/aggregate.d.ts",
|
||||
"default": "./dist/parsers/aggregate.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/parsers/aggregate.d.ts",
|
||||
"default": "./dist/parsers/aggregate.js"
|
||||
}
|
||||
},
|
||||
"./parsers/forensic": {
|
||||
"import": {
|
||||
"types": "./dist/parsers/forensic.d.ts",
|
||||
"default": "./dist/parsers/forensic.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/parsers/forensic.d.ts",
|
||||
"default": "./dist/parsers/forensic.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "catalog:parsing",
|
||||
"fflate": "catalog:parsing",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
76
packages/communication/emails/dmarc/src/decoders/gzip.md
Normal file
76
packages/communication/emails/dmarc/src/decoders/gzip.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# gzip
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides gzip decompression and input-decoding utilities for `@suaveplan/dmarc`. DMARC aggregate and forensic reports are commonly delivered as gzip-compressed XML attachments (`.xml.gz`). This module abstracts the decompression step so that parsers can accept both raw XML strings and compressed binary blobs through a single entry point without depending on Node.js stream APIs or native bindings.
|
||||
|
||||
## Features
|
||||
|
||||
- `isGzip(bytes)` — magic-byte probe to identify gzip streams before attempting decompression
|
||||
- `decompressGzip(bytes)` — synchronous gunzip via `fflate.gunzipSync` + `strFromU8` for zero-dependency UTF-8 conversion
|
||||
- `decodeInput(input)` — unified entry point: pass-through for strings, auto-gunzip for gzip blobs, plain UTF-8 decode for non-gzip bytes
|
||||
- Structured errors via `DmarcError` with code `DMARC.UNSUPPORTED_ENCODING` on decompression failure
|
||||
- No Node.js built-ins (`node:zlib`, `node:fs`) — pure `fflate` implementation works in browsers and edge runtimes
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { decodeInput } from "@suaveplan/dmarc/decoders/gzip";
|
||||
|
||||
// Accepts a plain XML string
|
||||
const xml1 = decodeInput("<feedback>...</feedback>");
|
||||
|
||||
// Accepts a gzip-compressed Uint8Array
|
||||
const gzippedBytes = new Uint8Array([0x1f, 0x8b, /* ... */]);
|
||||
const xml2 = decodeInput(gzippedBytes);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { isGzip, decompressGzip, decodeInput } from "@suaveplan/dmarc/decoders/gzip";
|
||||
import { DmarcError, DMARC_CODES } from "@suaveplan/dmarc/errors";
|
||||
|
||||
async function fetchAndDecode(url: string): Promise<string> {
|
||||
const resp = await fetch(url);
|
||||
const bytes = new Uint8Array(await resp.arrayBuffer());
|
||||
|
||||
if (isGzip(bytes)) {
|
||||
// Explicit probe — useful for logging / metrics before decode
|
||||
console.log("Input is gzip-compressed, decompressing…");
|
||||
return decompressGzip(bytes);
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
// Error handling
|
||||
try {
|
||||
decodeInput(new Uint8Array([0x1f, 0x8b, 0x00])); // truncated gzip
|
||||
} catch (err) {
|
||||
if (err instanceof DmarcError && err.code === DMARC_CODES.UNSUPPORTED_ENCODING) {
|
||||
console.error("Decompression failed:", err.message, err.cause);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `isGzip(bytes: Uint8Array): boolean`
|
||||
|
||||
Returns `true` if `bytes` starts with the two gzip magic bytes (`0x1f 0x8b`). Returns `false` for any input shorter than 2 bytes.
|
||||
|
||||
### `decompressGzip(bytes: Uint8Array): string`
|
||||
|
||||
Decompresses `bytes` synchronously via `fflate.gunzipSync` and converts the result to a UTF-8 string using `fflate.strFromU8`.
|
||||
|
||||
**Throws:** `DmarcError` with code `DMARC.UNSUPPORTED_ENCODING` if decompression fails.
|
||||
|
||||
### `decodeInput(input: string | Uint8Array): string`
|
||||
|
||||
Unified decoder: returns strings unchanged, decompresses gzip bytes, and UTF-8 decodes non-gzip bytes.
|
||||
|
||||
**Throws:** `DmarcError` with code `DMARC.UNSUPPORTED_ENCODING` if gzip decompression fails.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`fflate.gunzipSync` is chosen over `DecompressionStream` (Streams API) because the synchronous path avoids async complexity in callers and works identically across Node.js, Bun, Deno, and browser environments. `strFromU8` is preferred over `TextDecoder` on the decompressed output because it avoids an extra allocation when fflate has already produced a UTF-8 buffer.
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { gzipSync, strToU8 } from "fflate";
|
||||
import { DMARC_CODES, DmarcError } from "../errors/dmarc-error.js";
|
||||
import { decodeInput, decompressGzip, isGzip } from "./gzip.js";
|
||||
|
||||
const PLAIN_XML = "<feedback><version>1.0</version></feedback>";
|
||||
|
||||
describe("isGzip", () => {
|
||||
it("returns true for gzip magic bytes", { timeout: 5000 }, () => {
|
||||
const compressed = gzipSync(strToU8(PLAIN_XML));
|
||||
expect(isGzip(compressed)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for plain text bytes", { timeout: 5000 }, () => {
|
||||
const bytes = strToU8(PLAIN_XML);
|
||||
expect(isGzip(bytes)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty Uint8Array", { timeout: 5000 }, () => {
|
||||
expect(isGzip(new Uint8Array(0))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for single byte", { timeout: 5000 }, () => {
|
||||
expect(isGzip(new Uint8Array([0x1f]))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when first byte matches but second does not", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(isGzip(new Uint8Array([0x1f, 0x00]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decompressGzip", () => {
|
||||
it("decompresses a valid gzip buffer to a string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const compressed = gzipSync(strToU8(PLAIN_XML));
|
||||
const result = decompressGzip(compressed);
|
||||
expect(result).toBe(PLAIN_XML);
|
||||
});
|
||||
|
||||
it("throws DmarcError with UNSUPPORTED_ENCODING on invalid bytes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const invalid = new Uint8Array([0x1f, 0x8b, 0x00, 0x01]);
|
||||
expect(() => decompressGzip(invalid)).toThrowError(DmarcError);
|
||||
try {
|
||||
decompressGzip(invalid);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmarcError);
|
||||
expect((err as DmarcError).code).toBe(
|
||||
DMARC_CODES.UNSUPPORTED_ENCODING
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeInput", () => {
|
||||
it("returns string input unchanged", { timeout: 5000 }, () => {
|
||||
expect(decodeInput(PLAIN_XML)).toBe(PLAIN_XML);
|
||||
});
|
||||
|
||||
it("decompresses gzip Uint8Array", { timeout: 5000 }, () => {
|
||||
const compressed = gzipSync(strToU8(PLAIN_XML));
|
||||
expect(decodeInput(compressed)).toBe(PLAIN_XML);
|
||||
});
|
||||
|
||||
it("UTF-8 decodes plain Uint8Array", { timeout: 5000 }, () => {
|
||||
const bytes = new TextEncoder().encode(PLAIN_XML);
|
||||
expect(decodeInput(bytes)).toBe(PLAIN_XML);
|
||||
});
|
||||
});
|
||||
66
packages/communication/emails/dmarc/src/decoders/gzip.ts
Normal file
66
packages/communication/emails/dmarc/src/decoders/gzip.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import { DMARC_CODES, DmarcError } from "../errors/dmarc-error.js";
|
||||
|
||||
/**
|
||||
* Gzip magic bytes (first two bytes of a gzip stream: 0x1f 0x8b).
|
||||
*/
|
||||
const GZIP_MAGIC_0 = 0x1f;
|
||||
const GZIP_MAGIC_1 = 0x8b;
|
||||
|
||||
/**
|
||||
* Returns `true` if the given bytes begin with the gzip magic header.
|
||||
*
|
||||
* @param bytes - Raw input bytes.
|
||||
*/
|
||||
export function isGzip(bytes: Uint8Array): boolean {
|
||||
return (
|
||||
bytes.length >= 2 &&
|
||||
bytes[0] === GZIP_MAGIC_0 &&
|
||||
bytes[1] === GZIP_MAGIC_1
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompresses a gzip-compressed `Uint8Array` to a UTF-8 string.
|
||||
*
|
||||
* Uses the synchronous `fflate.gunzipSync` path so it works in all
|
||||
* JavaScript runtimes without requiring streams or Node.js builtins.
|
||||
*
|
||||
* @param bytes - Raw gzip-compressed bytes.
|
||||
* @returns Decompressed UTF-8 string.
|
||||
* @throws {DmarcError} with code `DMARC.UNSUPPORTED_ENCODING` when decompression fails.
|
||||
*/
|
||||
export function decompressGzip(bytes: Uint8Array): string {
|
||||
try {
|
||||
const decompressed = gunzipSync(bytes);
|
||||
return strFromU8(decompressed);
|
||||
} catch (cause) {
|
||||
throw new DmarcError(
|
||||
DMARC_CODES.UNSUPPORTED_ENCODING,
|
||||
"Failed to decompress gzip input",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a `Uint8Array` or string input to a UTF-8 string.
|
||||
*
|
||||
* - If `input` is already a `string`, it is returned as-is.
|
||||
* - If `input` is a `Uint8Array` starting with gzip magic bytes, it is
|
||||
* decompressed via {@link decompressGzip}.
|
||||
* - Otherwise the bytes are decoded as UTF-8 using the standard `TextDecoder`.
|
||||
*
|
||||
* @param input - Raw report data: either a string or binary bytes.
|
||||
* @returns UTF-8 string ready for XML parsing.
|
||||
* @throws {DmarcError} with code `DMARC.UNSUPPORTED_ENCODING` on decompression failure.
|
||||
*/
|
||||
export function decodeInput(input: string | Uint8Array): string {
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
if (isGzip(input)) {
|
||||
return decompressGzip(input);
|
||||
}
|
||||
return new TextDecoder().decode(input);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { decodeInput, decompressGzip, isGzip } from "./gzip";
|
||||
export { parseXml } from "./xml";
|
||||
69
packages/communication/emails/dmarc/src/decoders/xml.md
Normal file
69
packages/communication/emails/dmarc/src/decoders/xml.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# xml
|
||||
|
||||
## Purpose
|
||||
|
||||
Wraps `fast-xml-parser` with a fixed, strict configuration tuned for DMARC report XML. The module isolates all parser configuration in one place so that both aggregate and forensic parsers produce a consistently shaped raw object tree, reducing the surface area for subtle parsing divergences between the two report types.
|
||||
|
||||
## Features
|
||||
|
||||
- Shared `XMLParser` instance configuration: no-op attribute prefix, numeric tag-value coercion, whitespace trimming
|
||||
- `isArray` hook forces `<record>`, `<dkim>`, `<spf>`, and `<error>` nodes to always deserialise as arrays, preventing single-item vs. multi-item inconsistency
|
||||
- Structured errors via `DmarcError` with code `DMARC.PARSE_ERROR` on any parse failure
|
||||
- Pure function — stateless, no module-level side effects
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseXml } from "@suaveplan/dmarc/decoders/xml";
|
||||
|
||||
const raw = parseXml(`
|
||||
<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>Example</org_name>
|
||||
</report_metadata>
|
||||
</feedback>
|
||||
`);
|
||||
console.log((raw as any).feedback.version); // "1.0"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseXml } from "@suaveplan/dmarc/decoders/xml";
|
||||
import { DmarcError, DMARC_CODES } from "@suaveplan/dmarc/errors";
|
||||
|
||||
function safeParse(xml: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return parseXml(xml);
|
||||
} catch (err) {
|
||||
if (err instanceof DmarcError && err.code === DMARC_CODES.PARSE_ERROR) {
|
||||
console.error("XML parse failed:", err.message);
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Single <record> is still an array
|
||||
const result = parseXml("<feedback><record><row/></record></feedback>");
|
||||
const records = (result as any).feedback.record; // Array, length 1
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseXml(xml: string): Record<string, unknown>`
|
||||
|
||||
Parses a UTF-8 XML string into a raw nested JS object.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `xml` — Well-formed UTF-8 XML string.
|
||||
|
||||
**Returns:** Raw `Record<string, unknown>` mirroring the XML element hierarchy.
|
||||
|
||||
**Throws:** `DmarcError` with code `DMARC.PARSE_ERROR` if parsing fails or the parser returns a non-object value.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`fast-xml-parser` is used in preference to DOM-based parsers (e.g. `DOMParser`) because it works in all JavaScript environments — including Node.js and Bun — without requiring a DOM polyfill. The `isArray` hook is critical for DMARC reports: RFC 7489 allows a single `<record>` element, and without forcing array mode a one-record report would produce an object where `record` is a plain object rather than a length-1 array, breaking downstream mapping code. Numeric coercion (`parseTagValue: true`) handles the `<begin>`, `<end>`, `<count>`, and `<pct>` fields that appear as integer text nodes.
|
||||
58
packages/communication/emails/dmarc/src/decoders/xml.test.ts
Normal file
58
packages/communication/emails/dmarc/src/decoders/xml.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DMARC_CODES, DmarcError } from "../errors/dmarc-error.js";
|
||||
import { parseXml } from "./xml.js";
|
||||
|
||||
describe("parseXml", () => {
|
||||
it("parses valid XML into an object", { timeout: 5000 }, () => {
|
||||
const xml = "<root><child>value</child></root>";
|
||||
const result = parseXml(xml);
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe("object");
|
||||
});
|
||||
|
||||
it("forces <record> to be an array for a single element", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml = "<feedback><record><row/></record></feedback>";
|
||||
const result = parseXml(xml) as Record<string, Record<string, unknown>>;
|
||||
expect(Array.isArray(result["feedback"]["record"])).toBe(true);
|
||||
});
|
||||
|
||||
it("forces <dkim> to be an array for a single element", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml =
|
||||
"<auth_results><dkim><domain>x.com</domain><result>pass</result></dkim></auth_results>";
|
||||
const result = parseXml(xml) as Record<string, Record<string, unknown>>;
|
||||
expect(Array.isArray(result["auth_results"]["dkim"])).toBe(true);
|
||||
});
|
||||
|
||||
it("forces <spf> to be an array for a single element", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml =
|
||||
"<auth_results><spf><domain>x.com</domain><result>pass</result></spf></auth_results>";
|
||||
const result = parseXml(xml) as Record<string, Record<string, unknown>>;
|
||||
expect(Array.isArray(result["auth_results"]["spf"])).toBe(true);
|
||||
});
|
||||
|
||||
it("parses numeric values as numbers", { timeout: 5000 }, () => {
|
||||
const xml = "<row><count>5</count></row>";
|
||||
const result = parseXml(xml) as Record<string, Record<string, number>>;
|
||||
expect(typeof result["row"]["count"]).toBe("number");
|
||||
expect(result["row"]["count"]).toBe(5);
|
||||
});
|
||||
|
||||
it("throws DmarcError with PARSE_ERROR on malformed XML (unclosed attribute)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// fast-xml-parser throws on unclosed attribute strings
|
||||
expect(() => parseXml('<root attr="unclosed')).toThrowError(DmarcError);
|
||||
try {
|
||||
parseXml('<root attr="unclosed');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmarcError);
|
||||
expect((err as DmarcError).code).toBe(DMARC_CODES.PARSE_ERROR);
|
||||
}
|
||||
});
|
||||
});
|
||||
60
packages/communication/emails/dmarc/src/decoders/xml.ts
Normal file
60
packages/communication/emails/dmarc/src/decoders/xml.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { XMLParser } from "fast-xml-parser";
|
||||
import { DMARC_CODES, DmarcError } from "../errors/dmarc-error.js";
|
||||
|
||||
/**
|
||||
* Shared `fast-xml-parser` configuration for DMARC report parsing.
|
||||
*
|
||||
* Key choices:
|
||||
* - `ignoreAttributes: false` — preserve XML attributes (needed for version fields)
|
||||
* - `parseTagValue: true` — coerce numeric text nodes to JS numbers
|
||||
* - `trimValues: true` — strip leading/trailing whitespace from text content
|
||||
* - `isArray` — force `<record>` and `<dkim>`/`<spf>` to always be arrays so
|
||||
* single-record reports and single auth-result reports parse consistently
|
||||
*/
|
||||
const PARSER_OPTIONS = {
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: "@_",
|
||||
parseTagValue: true,
|
||||
parseAttributeValue: true,
|
||||
trimValues: true,
|
||||
isArray: (name: string): boolean => {
|
||||
return (
|
||||
name === "record" ||
|
||||
name === "dkim" ||
|
||||
name === "spf" ||
|
||||
name === "error"
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Parses a DMARC XML string into a raw JavaScript object.
|
||||
*
|
||||
* The raw object mirrors the XML structure with camelCase conversion handled by
|
||||
* callers (see the aggregate/forensic mappers). This function is intentionally
|
||||
* low-level — it only concerns itself with well-formedness; schema validation
|
||||
* is deferred to the Zod schemas.
|
||||
*
|
||||
* @param xml - Well-formed UTF-8 XML string.
|
||||
* @returns Raw parsed object (untyped — callers apply the Zod schema).
|
||||
* @throws {DmarcError} with code `DMARC.PARSE_ERROR` on XML parse failure.
|
||||
*/
|
||||
export function parseXml(xml: string): Record<string, unknown> {
|
||||
try {
|
||||
const parser = new XMLParser(PARSER_OPTIONS);
|
||||
const result = parser.parse(xml) as Record<string, unknown>;
|
||||
return result;
|
||||
} catch (cause) {
|
||||
const msg =
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: /* istanbul ignore next */ String(cause);
|
||||
throw new DmarcError(
|
||||
DMARC_CODES.PARSE_ERROR,
|
||||
`Failed to parse XML: ${msg}`,
|
||||
{
|
||||
cause,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# dmarc-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the lightweight error class and stable code registry for `@suaveplan/dmarc`. Because this package carries no `@suaveplan/error` runtime dependency (it is a leaf package with no `@suaveplan/*` runtime deps), it provides its own self-contained error substrate that gives callers the same discriminated-error contract: a strongly-typed `code` field for `switch`/`instanceof` discrimination, plus an idiomatic `cause` chain via the built-in `ErrorOptions`.
|
||||
|
||||
## Features
|
||||
|
||||
- `DMARC_CODES` — three stable string-literal constants (`DMARC.PARSE_ERROR`, `DMARC.INVALID_REPORT`, `DMARC.UNSUPPORTED_ENCODING`)
|
||||
- `DmarcCode` — union type of all valid code values
|
||||
- `DmarcError` — `Error` subclass with a `readonly code: DmarcCode` field and preserved `name`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { DmarcError, DMARC_CODES } from "@suaveplan/dmarc/errors";
|
||||
|
||||
try {
|
||||
// ... parse logic
|
||||
} catch (cause) {
|
||||
throw new DmarcError(DMARC_CODES.PARSE_ERROR, "Failed to parse XML", { cause });
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { DmarcError, DMARC_CODES, type DmarcCode } from "@suaveplan/dmarc/errors";
|
||||
|
||||
function handleDmarcError(err: unknown): never {
|
||||
if (err instanceof DmarcError) {
|
||||
switch (err.code) {
|
||||
case DMARC_CODES.PARSE_ERROR:
|
||||
console.error("XML parse failure:", err.message, err.cause);
|
||||
break;
|
||||
case DMARC_CODES.INVALID_REPORT:
|
||||
console.error("Schema validation failed:", err.message);
|
||||
break;
|
||||
case DMARC_CODES.UNSUPPORTED_ENCODING:
|
||||
console.error("Cannot decode input:", err.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DMARC_CODES`
|
||||
|
||||
`{ PARSE_ERROR: "DMARC.PARSE_ERROR"; INVALID_REPORT: "DMARC.INVALID_REPORT"; UNSUPPORTED_ENCODING: "DMARC.UNSUPPORTED_ENCODING" }`
|
||||
|
||||
Immutable code registry. Use these constants instead of raw strings so that typos produce type errors.
|
||||
|
||||
### `DmarcCode`
|
||||
|
||||
`"DMARC.PARSE_ERROR" | "DMARC.INVALID_REPORT" | "DMARC.UNSUPPORTED_ENCODING"`
|
||||
|
||||
Union of all valid code values for use in signatures and `switch` exhaustiveness checks.
|
||||
|
||||
### `DmarcError`
|
||||
|
||||
`class DmarcError extends Error`
|
||||
|
||||
**Constructor:** `new DmarcError(code: DmarcCode, message: string, options?: ErrorOptions)`
|
||||
|
||||
- `code` — one of the `DMARC_CODES` values
|
||||
- `message` — human-readable description
|
||||
- `options.cause` — original error for cause-chain logging
|
||||
|
||||
**Properties:**
|
||||
|
||||
- `code: DmarcCode` — readonly; set during construction
|
||||
- `name: string` — always `"DmarcError"`
|
||||
|
||||
**Throws:** nothing
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The absence of `@suaveplan/error` as a runtime dependency is intentional: this package is a leaf node in the dependency graph and must remain importable in environments where the broader Suaveplan stack is not installed. The same discriminated-error pattern (stable `code` field, `cause` chain, subclassed `Error`) is preserved so that tooling and callers that understand `@suaveplan/error` conventions can handle these errors uniformly.
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DMARC_CODES, DmarcError } from "./dmarc-error.js";
|
||||
|
||||
describe("DMARC_CODES", () => {
|
||||
it("has the expected code values", { timeout: 5000 }, () => {
|
||||
expect(DMARC_CODES.PARSE_ERROR).toBe("DMARC.PARSE_ERROR");
|
||||
expect(DMARC_CODES.INVALID_REPORT).toBe("DMARC.INVALID_REPORT");
|
||||
expect(DMARC_CODES.UNSUPPORTED_ENCODING).toBe(
|
||||
"DMARC.UNSUPPORTED_ENCODING"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DmarcError", () => {
|
||||
it("is an instance of Error", { timeout: 5000 }, () => {
|
||||
const err = new DmarcError(DMARC_CODES.PARSE_ERROR, "test");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("has the correct name", { timeout: 5000 }, () => {
|
||||
const err = new DmarcError(DMARC_CODES.PARSE_ERROR, "test");
|
||||
expect(err.name).toBe("DmarcError");
|
||||
});
|
||||
|
||||
it("sets the code field", { timeout: 5000 }, () => {
|
||||
const err = new DmarcError(DMARC_CODES.INVALID_REPORT, "bad report");
|
||||
expect(err.code).toBe(DMARC_CODES.INVALID_REPORT);
|
||||
});
|
||||
|
||||
it("sets the message field", { timeout: 5000 }, () => {
|
||||
const err = new DmarcError(DMARC_CODES.PARSE_ERROR, "my message");
|
||||
expect(err.message).toBe("my message");
|
||||
});
|
||||
|
||||
it("stores cause when provided", { timeout: 5000 }, () => {
|
||||
const cause = new Error("original");
|
||||
const err = new DmarcError(DMARC_CODES.UNSUPPORTED_ENCODING, "wrap", {
|
||||
cause,
|
||||
});
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("works with instanceof discrimination", { timeout: 5000 }, () => {
|
||||
const err = new DmarcError(DMARC_CODES.PARSE_ERROR, "x");
|
||||
expect(err instanceof DmarcError).toBe(true);
|
||||
expect(err instanceof Error).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* DMARC error codes as string literal constants.
|
||||
*
|
||||
* All codes are prefixed with `DMARC.` and fall into three categories:
|
||||
* - `DMARC.PARSE_ERROR` — XML parsing or structural failure
|
||||
* - `DMARC.INVALID_REPORT` — the parsed structure does not conform to the DMARC schema
|
||||
* - `DMARC.UNSUPPORTED_ENCODING` — input encoding cannot be handled (not gzip, not UTF-8)
|
||||
*/
|
||||
export const DMARC_CODES = {
|
||||
PARSE_ERROR: "DMARC.PARSE_ERROR",
|
||||
INVALID_REPORT: "DMARC.INVALID_REPORT",
|
||||
UNSUPPORTED_ENCODING: "DMARC.UNSUPPORTED_ENCODING",
|
||||
} as const;
|
||||
|
||||
export type DmarcCode = (typeof DMARC_CODES)[keyof typeof DMARC_CODES];
|
||||
|
||||
/**
|
||||
* Lightweight error class for all `@suaveplan/dmarc` failures.
|
||||
*
|
||||
* Extends the built-in `Error` with a strongly-typed `code` field drawn from
|
||||
* `DMARC_CODES`. Since this package carries no `@suaveplan/error` runtime
|
||||
* dependency, this is a self-contained substitute that provides the same
|
||||
* discriminated-error contract to callers.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { DmarcError, DMARC_CODES } from "@suaveplan/dmarc/errors";
|
||||
*
|
||||
* throw new DmarcError(DMARC_CODES.PARSE_ERROR, "Unexpected root element", {
|
||||
* cause: originalError,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class DmarcError extends Error {
|
||||
/** Stable string code identifying the failure kind. */
|
||||
public readonly code: DmarcCode;
|
||||
|
||||
public constructor(
|
||||
code: DmarcCode,
|
||||
message: string,
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = "DmarcError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
1
packages/communication/emails/dmarc/src/errors/index.ts
Normal file
1
packages/communication/emails/dmarc/src/errors/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DMARC_CODES, type DmarcCode, DmarcError } from "./dmarc-error";
|
||||
53
packages/communication/emails/dmarc/src/index.ts
Normal file
53
packages/communication/emails/dmarc/src/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Errors
|
||||
|
||||
// Decoders (public low-level API)
|
||||
export { decodeInput, decompressGzip, isGzip } from "./decoders/gzip.js";
|
||||
export { parseXml } from "./decoders/xml.js";
|
||||
export type { DmarcCode } from "./errors/dmarc-error.js";
|
||||
export { DMARC_CODES, DmarcError } from "./errors/dmarc-error.js";
|
||||
// OTel
|
||||
export {
|
||||
dmarcParseDurationMs,
|
||||
dmarcParseTotal,
|
||||
recordParse,
|
||||
} from "./otel/dmarc-otel.js";
|
||||
export type { ParseDmarcAggregateOptions } from "./parsers/aggregate.js";
|
||||
// Parsers
|
||||
export { parseDmarcAggregate } from "./parsers/aggregate.js";
|
||||
export type { ParseDmarcForensicOptions } from "./parsers/forensic.js";
|
||||
export { parseDmarcForensic } from "./parsers/forensic.js";
|
||||
export type { DmarcAggregateReport } from "./schemas/aggregate-report.schema.js";
|
||||
// Aggregate schema & types
|
||||
export { DmarcAggregateReportSchema } from "./schemas/aggregate-report.schema.js";
|
||||
export type { DmarcForensicReport } from "./schemas/forensic-report.schema.js";
|
||||
// Forensic schema & types
|
||||
export { DmarcForensicReportSchema } from "./schemas/forensic-report.schema.js";
|
||||
export type {
|
||||
AlignmentMode,
|
||||
DispositionMode,
|
||||
DkimResult,
|
||||
DmarcAuthResult,
|
||||
DmarcDateRange,
|
||||
DmarcIdentifiers,
|
||||
DmarcPolicyPublished,
|
||||
DmarcRecord,
|
||||
DmarcReportMetadata,
|
||||
DmarcRow,
|
||||
PolicyEvaluated,
|
||||
SpfResult,
|
||||
} from "./schemas/shared.schema.js";
|
||||
// Shared schemas & types
|
||||
export {
|
||||
AlignmentModeSchema,
|
||||
DispositionModeSchema,
|
||||
DkimResultSchema,
|
||||
DmarcAuthResultSchema,
|
||||
DmarcDateRangeSchema,
|
||||
DmarcIdentifiersSchema,
|
||||
DmarcPolicyPublishedSchema,
|
||||
DmarcRecordSchema,
|
||||
DmarcReportMetadataSchema,
|
||||
DmarcRowSchema,
|
||||
PolicyEvaluatedSchema,
|
||||
SpfResultSchema,
|
||||
} from "./schemas/shared.schema.js";
|
||||
69
packages/communication/emails/dmarc/src/otel/dmarc-otel.md
Normal file
69
packages/communication/emails/dmarc/src/otel/dmarc-otel.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# dmarc-otel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides OpenTelemetry metric instruments for `@suaveplan/dmarc`. It gives operators visibility into parse throughput and latency without coupling the parse logic itself to observability concerns. All instruments are created lazily by the OTel SDK's no-op implementation, so importing this module in an environment without an OTel SDK installed is always safe — the calls become no-ops.
|
||||
|
||||
## Features
|
||||
|
||||
- `dmarcParseTotal` — counter tracking total parse invocations by `kind` and `status`
|
||||
- `dmarcParseDurationMs` — histogram recording parse latency in milliseconds by `kind`
|
||||
- `recordParse()` — convenience wrapper that updates both instruments in one call
|
||||
- Zero runtime dependencies beyond `@opentelemetry/api`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordParse } from "@suaveplan/dmarc/otel/dmarc-otel";
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
// ... parse logic
|
||||
recordParse("aggregate", "success", performance.now() - start);
|
||||
} catch {
|
||||
recordParse("aggregate", "error", performance.now() - start);
|
||||
throw;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { dmarcParseTotal, dmarcParseDurationMs, recordParse } from "@suaveplan/dmarc/otel/dmarc-otel";
|
||||
|
||||
// Direct instrument access for custom attribute sets
|
||||
dmarcParseTotal.add(1, { kind: "aggregate", status: "success", source: "s3" });
|
||||
dmarcParseDurationMs.record(42, { kind: "forensic", source: "email" });
|
||||
|
||||
// Or use the convenience wrapper for standard usage
|
||||
recordParse("forensic", "success", 38);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `dmarcParseTotal`
|
||||
|
||||
`Counter` — incremented once per parse call. Attributes: `kind` (`"aggregate" | "forensic"`), `status` (`"success" | "error"`).
|
||||
|
||||
### `dmarcParseDurationMs`
|
||||
|
||||
`Histogram` — records elapsed parse time in milliseconds. Attributes: `kind` (`"aggregate" | "forensic"`).
|
||||
|
||||
### `recordParse(kind, status, durationMs): void`
|
||||
|
||||
Convenience function that increments `dmarcParseTotal` and records `durationMs` in `dmarcParseDurationMs` in a single call.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `kind` — `"aggregate"` or `"forensic"`
|
||||
- `status` — `"success"` or `"error"`
|
||||
- `durationMs` — elapsed time in milliseconds
|
||||
|
||||
**Returns:** `void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The meter name is `"@suaveplan/dmarc"` which matches the npm package name, making it trivial to filter metrics by package in dashboards. Histogram bucket boundaries are left to the OTel SDK/exporter configuration rather than hardcoded here, which is the recommended practice per the OTel specification. Importing this module in test environments without an SDK causes zero issues because the OTel API ships a built-in no-op implementation.
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
dmarcParseDurationMs,
|
||||
dmarcParseTotal,
|
||||
recordParse,
|
||||
} from "./dmarc-otel.js";
|
||||
|
||||
describe("dmarc-otel", () => {
|
||||
it("exports dmarcParseTotal counter", { timeout: 5000 }, () => {
|
||||
expect(dmarcParseTotal).toBeDefined();
|
||||
expect(typeof dmarcParseTotal.add).toBe("function");
|
||||
});
|
||||
|
||||
it("exports dmarcParseDurationMs histogram", { timeout: 5000 }, () => {
|
||||
expect(dmarcParseDurationMs).toBeDefined();
|
||||
expect(typeof dmarcParseDurationMs.record).toBe("function");
|
||||
});
|
||||
|
||||
it("recordParse does not throw for aggregate success", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => recordParse("aggregate", "success", 10)).not.toThrow();
|
||||
});
|
||||
|
||||
it("recordParse does not throw for aggregate error", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => recordParse("aggregate", "error", 5)).not.toThrow();
|
||||
});
|
||||
|
||||
it("recordParse does not throw for forensic success", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => recordParse("forensic", "success", 20)).not.toThrow();
|
||||
});
|
||||
|
||||
it("recordParse does not throw for forensic error", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => recordParse("forensic", "error", 3)).not.toThrow();
|
||||
});
|
||||
});
|
||||
41
packages/communication/emails/dmarc/src/otel/dmarc-otel.ts
Normal file
41
packages/communication/emails/dmarc/src/otel/dmarc-otel.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const meter = metrics.getMeter("@suaveplan/dmarc");
|
||||
|
||||
/**
|
||||
* Counter tracking the total number of DMARC parse operations, labelled by
|
||||
* `kind` (`aggregate` or `forensic`) and `status` (`success` or `error`).
|
||||
*/
|
||||
export const dmarcParseTotal = meter.createCounter("dmarc.parse.total", {
|
||||
description: "Total number of DMARC report parse operations",
|
||||
unit: "{reports}",
|
||||
});
|
||||
|
||||
/**
|
||||
* Histogram recording the wall-clock duration of each parse operation in
|
||||
* milliseconds, labelled by `kind` (`aggregate` or `forensic`).
|
||||
*/
|
||||
export const dmarcParseDurationMs = meter.createHistogram(
|
||||
"dmarc.parse.duration_ms",
|
||||
{
|
||||
description:
|
||||
"Duration of DMARC report parse operations in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Records a completed parse observation.
|
||||
*
|
||||
* @param kind - `"aggregate"` or `"forensic"`.
|
||||
* @param status - `"success"` or `"error"`.
|
||||
* @param durationMs - Elapsed milliseconds for the parse call.
|
||||
*/
|
||||
export function recordParse(
|
||||
kind: "aggregate" | "forensic",
|
||||
status: "success" | "error",
|
||||
durationMs: number
|
||||
): void {
|
||||
dmarcParseTotal.add(1, { kind, status });
|
||||
dmarcParseDurationMs.record(durationMs, { kind });
|
||||
}
|
||||
5
packages/communication/emails/dmarc/src/otel/index.ts
Normal file
5
packages/communication/emails/dmarc/src/otel/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
dmarcParseDurationMs,
|
||||
dmarcParseTotal,
|
||||
recordParse,
|
||||
} from "./dmarc-otel";
|
||||
108
packages/communication/emails/dmarc/src/parsers/aggregate.md
Normal file
108
packages/communication/emails/dmarc/src/parsers/aggregate.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# aggregate
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `parseDmarcAggregate`, the primary entry point for converting raw DMARC aggregate (RUA) report data into validated, strongly-typed `DmarcAggregateReport` objects. It handles the full pipeline: gzip auto-detection and decompression, XML parsing, snake_case-to-camelCase field mapping, single-vs-multiple element normalisation, and Zod schema validation with structured error reporting.
|
||||
|
||||
## Features
|
||||
|
||||
- Auto-detects gzip input via magic-byte probe — no caller configuration needed for compressed reports
|
||||
- Accepts both raw XML strings and `Uint8Array` binary blobs in a single overload
|
||||
- Full snake_case → camelCase mapping for all RFC 7489 field names
|
||||
- Handles variable-arity elements: `<record>`, `<dkim>`, `<spf>` can be single or multiple
|
||||
- Validates the mapped structure with `DmarcAggregateReportSchema` (Zod)
|
||||
- Emits OTel counter and histogram via `recordParse()` on both success and error paths
|
||||
- Wraps all failures in typed `DmarcError` with stable codes
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseDmarcAggregate } from "@suaveplan/dmarc/parsers/aggregate";
|
||||
|
||||
const report = parseDmarcAggregate(`
|
||||
<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>Example Corp</org_name>
|
||||
<email>dmarc@example.com</email>
|
||||
<report_id>12345</report_id>
|
||||
<date_range><begin>1609459200</begin><end>1609545600</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>192.0.2.1</source_ip><count>5</count>
|
||||
<policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
|
||||
</row>
|
||||
<identifiers><header_from>example.com</header_from></identifiers>
|
||||
<auth_results>
|
||||
<dkim><domain>example.com</domain><result>pass</result></dkim>
|
||||
<spf><domain>example.com</domain><result>pass</result></spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
`);
|
||||
|
||||
console.log(report.reportMetadata.orgName); // "Example Corp"
|
||||
console.log(report.record[0].row.count); // 5
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseDmarcAggregate } from "@suaveplan/dmarc/parsers/aggregate";
|
||||
import { DmarcError, DMARC_CODES } from "@suaveplan/dmarc/errors";
|
||||
import { gzipSync, strToU8 } from "fflate";
|
||||
|
||||
// Parse from gzip-compressed bytes
|
||||
const xmlBytes = strToU8(xmlString);
|
||||
const compressed = gzipSync(xmlBytes);
|
||||
const report = parseDmarcAggregate(compressed);
|
||||
|
||||
// Error discrimination
|
||||
try {
|
||||
parseDmarcAggregate("not xml");
|
||||
} catch (err) {
|
||||
if (err instanceof DmarcError) {
|
||||
switch (err.code) {
|
||||
case DMARC_CODES.PARSE_ERROR:
|
||||
// XML could not be parsed
|
||||
break;
|
||||
case DMARC_CODES.INVALID_REPORT:
|
||||
// Parsed but failed schema validation
|
||||
break;
|
||||
case DMARC_CODES.UNSUPPORTED_ENCODING:
|
||||
// Gzip decompression failed
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseDmarcAggregate(input, options?): DmarcAggregateReport`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `input: string | Uint8Array` — raw XML string or binary data (plain or gzip-compressed)
|
||||
- `options.decompress?: boolean` — force gzip decompression even without magic bytes
|
||||
|
||||
**Returns:** `DmarcAggregateReport` — fully validated aggregate report
|
||||
|
||||
**Throws:**
|
||||
|
||||
- `DmarcError(DMARC.PARSE_ERROR)` — XML parse failure
|
||||
- `DmarcError(DMARC.INVALID_REPORT)` — missing required elements or schema validation failure
|
||||
- `DmarcError(DMARC.UNSUPPORTED_ENCODING)` — gzip decompression failure
|
||||
|
||||
### `ParseDmarcAggregateOptions`
|
||||
|
||||
`{ decompress?: boolean }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The parser uses a two-phase approach: structural mapping first (XML → plain object), validation second (Zod). This separation means that when validation fails the error message includes Zod's structured issue list rather than a raw type error, making debugging significantly easier. The `performance.now()` timing wraps the entire pipeline so OTel histograms reflect the true end-to-end cost including decompression and schema validation.
|
||||
@@ -0,0 +1,485 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { gzipSync, strToU8 } from "fflate";
|
||||
import { DMARC_CODES, DmarcError } from "../errors/dmarc-error.js";
|
||||
import { parseDmarcAggregate } from "./aggregate.js";
|
||||
|
||||
const MINIMAL_XML = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>Example Corp</org_name>
|
||||
<email>dmarc@example.com</email>
|
||||
<report_id>12345</report_id>
|
||||
<date_range><begin>1609459200</begin><end>1609545600</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>none</p>
|
||||
<pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>192.0.2.1</source_ip>
|
||||
<count>5</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>pass</dkim>
|
||||
<spf>pass</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>example.com</header_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim><domain>example.com</domain><result>pass</result></dkim>
|
||||
<spf><domain>example.com</domain><result>pass</result><scope>mfrom</scope></spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>`;
|
||||
|
||||
const MULTI_RECORD_XML = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>Multi Corp</org_name>
|
||||
<email>dmarc@multi.com</email>
|
||||
<report_id>99</report_id>
|
||||
<date_range><begin>1609459200</begin><end>1609545600</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>multi.com</domain>
|
||||
<adkim>s</adkim>
|
||||
<aspf>s</aspf>
|
||||
<p>reject</p>
|
||||
<sp>quarantine</sp>
|
||||
<pct>50</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>10.0.0.1</source_ip>
|
||||
<count>3</count>
|
||||
<policy_evaluated>
|
||||
<disposition>reject</disposition>
|
||||
<dkim>fail</dkim>
|
||||
<spf>fail</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>multi.com</header_from>
|
||||
<envelope_from>bounce@multi.com</envelope_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim><domain>multi.com</domain><result>fail</result><selector>dkim1</selector></dkim>
|
||||
<spf><domain>multi.com</domain><result>fail</result></spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>10.0.0.2</source_ip>
|
||||
<count>1</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>pass</dkim>
|
||||
<spf>pass</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>multi.com</header_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim><domain>multi.com</domain><result>pass</result></dkim>
|
||||
<spf><domain>multi.com</domain><result>pass</result></spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>`;
|
||||
|
||||
const ERROR_XML = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>Example Corp</org_name>
|
||||
<email>dmarc@example.com</email>
|
||||
<report_id>12345</report_id>
|
||||
<date_range><begin>1609459200</begin><end>1609545600</end></date_range>
|
||||
<error>Something went wrong</error>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>none</p>
|
||||
<pct>100</pct>
|
||||
</policy_published>
|
||||
</feedback>`;
|
||||
|
||||
describe("parseDmarcAggregate", () => {
|
||||
it("parses minimal valid XML", { timeout: 5000 }, () => {
|
||||
const report = parseDmarcAggregate(MINIMAL_XML);
|
||||
// fast-xml-parser coerces "1.0" to the number 1, which str() converts to "1"
|
||||
expect(report.version).toBe("1");
|
||||
expect(report.reportMetadata.orgName).toBe("Example Corp");
|
||||
expect(report.reportMetadata.email).toBe("dmarc@example.com");
|
||||
expect(report.reportMetadata.reportId).toBe("12345");
|
||||
expect(report.reportMetadata.dateRange.begin).toBe(1609459200);
|
||||
expect(report.reportMetadata.dateRange.end).toBe(1609545600);
|
||||
});
|
||||
|
||||
it("maps policy_published fields correctly", { timeout: 5000 }, () => {
|
||||
const report = parseDmarcAggregate(MINIMAL_XML);
|
||||
expect(report.policyPublished.domain).toBe("example.com");
|
||||
expect(report.policyPublished.adkim).toBe("r");
|
||||
expect(report.policyPublished.aspf).toBe("r");
|
||||
expect(report.policyPublished.p).toBe("none");
|
||||
expect(report.policyPublished.pct).toBe(100);
|
||||
});
|
||||
|
||||
it("maps record fields correctly", { timeout: 5000 }, () => {
|
||||
const report = parseDmarcAggregate(MINIMAL_XML);
|
||||
expect(report.record).toHaveLength(1);
|
||||
const rec = report.record[0];
|
||||
expect(rec.row.sourceIp).toBe("192.0.2.1");
|
||||
expect(rec.row.count).toBe(5);
|
||||
expect(rec.row.policyEvaluated.disposition).toBe("none");
|
||||
expect(rec.row.policyEvaluated.dkim).toBe("pass");
|
||||
expect(rec.row.policyEvaluated.spf).toBe("pass");
|
||||
expect(rec.identifiers.headerFrom).toBe("example.com");
|
||||
});
|
||||
|
||||
it("maps auth_results fields", { timeout: 5000 }, () => {
|
||||
const report = parseDmarcAggregate(MINIMAL_XML);
|
||||
const auth = report.record[0].authResults;
|
||||
expect(auth.dkim).toBeDefined();
|
||||
expect(auth.spf).toBeDefined();
|
||||
});
|
||||
|
||||
it("parses gzip-compressed XML (Uint8Array)", { timeout: 5000 }, () => {
|
||||
const compressed = gzipSync(strToU8(MINIMAL_XML));
|
||||
const report = parseDmarcAggregate(compressed);
|
||||
expect(report.reportMetadata.orgName).toBe("Example Corp");
|
||||
});
|
||||
|
||||
it("parses plain XML as Uint8Array", { timeout: 5000 }, () => {
|
||||
const bytes = new TextEncoder().encode(MINIMAL_XML);
|
||||
const report = parseDmarcAggregate(bytes);
|
||||
expect(report.reportMetadata.orgName).toBe("Example Corp");
|
||||
});
|
||||
|
||||
it("handles multiple records", { timeout: 5000 }, () => {
|
||||
const report = parseDmarcAggregate(MULTI_RECORD_XML);
|
||||
expect(report.record).toHaveLength(2);
|
||||
expect(report.policyPublished.sp).toBe("quarantine");
|
||||
});
|
||||
|
||||
it("handles optional envelope_from", { timeout: 5000 }, () => {
|
||||
const report = parseDmarcAggregate(MULTI_RECORD_XML);
|
||||
expect(report.record[0].identifiers.envelopeFrom).toBe(
|
||||
"bounce@multi.com"
|
||||
);
|
||||
});
|
||||
|
||||
it("handles dkim selector", { timeout: 5000 }, () => {
|
||||
const report = parseDmarcAggregate(MULTI_RECORD_XML);
|
||||
const dkim = report.record[0].authResults.dkim;
|
||||
expect(dkim).toBeDefined();
|
||||
});
|
||||
|
||||
it("parses report with error elements", { timeout: 5000 }, () => {
|
||||
const report = parseDmarcAggregate(ERROR_XML);
|
||||
expect(report.reportMetadata.errors).toEqual(["Something went wrong"]);
|
||||
expect(report.record).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("throws DmarcError on missing <feedback>", { timeout: 5000 }, () => {
|
||||
expect(() => parseDmarcAggregate("<notfeedback/>")).toThrowError(
|
||||
DmarcError
|
||||
);
|
||||
try {
|
||||
parseDmarcAggregate("<notfeedback/>");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmarcError);
|
||||
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws DmarcError on missing <report_metadata>", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml = "<feedback><version>1.0</version></feedback>";
|
||||
expect(() => parseDmarcAggregate(xml)).toThrowError(DmarcError);
|
||||
try {
|
||||
parseDmarcAggregate(xml);
|
||||
} catch (err) {
|
||||
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws DmarcError on missing <policy_published>", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml = `<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
|
||||
<date_range><begin>0</begin><end>1</end></date_range>
|
||||
</report_metadata>
|
||||
</feedback>`;
|
||||
expect(() => parseDmarcAggregate(xml)).toThrowError(DmarcError);
|
||||
try {
|
||||
parseDmarcAggregate(xml);
|
||||
} catch (err) {
|
||||
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws DmarcError on malformed XML (unclosed attribute)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseDmarcAggregate('<feedback attr="unclosed')
|
||||
).toThrowError(DmarcError);
|
||||
try {
|
||||
parseDmarcAggregate('<feedback attr="unclosed');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(DmarcError);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws DmarcError when record is missing <row>", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml = `<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
|
||||
<date_range><begin>0</begin><end>1</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
|
||||
</policy_published>
|
||||
<record><identifiers><header_from>x.com</header_from></identifiers></record>
|
||||
</feedback>`;
|
||||
expect(() => parseDmarcAggregate(xml)).toThrowError(DmarcError);
|
||||
try {
|
||||
parseDmarcAggregate(xml);
|
||||
} catch (err) {
|
||||
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws DmarcError when record <row> is missing <policy_evaluated>", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml = `<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
|
||||
<date_range><begin>0</begin><end>1</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row><source_ip>1.2.3.4</source_ip><count>1</count></row>
|
||||
<identifiers><header_from>x.com</header_from></identifiers>
|
||||
<auth_results/>
|
||||
</record>
|
||||
</feedback>`;
|
||||
expect(() => parseDmarcAggregate(xml)).toThrowError(DmarcError);
|
||||
try {
|
||||
parseDmarcAggregate(xml);
|
||||
} catch (err) {
|
||||
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts decompress: true option explicitly", { timeout: 5000 }, () => {
|
||||
const compressed = gzipSync(strToU8(MINIMAL_XML));
|
||||
const report = parseDmarcAggregate(compressed, { decompress: true });
|
||||
expect(report.reportMetadata.orgName).toBe("Example Corp");
|
||||
});
|
||||
|
||||
it("handles multiple DKIM entries in auth_results", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml = `<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
|
||||
<date_range><begin>0</begin><end>1</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>1.2.3.4</source_ip><count>1</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition><dkim>pass</dkim><spf>pass</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers><header_from>x.com</header_from></identifiers>
|
||||
<auth_results>
|
||||
<dkim><domain>x.com</domain><result>pass</result><selector>sel1</selector></dkim>
|
||||
<dkim><domain>y.com</domain><result>pass</result></dkim>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>`;
|
||||
const report = parseDmarcAggregate(xml);
|
||||
expect(Array.isArray(report.record[0].authResults.dkim)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles invalid disposition value (schema validation failure)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml = `<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
|
||||
<date_range><begin>0</begin><end>1</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>invalid_disposition</p><pct>100</pct>
|
||||
</policy_published>
|
||||
</feedback>`;
|
||||
expect(() => parseDmarcAggregate(xml)).toThrowError(DmarcError);
|
||||
try {
|
||||
parseDmarcAggregate(xml);
|
||||
} catch (err) {
|
||||
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws DmarcError when record element is an empty string (non-object)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// <record/> produces "" string in fast-xml-parser, not an object
|
||||
const xml = `<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
|
||||
<date_range><begin>0</begin><end>1</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
|
||||
</policy_published>
|
||||
<record/>
|
||||
</feedback>`;
|
||||
expect(() => parseDmarcAggregate(xml)).toThrowError(DmarcError);
|
||||
try {
|
||||
parseDmarcAggregate(xml);
|
||||
} catch (err) {
|
||||
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
|
||||
}
|
||||
});
|
||||
|
||||
it("num() handles non-numeric string (NaN -> 0) and empty string (0) date range", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// NaN path: non-parseable string -> NaN -> 0
|
||||
const xmlNaN = `<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
|
||||
<date_range><begin>notanumber</begin><end>alsonotanumber</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
|
||||
</policy_published>
|
||||
</feedback>`;
|
||||
const reportNaN = parseDmarcAggregate(xmlNaN);
|
||||
expect(reportNaN.reportMetadata.dateRange.begin).toBe(0);
|
||||
// Finite path: empty string -> Number("") = 0 (finite) -> returns 0
|
||||
const xmlEmpty = `<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
|
||||
<date_range><begin></begin><end></end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
|
||||
</policy_published>
|
||||
</feedback>`;
|
||||
const reportEmpty = parseDmarcAggregate(xmlEmpty);
|
||||
expect(reportEmpty.reportMetadata.dateRange.begin).toBe(0);
|
||||
});
|
||||
|
||||
it("adkim/aspf defaults to 'r' when absent from policy_published", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Missing adkim/aspf elements triggers the || "r" fallback
|
||||
const xml = `<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
|
||||
<date_range><begin>0</begin><end>1</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>x.com</domain><p>none</p><pct>100</pct>
|
||||
</policy_published>
|
||||
</feedback>`;
|
||||
const report = parseDmarcAggregate(xml);
|
||||
expect(report.policyPublished.adkim).toBe("r");
|
||||
expect(report.policyPublished.aspf).toBe("r");
|
||||
});
|
||||
|
||||
it("handles multiple SPF entries with scope in auth_results", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml = `<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
|
||||
<date_range><begin>0</begin><end>1</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>1.2.3.4</source_ip><count>1</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition><dkim>pass</dkim><spf>pass</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers><header_from>x.com</header_from></identifiers>
|
||||
<auth_results>
|
||||
<spf><domain>x.com</domain><result>pass</result><scope>mfrom</scope></spf>
|
||||
<spf><domain>y.com</domain><result>fail</result></spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>`;
|
||||
const report = parseDmarcAggregate(xml);
|
||||
const spf = report.record[0].authResults.spf;
|
||||
expect(Array.isArray(spf)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles envelope_to in identifiers", { timeout: 5000 }, () => {
|
||||
const xml = `<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
|
||||
<date_range><begin>0</begin><end>1</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>1.2.3.4</source_ip><count>1</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition><dkim>pass</dkim><spf>pass</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>x.com</header_from>
|
||||
<envelope_to>dest@x.com</envelope_to>
|
||||
</identifiers>
|
||||
<auth_results/>
|
||||
</record>
|
||||
</feedback>`;
|
||||
const report = parseDmarcAggregate(xml);
|
||||
expect(report.record[0].identifiers.envelopeTo).toBe("dest@x.com");
|
||||
});
|
||||
});
|
||||
310
packages/communication/emails/dmarc/src/parsers/aggregate.ts
Normal file
310
packages/communication/emails/dmarc/src/parsers/aggregate.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { decodeInput } from "../decoders/gzip.js";
|
||||
import { parseXml } from "../decoders/xml.js";
|
||||
import { DMARC_CODES, DmarcError } from "../errors/dmarc-error.js";
|
||||
import { recordParse } from "../otel/dmarc-otel.js";
|
||||
import {
|
||||
type DmarcAggregateReport,
|
||||
DmarcAggregateReportSchema,
|
||||
} from "../schemas/aggregate-report.schema.js";
|
||||
import type { DmarcAuthResult, DmarcRecord } from "../schemas/shared.schema.js";
|
||||
|
||||
/** Options for `parseDmarcAggregate`. */
|
||||
export interface ParseDmarcAggregateOptions {
|
||||
/**
|
||||
* When `true`, the input is decompressed with gzip before XML parsing.
|
||||
* Defaults to auto-detection: binary inputs starting with gzip magic bytes
|
||||
* are always decompressed regardless of this flag.
|
||||
*/
|
||||
decompress?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerces a raw value from fast-xml-parser into a string.
|
||||
* Numeric coercion means integers come back as JS numbers.
|
||||
*/
|
||||
function str(v: unknown): string {
|
||||
if (v === undefined || v === null) return "";
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function num(v: unknown): number {
|
||||
if (typeof v === "number") return v;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the raw `<auth_results>` node to a typed `DmarcAuthResult`.
|
||||
*/
|
||||
function mapAuthResults(raw: unknown): DmarcAuthResult {
|
||||
if (raw === null || typeof raw !== "object") return {};
|
||||
const r = raw as Record<string, unknown>;
|
||||
|
||||
const rawDkim = r["dkim"];
|
||||
const rawSpf = r["spf"];
|
||||
|
||||
let dkim: DmarcAuthResult["dkim"];
|
||||
let spf: DmarcAuthResult["spf"];
|
||||
|
||||
if (rawDkim !== undefined) {
|
||||
// XMLParser isArray config forces <dkim> to always be an array
|
||||
const dkimArr = rawDkim as unknown[];
|
||||
dkim =
|
||||
dkimArr.length === 1
|
||||
? {
|
||||
domain: str(
|
||||
(dkimArr[0] as Record<string, unknown>)["domain"]
|
||||
),
|
||||
result: str(
|
||||
(dkimArr[0] as Record<string, unknown>)["result"]
|
||||
),
|
||||
selector:
|
||||
(dkimArr[0] as Record<string, unknown>)[
|
||||
"selector"
|
||||
] !== undefined
|
||||
? str(
|
||||
(dkimArr[0] as Record<string, unknown>)[
|
||||
"selector"
|
||||
]
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
: dkimArr.map((d) => ({
|
||||
domain: str((d as Record<string, unknown>)["domain"]),
|
||||
result: str((d as Record<string, unknown>)["result"]),
|
||||
selector:
|
||||
(d as Record<string, unknown>)["selector"] !==
|
||||
undefined
|
||||
? str((d as Record<string, unknown>)["selector"])
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
if (rawSpf !== undefined) {
|
||||
// XMLParser isArray config forces <spf> to always be an array
|
||||
const spfArr = rawSpf as unknown[];
|
||||
spf =
|
||||
spfArr.length === 1
|
||||
? {
|
||||
domain: str(
|
||||
(spfArr[0] as Record<string, unknown>)["domain"]
|
||||
),
|
||||
result: str(
|
||||
(spfArr[0] as Record<string, unknown>)["result"]
|
||||
),
|
||||
scope:
|
||||
(spfArr[0] as Record<string, unknown>)["scope"] !==
|
||||
undefined
|
||||
? str(
|
||||
(spfArr[0] as Record<string, unknown>)[
|
||||
"scope"
|
||||
]
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
: spfArr.map((s) => ({
|
||||
domain: str((s as Record<string, unknown>)["domain"]),
|
||||
result: str((s as Record<string, unknown>)["result"]),
|
||||
scope:
|
||||
(s as Record<string, unknown>)["scope"] !== undefined
|
||||
? str((s as Record<string, unknown>)["scope"])
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
return { dkim, spf };
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a raw `<record>` node to a typed `DmarcRecord`.
|
||||
*/
|
||||
function mapRecord(raw: unknown): DmarcRecord {
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
throw new DmarcError(
|
||||
DMARC_CODES.INVALID_REPORT,
|
||||
"Record element is not an object"
|
||||
);
|
||||
}
|
||||
const r = raw as Record<string, unknown>;
|
||||
const row = r["row"] as Record<string, unknown> | undefined;
|
||||
const identifiers = r["identifiers"] as Record<string, unknown> | undefined;
|
||||
const authResults = r["auth_results"];
|
||||
|
||||
if (!row) {
|
||||
throw new DmarcError(
|
||||
DMARC_CODES.INVALID_REPORT,
|
||||
"Record missing <row>"
|
||||
);
|
||||
}
|
||||
|
||||
const policyEval = row["policy_evaluated"] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!policyEval) {
|
||||
throw new DmarcError(
|
||||
DMARC_CODES.INVALID_REPORT,
|
||||
"Record <row> missing <policy_evaluated>"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
row: {
|
||||
sourceIp: str(row["source_ip"]),
|
||||
count: num(row["count"]),
|
||||
policyEvaluated: {
|
||||
disposition: str(policyEval["disposition"]) as
|
||||
| "none"
|
||||
| "quarantine"
|
||||
| "reject",
|
||||
dkim: str(policyEval["dkim"]),
|
||||
spf: str(policyEval["spf"]),
|
||||
},
|
||||
},
|
||||
identifiers: {
|
||||
headerFrom: str(identifiers?.["header_from"]),
|
||||
envelopeFrom:
|
||||
identifiers?.["envelope_from"] !== undefined
|
||||
? str(identifiers["envelope_from"])
|
||||
: undefined,
|
||||
envelopeTo:
|
||||
identifiers?.["envelope_to"] !== undefined
|
||||
? str(identifiers["envelope_to"])
|
||||
: undefined,
|
||||
},
|
||||
authResults: mapAuthResults(authResults),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a DMARC aggregate (RUA) report from XML or gzip-compressed XML.
|
||||
*
|
||||
* The function auto-detects gzip input: any `Uint8Array` beginning with the
|
||||
* gzip magic bytes (`0x1f 0x8b`) is decompressed before XML parsing regardless
|
||||
* of the `decompress` option value. Pass `decompress: true` to force gzip
|
||||
* decompression even when magic-byte detection does not trigger.
|
||||
*
|
||||
* @param input - Raw XML string or binary report data (plain or gzip-compressed).
|
||||
* @param options - Optional parse configuration.
|
||||
* @returns Validated `DmarcAggregateReport`.
|
||||
* @throws {DmarcError} with code `DMARC.PARSE_ERROR` on XML parse failure.
|
||||
* @throws {DmarcError} with code `DMARC.INVALID_REPORT` on schema validation failure.
|
||||
* @throws {DmarcError} with code `DMARC.UNSUPPORTED_ENCODING` on decompression failure.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { parseDmarcAggregate } from "@suaveplan/dmarc/parsers/aggregate";
|
||||
*
|
||||
* const report = parseDmarcAggregate(xmlString);
|
||||
* console.log(report.reportMetadata.orgName);
|
||||
* ```
|
||||
*/
|
||||
export function parseDmarcAggregate(
|
||||
input: string | Uint8Array,
|
||||
options?: ParseDmarcAggregateOptions
|
||||
): DmarcAggregateReport {
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
// Determine whether to decompress.
|
||||
let xml: string;
|
||||
if (
|
||||
input instanceof Uint8Array &&
|
||||
(options?.decompress === true ||
|
||||
(input.length >= 2 && input[0] === 0x1f && input[1] === 0x8b))
|
||||
) {
|
||||
xml = decodeInput(input);
|
||||
} else {
|
||||
xml = decodeInput(input);
|
||||
}
|
||||
|
||||
const raw = parseXml(xml);
|
||||
|
||||
const fb = raw["feedback"] as Record<string, unknown> | undefined;
|
||||
if (!fb) {
|
||||
throw new DmarcError(
|
||||
DMARC_CODES.INVALID_REPORT,
|
||||
"Missing <feedback> root element"
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = fb["report_metadata"] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!metadata) {
|
||||
throw new DmarcError(
|
||||
DMARC_CODES.INVALID_REPORT,
|
||||
"Missing <report_metadata>"
|
||||
);
|
||||
}
|
||||
|
||||
const dateRange = metadata["date_range"] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const policy = fb["policy_published"] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!policy) {
|
||||
throw new DmarcError(
|
||||
DMARC_CODES.INVALID_REPORT,
|
||||
"Missing <policy_published>"
|
||||
);
|
||||
}
|
||||
|
||||
const rawErrors = metadata["error"];
|
||||
// XMLParser isArray config forces <error> to always be an array
|
||||
const errors: string[] | undefined =
|
||||
rawErrors !== undefined
|
||||
? (rawErrors as unknown[]).map(str)
|
||||
: undefined;
|
||||
|
||||
const rawRecords = fb["record"];
|
||||
// XMLParser isArray config forces <record> to always be an array when present
|
||||
const recordsArr: unknown[] = Array.isArray(rawRecords)
|
||||
? rawRecords
|
||||
: [];
|
||||
|
||||
const mapped = {
|
||||
version: str(fb["version"]),
|
||||
reportMetadata: {
|
||||
orgName: str(metadata["org_name"]),
|
||||
email: str(metadata["email"]),
|
||||
reportId: str(metadata["report_id"]),
|
||||
dateRange: {
|
||||
begin: num(dateRange?.["begin"]),
|
||||
end: num(dateRange?.["end"]),
|
||||
},
|
||||
errors,
|
||||
},
|
||||
policyPublished: {
|
||||
domain: str(policy["domain"]),
|
||||
adkim: (str(policy["adkim"]) || "r") as "r" | "s",
|
||||
aspf: (str(policy["aspf"]) || "r") as "r" | "s",
|
||||
p: str(policy["p"]) as "none" | "quarantine" | "reject",
|
||||
sp:
|
||||
policy["sp"] !== undefined
|
||||
? (str(policy["sp"]) as
|
||||
| "none"
|
||||
| "quarantine"
|
||||
| "reject")
|
||||
: undefined,
|
||||
pct: num(policy["pct"]),
|
||||
},
|
||||
record: recordsArr.map(mapRecord),
|
||||
};
|
||||
|
||||
const result = DmarcAggregateReportSchema.safeParse(mapped);
|
||||
if (!result.success) {
|
||||
throw new DmarcError(
|
||||
DMARC_CODES.INVALID_REPORT,
|
||||
`DMARC aggregate report failed schema validation: ${result.error.message}`,
|
||||
{ cause: result.error }
|
||||
);
|
||||
}
|
||||
|
||||
recordParse("aggregate", "success", performance.now() - start);
|
||||
return result.data;
|
||||
} catch (err) {
|
||||
recordParse("aggregate", "error", performance.now() - start);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
89
packages/communication/emails/dmarc/src/parsers/forensic.md
Normal file
89
packages/communication/emails/dmarc/src/parsers/forensic.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# forensic
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `parseDmarcForensic`, the entry point for parsing DMARC forensic (RUF) reports. Forensic reports notify domain owners of individual authentication failures; they share the `<feedback>` / `<report_metadata>` / `<policy_published>` structure with aggregate reports but omit the `<record>` block and may include a raw MIME-formatted `auth_failure_report` element. This module provides the same auto-detect-decompress → parse → map → validate pipeline as the aggregate parser but targets the forensic schema.
|
||||
|
||||
## Features
|
||||
|
||||
- Auto-detects gzip input via magic-byte probe
|
||||
- Accepts raw XML strings or `Uint8Array` binary blobs
|
||||
- Maps snake_case XML fields to camelCase typed fields
|
||||
- Surfaces `authFailureReport` string when present in the source XML
|
||||
- Validates against `DmarcForensicReportSchema` (Zod)
|
||||
- Emits OTel counter and histogram for both success and error paths
|
||||
- Wraps all failures in typed `DmarcError` with stable codes
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseDmarcForensic } from "@suaveplan/dmarc/parsers/forensic";
|
||||
|
||||
const report = parseDmarcForensic(`
|
||||
<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>Example Corp</org_name>
|
||||
<email>dmarc@example.com</email>
|
||||
<report_id>ruf-001</report_id>
|
||||
<date_range><begin>1609459200</begin><end>1609545600</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
|
||||
</policy_published>
|
||||
</feedback>
|
||||
`);
|
||||
|
||||
console.log(report.feedback.reportMetadata.orgName); // "Example Corp"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseDmarcForensic } from "@suaveplan/dmarc/parsers/forensic";
|
||||
import { DmarcError, DMARC_CODES } from "@suaveplan/dmarc/errors";
|
||||
import { gzipSync, strToU8 } from "fflate";
|
||||
|
||||
const compressed = gzipSync(strToU8(xmlString));
|
||||
const report = parseDmarcForensic(compressed);
|
||||
|
||||
// Access optional auth failure report body
|
||||
if (report.authFailureReport) {
|
||||
console.log("Failure report MIME body:", report.authFailureReport);
|
||||
}
|
||||
|
||||
// Discriminated error handling
|
||||
try {
|
||||
parseDmarcForensic("<bad>xml</bad>");
|
||||
} catch (err) {
|
||||
if (err instanceof DmarcError && err.code === DMARC_CODES.INVALID_REPORT) {
|
||||
// Schema validation failed — missing required elements
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseDmarcForensic(input, options?): DmarcForensicReport`
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `input: string | Uint8Array` — raw XML string or binary data
|
||||
- `options.decompress?: boolean` — force gzip decompression
|
||||
|
||||
**Returns:** `DmarcForensicReport` — validated forensic report
|
||||
|
||||
**Throws:**
|
||||
|
||||
- `DmarcError(DMARC.PARSE_ERROR)` — XML parse failure
|
||||
- `DmarcError(DMARC.INVALID_REPORT)` — schema validation failure or missing required elements
|
||||
- `DmarcError(DMARC.UNSUPPORTED_ENCODING)` — gzip decompression failure
|
||||
|
||||
### `ParseDmarcForensicOptions`
|
||||
|
||||
`{ decompress?: boolean }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The forensic parser is structurally identical to the aggregate parser except for the output schema. Both share the same `str`/`num` coercion helpers and the same two-phase (map → validate) approach. In practice, RFC 7489 forensic reports are far less common than aggregate reports and are often transmitted as raw MIME messages rather than standalone XML files; the `authFailureReport` field is therefore optional and surfaced as a plain string for callers to process further if needed.
|
||||
241
packages/communication/emails/dmarc/src/parsers/forensic.test.ts
Normal file
241
packages/communication/emails/dmarc/src/parsers/forensic.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { gzipSync, strToU8 } from "fflate";
|
||||
import { DMARC_CODES, DmarcError } from "../errors/dmarc-error.js";
|
||||
import { parseDmarcForensic } from "./forensic.js";
|
||||
|
||||
const MINIMAL_FORENSIC_XML = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>Example Corp</org_name>
|
||||
<email>dmarc@example.com</email>
|
||||
<report_id>ruf-001</report_id>
|
||||
<date_range><begin>1609459200</begin><end>1609545600</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>none</p>
|
||||
<pct>100</pct>
|
||||
</policy_published>
|
||||
</feedback>`;
|
||||
|
||||
const FORENSIC_WITH_FAILURE_XML = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>Example Corp</org_name>
|
||||
<email>dmarc@example.com</email>
|
||||
<report_id>ruf-002</report_id>
|
||||
<date_range><begin>1609459200</begin><end>1609545600</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>s</adkim>
|
||||
<aspf>s</aspf>
|
||||
<p>quarantine</p>
|
||||
<sp>none</sp>
|
||||
<pct>75</pct>
|
||||
</policy_published>
|
||||
<auth_failure_report>Received: from mail.example.com\nDate: Thu, 1 Jan 2021</auth_failure_report>
|
||||
</feedback>`;
|
||||
|
||||
const FORENSIC_WITH_ERROR_XML = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>Example Corp</org_name>
|
||||
<email>dmarc@example.com</email>
|
||||
<report_id>ruf-003</report_id>
|
||||
<date_range><begin>1609459200</begin><end>1609545600</end></date_range>
|
||||
<error>Something failed</error>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>none</p>
|
||||
<pct>100</pct>
|
||||
</policy_published>
|
||||
</feedback>`;
|
||||
|
||||
describe("parseDmarcForensic", () => {
|
||||
it("parses minimal forensic XML", { timeout: 5000 }, () => {
|
||||
const report = parseDmarcForensic(MINIMAL_FORENSIC_XML);
|
||||
// fast-xml-parser coerces "1.0" to the number 1, which str() converts to "1"
|
||||
expect(report.feedback.version).toBe("1");
|
||||
expect(report.feedback.reportMetadata.orgName).toBe("Example Corp");
|
||||
expect(report.feedback.reportMetadata.reportId).toBe("ruf-001");
|
||||
expect(report.feedback.reportMetadata.dateRange.begin).toBe(1609459200);
|
||||
});
|
||||
|
||||
it("maps policy_published correctly", { timeout: 5000 }, () => {
|
||||
const report = parseDmarcForensic(MINIMAL_FORENSIC_XML);
|
||||
expect(report.feedback.policyPublished.domain).toBe("example.com");
|
||||
expect(report.feedback.policyPublished.adkim).toBe("r");
|
||||
expect(report.feedback.policyPublished.p).toBe("none");
|
||||
expect(report.feedback.policyPublished.pct).toBe(100);
|
||||
});
|
||||
|
||||
it("surfaced authFailureReport when present", { timeout: 5000 }, () => {
|
||||
const report = parseDmarcForensic(FORENSIC_WITH_FAILURE_XML);
|
||||
expect(report.authFailureReport).toBeDefined();
|
||||
expect(typeof report.authFailureReport).toBe("string");
|
||||
expect(report.authFailureReport).toContain("mail.example.com");
|
||||
});
|
||||
|
||||
it("authFailureReport is undefined when absent", { timeout: 5000 }, () => {
|
||||
const report = parseDmarcForensic(MINIMAL_FORENSIC_XML);
|
||||
expect(report.authFailureReport).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles sp field", { timeout: 5000 }, () => {
|
||||
const report = parseDmarcForensic(FORENSIC_WITH_FAILURE_XML);
|
||||
expect(report.feedback.policyPublished.sp).toBe("none");
|
||||
});
|
||||
|
||||
it("parses gzip-compressed XML", { timeout: 5000 }, () => {
|
||||
const compressed = gzipSync(strToU8(MINIMAL_FORENSIC_XML));
|
||||
const report = parseDmarcForensic(compressed);
|
||||
expect(report.feedback.reportMetadata.orgName).toBe("Example Corp");
|
||||
});
|
||||
|
||||
it("parses plain XML as Uint8Array", { timeout: 5000 }, () => {
|
||||
const bytes = new TextEncoder().encode(MINIMAL_FORENSIC_XML);
|
||||
const report = parseDmarcForensic(bytes);
|
||||
expect(report.feedback.reportMetadata.orgName).toBe("Example Corp");
|
||||
});
|
||||
|
||||
it("accepts decompress: true option", { timeout: 5000 }, () => {
|
||||
const compressed = gzipSync(strToU8(MINIMAL_FORENSIC_XML));
|
||||
const report = parseDmarcForensic(compressed, { decompress: true });
|
||||
expect(report.feedback.reportMetadata.orgName).toBe("Example Corp");
|
||||
});
|
||||
|
||||
it("parses forensic report with error element", { timeout: 5000 }, () => {
|
||||
const report = parseDmarcForensic(FORENSIC_WITH_ERROR_XML);
|
||||
expect(report.feedback.reportMetadata.errors).toEqual([
|
||||
"Something failed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws DmarcError on missing <feedback>", { timeout: 5000 }, () => {
|
||||
expect(() => parseDmarcForensic("<notfeedback/>")).toThrowError(
|
||||
DmarcError
|
||||
);
|
||||
try {
|
||||
parseDmarcForensic("<notfeedback/>");
|
||||
} catch (err) {
|
||||
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws DmarcError on missing <report_metadata>", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml = "<feedback><version>1.0</version></feedback>";
|
||||
try {
|
||||
parseDmarcForensic(xml);
|
||||
} catch (err) {
|
||||
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws DmarcError on missing <policy_published>", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml = `<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
|
||||
<date_range><begin>0</begin><end>1</end></date_range>
|
||||
</report_metadata>
|
||||
</feedback>`;
|
||||
try {
|
||||
parseDmarcForensic(xml);
|
||||
} catch (err) {
|
||||
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws DmarcError on malformed XML (unclosed attribute)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseDmarcForensic('<feedback attr="unclosed')
|
||||
).toThrowError(DmarcError);
|
||||
});
|
||||
|
||||
it("handles invalid disposition value (schema validation failure)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const xml = `<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
|
||||
<date_range><begin>0</begin><end>1</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>bad_value</p><pct>100</pct>
|
||||
</policy_published>
|
||||
</feedback>`;
|
||||
expect(() => parseDmarcForensic(xml)).toThrowError(DmarcError);
|
||||
try {
|
||||
parseDmarcForensic(xml);
|
||||
} catch (err) {
|
||||
expect((err as DmarcError).code).toBe(DMARC_CODES.INVALID_REPORT);
|
||||
}
|
||||
});
|
||||
|
||||
it("num() handles non-numeric string (NaN -> 0) and empty string (0) date range", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// NaN path: non-parseable string -> NaN -> 0
|
||||
const xmlNaN = `<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
|
||||
<date_range><begin>notanumber</begin><end>alsonotanumber</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
|
||||
</policy_published>
|
||||
</feedback>`;
|
||||
const reportNaN = parseDmarcForensic(xmlNaN);
|
||||
expect(reportNaN.feedback.reportMetadata.dateRange.begin).toBe(0);
|
||||
expect(reportNaN.feedback.reportMetadata.dateRange.end).toBe(0);
|
||||
// Finite path: empty string -> Number("") = 0 (finite) -> returns 0
|
||||
const xmlEmpty = `<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
|
||||
<date_range><begin></begin><end></end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>x.com</domain><adkim>r</adkim><aspf>r</aspf><p>none</p><pct>100</pct>
|
||||
</policy_published>
|
||||
</feedback>`;
|
||||
const reportEmpty = parseDmarcForensic(xmlEmpty);
|
||||
expect(reportEmpty.feedback.reportMetadata.dateRange.begin).toBe(0);
|
||||
});
|
||||
|
||||
it("adkim/aspf defaults to 'r' when absent from policy_published", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Missing adkim/aspf elements triggers the || "r" fallback
|
||||
const xml = `<feedback>
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>X</org_name><email>x@x.com</email><report_id>1</report_id>
|
||||
<date_range><begin>0</begin><end>1</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>x.com</domain><p>none</p><pct>100</pct>
|
||||
</policy_published>
|
||||
</feedback>`;
|
||||
const report = parseDmarcForensic(xml);
|
||||
expect(report.feedback.policyPublished.adkim).toBe("r");
|
||||
expect(report.feedback.policyPublished.aspf).toBe("r");
|
||||
});
|
||||
});
|
||||
161
packages/communication/emails/dmarc/src/parsers/forensic.ts
Normal file
161
packages/communication/emails/dmarc/src/parsers/forensic.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { decodeInput } from "../decoders/gzip.js";
|
||||
import { parseXml } from "../decoders/xml.js";
|
||||
import { DMARC_CODES, DmarcError } from "../errors/dmarc-error.js";
|
||||
import { recordParse } from "../otel/dmarc-otel.js";
|
||||
import {
|
||||
type DmarcForensicReport,
|
||||
DmarcForensicReportSchema,
|
||||
} from "../schemas/forensic-report.schema.js";
|
||||
|
||||
/** Options for `parseDmarcForensic`. */
|
||||
export interface ParseDmarcForensicOptions {
|
||||
/**
|
||||
* When `true`, the input is decompressed with gzip before XML parsing.
|
||||
* Defaults to auto-detection: binary inputs starting with gzip magic bytes
|
||||
* are always decompressed regardless of this flag.
|
||||
*/
|
||||
decompress?: boolean;
|
||||
}
|
||||
|
||||
function str(v: unknown): string {
|
||||
if (v === undefined || v === null) return "";
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function num(v: unknown): number {
|
||||
if (typeof v === "number") return v;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a DMARC forensic (RUF) report from XML or gzip-compressed XML.
|
||||
*
|
||||
* Forensic reports share the same `<feedback>` root and `<report_metadata>` /
|
||||
* `<policy_published>` structure as aggregate reports but do not carry `<record>`
|
||||
* entries. Instead, MTAs may include a raw MIME-formatted failure report in an
|
||||
* `auth_failure_report` element; this is surfaced as `authFailureReport`.
|
||||
*
|
||||
* @param input - Raw XML string or binary report data (plain or gzip-compressed).
|
||||
* @param options - Optional parse configuration.
|
||||
* @returns Validated `DmarcForensicReport`.
|
||||
* @throws {DmarcError} with code `DMARC.PARSE_ERROR` on XML parse failure.
|
||||
* @throws {DmarcError} with code `DMARC.INVALID_REPORT` on schema validation failure.
|
||||
* @throws {DmarcError} with code `DMARC.UNSUPPORTED_ENCODING` on decompression failure.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { parseDmarcForensic } from "@suaveplan/dmarc/parsers/forensic";
|
||||
*
|
||||
* const report = parseDmarcForensic(xmlString);
|
||||
* console.log(report.feedback.reportMetadata.orgName);
|
||||
* ```
|
||||
*/
|
||||
export function parseDmarcForensic(
|
||||
input: string | Uint8Array,
|
||||
options?: ParseDmarcForensicOptions
|
||||
): DmarcForensicReport {
|
||||
const start = performance.now();
|
||||
|
||||
try {
|
||||
let xml: string;
|
||||
if (
|
||||
input instanceof Uint8Array &&
|
||||
(options?.decompress === true ||
|
||||
(input.length >= 2 && input[0] === 0x1f && input[1] === 0x8b))
|
||||
) {
|
||||
xml = decodeInput(input);
|
||||
} else {
|
||||
xml = decodeInput(input);
|
||||
}
|
||||
|
||||
const raw = parseXml(xml);
|
||||
|
||||
const fb = raw["feedback"] as Record<string, unknown> | undefined;
|
||||
if (!fb) {
|
||||
throw new DmarcError(
|
||||
DMARC_CODES.INVALID_REPORT,
|
||||
"Missing <feedback> root element"
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = fb["report_metadata"] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!metadata) {
|
||||
throw new DmarcError(
|
||||
DMARC_CODES.INVALID_REPORT,
|
||||
"Missing <report_metadata>"
|
||||
);
|
||||
}
|
||||
|
||||
const dateRange = metadata["date_range"] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const policy = fb["policy_published"] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!policy) {
|
||||
throw new DmarcError(
|
||||
DMARC_CODES.INVALID_REPORT,
|
||||
"Missing <policy_published>"
|
||||
);
|
||||
}
|
||||
|
||||
const rawErrors = metadata["error"];
|
||||
// XMLParser isArray config forces <error> to always be an array
|
||||
const errors: string[] | undefined =
|
||||
rawErrors !== undefined
|
||||
? (rawErrors as unknown[]).map(str)
|
||||
: undefined;
|
||||
|
||||
const mapped = {
|
||||
feedback: {
|
||||
version: str(fb["version"]),
|
||||
reportMetadata: {
|
||||
orgName: str(metadata["org_name"]),
|
||||
email: str(metadata["email"]),
|
||||
reportId: str(metadata["report_id"]),
|
||||
dateRange: {
|
||||
begin: num(dateRange?.["begin"]),
|
||||
end: num(dateRange?.["end"]),
|
||||
},
|
||||
errors,
|
||||
},
|
||||
policyPublished: {
|
||||
domain: str(policy["domain"]),
|
||||
adkim: (str(policy["adkim"]) || "r") as "r" | "s",
|
||||
aspf: (str(policy["aspf"]) || "r") as "r" | "s",
|
||||
p: str(policy["p"]) as "none" | "quarantine" | "reject",
|
||||
sp:
|
||||
policy["sp"] !== undefined
|
||||
? (str(policy["sp"]) as
|
||||
| "none"
|
||||
| "quarantine"
|
||||
| "reject")
|
||||
: undefined,
|
||||
pct: num(policy["pct"]),
|
||||
},
|
||||
},
|
||||
authFailureReport:
|
||||
fb["auth_failure_report"] !== undefined
|
||||
? str(fb["auth_failure_report"])
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const result = DmarcForensicReportSchema.safeParse(mapped);
|
||||
if (!result.success) {
|
||||
throw new DmarcError(
|
||||
DMARC_CODES.INVALID_REPORT,
|
||||
`DMARC forensic report failed schema validation: ${result.error.message}`,
|
||||
{ cause: result.error }
|
||||
);
|
||||
}
|
||||
|
||||
recordParse("forensic", "success", performance.now() - start);
|
||||
return result.data;
|
||||
} catch (err) {
|
||||
recordParse("forensic", "error", performance.now() - start);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
5
packages/communication/emails/dmarc/src/parsers/index.ts
Normal file
5
packages/communication/emails/dmarc/src/parsers/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type ParseDmarcAggregateOptions,
|
||||
parseDmarcAggregate,
|
||||
} from "./aggregate";
|
||||
export { type ParseDmarcForensicOptions, parseDmarcForensic } from "./forensic";
|
||||
@@ -0,0 +1,20 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
DmarcPolicyPublishedSchema,
|
||||
DmarcRecordSchema,
|
||||
DmarcReportMetadataSchema,
|
||||
} from "./shared.schema.js";
|
||||
|
||||
/**
|
||||
* Full Zod schema for a DMARC aggregate (RUA) report.
|
||||
*
|
||||
* Maps directly onto the RFC 7489 `<feedback>` root element.
|
||||
*/
|
||||
export const DmarcAggregateReportSchema = z.object({
|
||||
version: z.string(),
|
||||
reportMetadata: DmarcReportMetadataSchema,
|
||||
policyPublished: DmarcPolicyPublishedSchema,
|
||||
record: z.array(DmarcRecordSchema),
|
||||
});
|
||||
|
||||
export type DmarcAggregateReport = z.infer<typeof DmarcAggregateReportSchema>;
|
||||
@@ -0,0 +1,23 @@
|
||||
import z from "zod";
|
||||
import {
|
||||
DmarcPolicyPublishedSchema,
|
||||
DmarcReportMetadataSchema,
|
||||
} from "./shared.schema.js";
|
||||
|
||||
/**
|
||||
* Full Zod schema for a DMARC forensic (RUF) report.
|
||||
*
|
||||
* A forensic report wraps the standard `<feedback>` elements and may include
|
||||
* an `authFailureReport` field carrying the raw MIME-formatted failure report
|
||||
* as provided by reporting MTAs.
|
||||
*/
|
||||
export const DmarcForensicReportSchema = z.object({
|
||||
feedback: z.object({
|
||||
version: z.string(),
|
||||
reportMetadata: DmarcReportMetadataSchema,
|
||||
policyPublished: DmarcPolicyPublishedSchema,
|
||||
}),
|
||||
authFailureReport: z.string().optional(),
|
||||
});
|
||||
|
||||
export type DmarcForensicReport = z.infer<typeof DmarcForensicReportSchema>;
|
||||
34
packages/communication/emails/dmarc/src/schemas/index.ts
Normal file
34
packages/communication/emails/dmarc/src/schemas/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export {
|
||||
type DmarcAggregateReport,
|
||||
DmarcAggregateReportSchema,
|
||||
} from "./aggregate-report.schema";
|
||||
export {
|
||||
type DmarcForensicReport,
|
||||
DmarcForensicReportSchema,
|
||||
} from "./forensic-report.schema";
|
||||
export {
|
||||
type AlignmentMode,
|
||||
AlignmentModeSchema,
|
||||
type DispositionMode,
|
||||
DispositionModeSchema,
|
||||
type DkimResult,
|
||||
DkimResultSchema,
|
||||
type DmarcAuthResult,
|
||||
DmarcAuthResultSchema,
|
||||
type DmarcDateRange,
|
||||
DmarcDateRangeSchema,
|
||||
type DmarcIdentifiers,
|
||||
DmarcIdentifiersSchema,
|
||||
type DmarcPolicyPublished,
|
||||
DmarcPolicyPublishedSchema,
|
||||
type DmarcRecord,
|
||||
DmarcRecordSchema,
|
||||
type DmarcReportMetadata,
|
||||
DmarcReportMetadataSchema,
|
||||
type DmarcRow,
|
||||
DmarcRowSchema,
|
||||
type PolicyEvaluated,
|
||||
PolicyEvaluatedSchema,
|
||||
type SpfResult,
|
||||
SpfResultSchema,
|
||||
} from "./shared.schema";
|
||||
116
packages/communication/emails/dmarc/src/schemas/shared.schema.ts
Normal file
116
packages/communication/emails/dmarc/src/schemas/shared.schema.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Alignment mode: `r` (relaxed) or `s` (strict).
|
||||
*/
|
||||
export const AlignmentModeSchema = z.enum(["r", "s"]);
|
||||
export type AlignmentMode = z.infer<typeof AlignmentModeSchema>;
|
||||
|
||||
/**
|
||||
* Disposition mode: `none`, `quarantine`, or `reject`.
|
||||
*/
|
||||
export const DispositionModeSchema = z.enum(["none", "quarantine", "reject"]);
|
||||
export type DispositionMode = z.infer<typeof DispositionModeSchema>;
|
||||
|
||||
/**
|
||||
* DMARC date range — Unix timestamps (begin/end inclusive).
|
||||
*/
|
||||
export const DmarcDateRangeSchema = z.object({
|
||||
begin: z.number().int(),
|
||||
end: z.number().int(),
|
||||
});
|
||||
export type DmarcDateRange = z.infer<typeof DmarcDateRangeSchema>;
|
||||
|
||||
/**
|
||||
* Policy published for the sending domain.
|
||||
*/
|
||||
export const DmarcPolicyPublishedSchema = z.object({
|
||||
domain: z.string(),
|
||||
adkim: AlignmentModeSchema,
|
||||
aspf: AlignmentModeSchema,
|
||||
p: DispositionModeSchema,
|
||||
sp: DispositionModeSchema.optional(),
|
||||
pct: z.number().int().min(0).max(100),
|
||||
});
|
||||
export type DmarcPolicyPublished = z.infer<typeof DmarcPolicyPublishedSchema>;
|
||||
|
||||
/**
|
||||
* DKIM authentication result sub-record.
|
||||
*/
|
||||
export const DkimResultSchema = z.object({
|
||||
domain: z.string(),
|
||||
result: z.string(),
|
||||
selector: z.string().optional(),
|
||||
});
|
||||
export type DkimResult = z.infer<typeof DkimResultSchema>;
|
||||
|
||||
/**
|
||||
* SPF authentication result sub-record.
|
||||
*/
|
||||
export const SpfResultSchema = z.object({
|
||||
domain: z.string(),
|
||||
result: z.string(),
|
||||
scope: z.string().optional(),
|
||||
});
|
||||
export type SpfResult = z.infer<typeof SpfResultSchema>;
|
||||
|
||||
/**
|
||||
* Combined authentication results for a single message sample.
|
||||
*/
|
||||
export const DmarcAuthResultSchema = z.object({
|
||||
dkim: z.union([DkimResultSchema, z.array(DkimResultSchema)]).optional(),
|
||||
spf: z.union([SpfResultSchema, z.array(SpfResultSchema)]).optional(),
|
||||
});
|
||||
export type DmarcAuthResult = z.infer<typeof DmarcAuthResultSchema>;
|
||||
|
||||
/**
|
||||
* Policy evaluation outcome for a row.
|
||||
*/
|
||||
export const PolicyEvaluatedSchema = z.object({
|
||||
disposition: DispositionModeSchema,
|
||||
dkim: z.string(),
|
||||
spf: z.string(),
|
||||
});
|
||||
export type PolicyEvaluated = z.infer<typeof PolicyEvaluatedSchema>;
|
||||
|
||||
/**
|
||||
* Row data: source IP, message count, and policy evaluation result.
|
||||
*/
|
||||
export const DmarcRowSchema = z.object({
|
||||
sourceIp: z.string(),
|
||||
count: z.number().int().nonnegative(),
|
||||
policyEvaluated: PolicyEvaluatedSchema,
|
||||
});
|
||||
export type DmarcRow = z.infer<typeof DmarcRowSchema>;
|
||||
|
||||
/**
|
||||
* Identifier fields: header-from, optional envelope-from and envelope-to.
|
||||
*/
|
||||
export const DmarcIdentifiersSchema = z.object({
|
||||
headerFrom: z.string(),
|
||||
envelopeFrom: z.string().optional(),
|
||||
envelopeTo: z.string().optional(),
|
||||
});
|
||||
export type DmarcIdentifiers = z.infer<typeof DmarcIdentifiersSchema>;
|
||||
|
||||
/**
|
||||
* A single aggregate report record: row + identifiers + auth results.
|
||||
*/
|
||||
export const DmarcRecordSchema = z.object({
|
||||
row: DmarcRowSchema,
|
||||
identifiers: DmarcIdentifiersSchema,
|
||||
authResults: DmarcAuthResultSchema,
|
||||
});
|
||||
export type DmarcRecord = z.infer<typeof DmarcRecordSchema>;
|
||||
|
||||
/**
|
||||
* Report metadata block.
|
||||
*/
|
||||
export const DmarcReportMetadataSchema = z.object({
|
||||
orgName: z.string(),
|
||||
email: z.string(),
|
||||
reportId: z.string(),
|
||||
dateRange: DmarcDateRangeSchema,
|
||||
errors: z.array(z.string()).optional(),
|
||||
});
|
||||
export type DmarcReportMetadata = z.infer<typeof DmarcReportMetadataSchema>;
|
||||
36
packages/communication/emails/dmarc/tests/dep-graph.test.ts
Normal file
36
packages/communication/emails/dmarc/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("has no @suaveplan/* runtime deps (leaf package)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
|
||||
.filter((name) => name.startsWith("@suaveplan/"))
|
||||
.sort();
|
||||
expect(runtimeScoped).toEqual([]);
|
||||
});
|
||||
|
||||
it("runtime deps are exactly the expected set (sorted)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(runtime).toEqual([
|
||||
"@opentelemetry/api",
|
||||
"fast-xml-parser",
|
||||
"fflate",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC = join(__dirname, "..", "src");
|
||||
|
||||
// Note: fflate and fast-xml-parser are external dependencies — their internal
|
||||
// use of node: builtins doesn't count. We only scan src/ for direct usage.
|
||||
const BANNED: ReadonlyArray<RegExp> = [
|
||||
/\bnode:fs\b/,
|
||||
/\bnode:path\b/,
|
||||
/\bnode:url\b/,
|
||||
/\bprocess\./,
|
||||
/\bBuffer\b/,
|
||||
/\bdocument\b/,
|
||||
/\bwindow\b/,
|
||||
];
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) yield* walk(full);
|
||||
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
describe("no-disallowed", () => {
|
||||
it("src/ does not reference Node or browser built-ins", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hits: string[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
for (const pattern of BANNED) {
|
||||
if (pattern.test(content)) {
|
||||
hits.push(`${file} matched ${pattern}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
20
packages/communication/emails/dmarc/tests/tree-shake.test.ts
Normal file
20
packages/communication/emails/dmarc/tests/tree-shake.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("tree-shake", () => {
|
||||
it("sideEffects is false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("exports has at least 3 entries", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { exports?: Record<string, unknown> };
|
||||
expect(pkg.exports).toBeDefined();
|
||||
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
12
packages/communication/emails/dmarc/tsconfig.lib.json
Normal file
12
packages/communication/emails/dmarc/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
19
packages/communication/emails/dmarc/vite.config.ts
Normal file
19
packages/communication/emails/dmarc/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/dmarc",
|
||||
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
|
||||
external: ["fast-xml-parser", "fflate", "tslib", "zod", "@opentelemetry/api", /^node:/],
|
||||
},
|
||||
},
|
||||
});
|
||||
27
packages/communication/emails/dmarc/vitest.config.ts
Normal file
27
packages/communication/emails/dmarc/vitest.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user