# Spec delta — email-processing ## ADDED Requirements ### Requirement: Parse RFC 822 messages with hook pipeline The package SHALL expose `parseEmail(raw, options?)` that: - accepts `string | ArrayBuffer | Uint8Array`, - validates `options` via a strict Zod schema, - raises `EmailProcessingError(INVALID_INPUT)` for null / undefined / wrong-type `raw`, - raises `EmailProcessingError(INVALID_OPTIONS)` for any Zod failure, - wraps `postal-mime` parse failures in `EmailProcessingError(PARSE_FAILURE)` and increments the OTel counter `email_processing.parse.failure_total`, - on success, increments `email_processing.parse.total` (attributed by attachment count) and pipes the result through the `email:processing` hook pipeline. #### Scenario: Successful parse threads through the hook pipeline - **WHEN** `parseEmail` is called with a valid multipart MIME string and an `onEmailProcessing` handler is registered that mutates `parsed.subject` - **THEN** the returned `ParsedEmail.subject` reflects the handler's transformation. ### Requirement: Attachment processing with size + MIME allowlist `processAttachment` SHALL hash content with SHA-256, validate against `maxSize` (default 25 MiB) and `allowedMimeTypes` (wildcard `image/*` supported), increment `email_processing.attachment.processed_total` or `...rejected_total` (attributed by reason), and record an `...extraction_duration_ms` histogram entry per call. #### Scenario: Attachment over maxSize is rejected with reason=size - **WHEN** `processAttachment` is called with content larger than the configured `maxSize` - **THEN** the result has `valid: false`, the `error` mentions the size limit, and the rejected counter is incremented with `reason=size`. ### Requirement: Hook pipeline with public registry The package SHALL expose `Hooks`, `emailHooks`, and `onEmailProcessing`. Handler errors SHALL short-circuit the pipeline and surface as `EmailProcessingError(HOOK_FAILURE)`. The `email_processing.hook.invocation_total` counter SHALL be incremented once per successful pipeline invocation, attributed by event name and handler count. ### Requirement: DKIM / SPF / DMARC verification - `verifyDkim` SHALL parse the `DKIM-Signature` header, look up the public key through an injectable DNS resolver, recompute the body hash, rebuild the canonicalised signed-header block, call `crypto.subtle.verify`, and emit `email_processing.dkim.result_total` attributed by outcome. - `checkSpf` SHALL enforce `MAX_INCLUDE_DEPTH = 10` and `MAX_DNS_LOOKUPS = 10`, emit `email_processing.spf.result_total` attributed by outcome. - `checkDmarc` SHALL combine SPF + DKIM into a relaxed / strict alignment evaluation, emit `email_processing.dmarc.result_total` attributed by outcome. ### Requirement: Heuristic spam scoring `scoreSpam` SHALL apply the twelve default rules, accept `customRules` (which replace defaults with the same `id`), clamp the score at `[0, 10]`, classify against `suspiciousThreshold` (default 3) and `spamThreshold` (default 6), and emit `email_processing.spam.scored_total` attributed by classification. ### Requirement: Threading `groupThreads` SHALL produce one `EmailThread` per conversation, using `In-Reply-To`, `References`, and a subject-change heuristic, sorted chronologically by first-message date. The `email_processing.thread.grouped_total` counter SHALL be incremented with the produced thread count per call. ### Requirement: Stable error registry The package SHALL expose `EMAIL_PROCESSING_CODES` containing exactly eight codes — `INVALID_INPUT`, `PARSE_FAILURE`, `INVALID_OPTIONS`, `ATTACHMENT_TOO_LARGE`, `ATTACHMENT_TYPE_REJECTED`, `HASH_FAILURE`, `DKIM_VERIFY_FAILURE`, `HOOK_FAILURE`. Every code SHALL be thrown by at least one production path. `EmailProcessingError`'s constructor SHALL be `public`. #### Scenario: Public constructor allows direct construction - **WHEN** a downstream package imports `EmailProcessingError` and `EMAIL_PROCESSING_CODES.INVALID_INPUT` and calls `new EmailProcessingError(code, key)` - **THEN** TypeScript accepts the call and the resulting instance has `code === "EMAIL_PROCESSING.INVALID_INPUT"`.