Skip to content

HT-11: inbound email parser (postal-mime) - #7

Merged
zaridan merged 3 commits into
mainfrom
feat/ht-11-mail-parser
Jul 10, 2026
Merged

HT-11: inbound email parser (postal-mime)#7
zaridan merged 3 commits into
mainfrom
feat/ht-11-mail-parser

Conversation

@zaridan

@zaridan zaridan commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

The first mail-engine module — a pure function that turns a raw RFC822/MIME email into a normalized ParsedEmail. No I/O, no storage, no side effects.

Built on postal-mime (MIT-0, serverless MIME parser — verified at adoption). Faithfully surfaces the threading-critical headers the spec depends on:

  • messageId / inReplyTo — verbatim
  • references — split into an ordered array of message-ids (threading depends on this — tested against folded multi-id headers)
  • html captured verbatim / unsanitized (sanitization is a later, separate step — matches the observed 'inbound HTML stored raw' behavior)
  • attachments carry bytes (blob storage happens at the store layer; ParsedEmail is intentionally richer than the providers' NormalizedInboundEmail)

Test-first: 8 hand-authored RFC 5322 fixtures (plain, multipart, script-tag HTML, threading headers, attachment, encoded-word subject, quoted-printable, missing-headers) + 17 Vitest cases. 94% coverage on parse.ts; verified locally green (typecheck/lint/test).

Also adds @types/node (was absent — needed for fs and any future Node-touching module) and sets tsconfig types to ["node"].

Jira: https://resonantiq.atlassian.net/browse/HT-11

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added inbound email parsing that converts raw RFC 5322/MIME messages into structured sender/recipients, subject, dates, threading references, normalized headers, and inline attachment bytes.
    • Handles multipart content (including HTML/script passthrough), quoted-printable decoding, RFC 2047 encoded-word subjects, grouped addresses, and reference token extraction.
  • Tests

    • Expanded parsing test coverage with additional scenarios (missing/invalid headers, threading variations, safe header key handling) and added multiple new EML fixtures.
  • Chores

    • Updated runtime and type configuration (including Node.js types) to support the new parser.

Pure raw-MIME -> ParsedEmail parser, the first mail-engine module. Built
on postal-mime (MIT-0). Faithfully surfaces the threading-critical
headers (messageId, inReplyTo, references-as-ordered-array) and captures
HTML verbatim (unsanitized — sanitization is a later, separate step).
Attachments carry bytes (blob storage happens at the store layer).

Test-first: 8 hand-authored RFC 5322 fixtures + 17 Vitest cases; 94%
coverage on parse.ts. Verified: typecheck, lint, tests all green.

Adds @types/node (was absent; needed for fs + any future Node-touching
module) and flips tsconfig types to ["node"].

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ac2ae767-add2-4f30-bd2b-680f8048b809

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds parseInboundEmail, exported parsed-email structures, postal-mime integration, normalization helpers, Node.js type configuration, and fixture-backed tests for MIME bodies, headers, addresses, threading, decoding, and attachments.

Changes

Inbound email parsing

Layer / File(s) Summary
Parser contract and setup
src/mail/parse.ts, package.json, tsconfig.json
Defines ParsedEmail, ParsedAddress, and ParsedAttachment, adds postal-mime, and enables Node.js types.
Parsing and normalization
src/mail/parse.ts
Parses raw email input and normalizes threading headers, dates, addresses, repeated headers, bodies, and attachment bytes.
Fixture-backed parser validation
src/mail/parse.test.ts, tests/mail/fixtures/*
Tests plain text, multipart content, decoding, HTML bodies, optional headers, threading, address groups, repeated headers, prototype-safe headers, and attachments.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant parseInboundEmail
  participant PostalMime
  Caller->>parseInboundEmail: raw email input
  parseInboundEmail->>PostalMime: parse(raw)
  PostalMime-->>parseInboundEmail: parsed email data
  parseInboundEmail-->>Caller: normalized ParsedEmail
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding an inbound email parser built on postal-mime.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-11-mail-parser

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
src/mail/parse.test.ts (1)

12-31: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the binary parser inputs.

parseInboundEmail accepts string | Uint8Array | ArrayBuffer, but the tests only pass strings. Add equivalent fixture tests for both binary forms to protect the public input contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mail/parse.test.ts` around lines 12 - 31, Add equivalent tests for
parseInboundEmail using both Uint8Array and ArrayBuffer inputs, reusing the
plain-text-simple fixture bytes and asserting the same parsed fields as the
existing string-input test. This should cover the complete public input contract
without duplicating unrelated test setup.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/mail/parse.ts`:
- Around line 169-182: Update toParsedAddress and the ParsedEmail.from type to
preserve RFC 6854 group-form senders instead of returning null when only
addr.group is present. Add a group representation compatible with existing
ParsedAddress consumers, retain the group name and member addresses (including
empty groups), and add fixtures/tests covering named groups and empty-group From
values.
- Around line 219-225: Update toHeaderRecord to initialize its accumulator with
Object.create(null) instead of a plain object, preserving reserved header names
such as __proto__ and constructor; add a regression test covering a reserved
header name and verifying its value is retained correctly.
- Around line 152-155: Update parseReferences to extract RFC 5322 msg-id values
rather than splitting on whitespace, so CFWS/comments between IDs are ignored
and only bracketed message IDs are returned. Add a fixture covering a References
header such as `<a@example.test> (legacy MUA) <b@example.test>` and verify the
result contains only both IDs.

---

Nitpick comments:
In `@src/mail/parse.test.ts`:
- Around line 12-31: Add equivalent tests for parseInboundEmail using both
Uint8Array and ArrayBuffer inputs, reusing the plain-text-simple fixture bytes
and asserting the same parsed fields as the existing string-input test. This
should cover the complete public input contract without duplicating unrelated
test setup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7151f5d5-dfc6-46f0-a4c8-8a50a4513400

📥 Commits

Reviewing files that changed from the base of the PR and between d811934 and fa89751.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (12)
  • package.json
  • src/mail/parse.test.ts
  • src/mail/parse.ts
  • tests/mail/fixtures/attachment.eml
  • tests/mail/fixtures/encoded-word-subject.eml
  • tests/mail/fixtures/html-script-body.eml
  • tests/mail/fixtures/missing-optional-headers.eml
  • tests/mail/fixtures/multipart-alternative.eml
  • tests/mail/fixtures/plain-text-simple.eml
  • tests/mail/fixtures/quoted-printable-body.eml
  • tests/mail/fixtures/threading-headers.eml
  • tsconfig.json

Comment thread src/mail/parse.ts
Comment thread src/mail/parse.ts Outdated
Comment on lines +169 to +182
/**
* postal-mime's `Address` type is a union of a plain `Mailbox`
* (`{name, address}`) and an RFC 5322 address GROUP
* (`{name, group: Mailbox[]}`, no top-level `address`) — group syntax is
* legal in `To`/`Cc`/`Bcc` (e.g. `undisclosed-recipients:;`) but not in
* `From`. `ParsedAddress` has no group concept, so a bare group here (no
* `address`) maps to `null` — this only matters for `From`, where a group
* would be non-conformant mail anyway. `to`/`cc` use `toParsedAddressList`
* below, which flattens groups into their member addresses instead of
* dropping them.
*/
function toParsedAddress(addr: Address | undefined): ParsedAddress | null {
if (!addr?.address) return null
return addr.name ? { address: addr.address, name: addr.name } : { address: addr.address }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

sed -n '1,260p' src/mail/parse.ts

Repository: Helpthread/helpthread

Length of output: 9824


🏁 Script executed:

git ls-files | rg '(^|/)(test|spec|fixture|fixtures|__tests__|parse).*'

Repository: Helpthread/helpthread

Length of output: 1016


🏁 Script executed:

sed -n '1,260p' src/mail/parse.test.ts && printf '\n---\n' && sed -n '1,260p' specs/mail/threading.md && printf '\n---\n' && rg -n "group|undisclosed|Sender|From:" tests/mail fixtures/mail src/mail specs/mail -S

Repository: Helpthread/helpthread

Length of output: 22878


🌐 Web query:

RFC 6854 group syntax From Sender allow group syntax in From Sender RFC 5322

💡 Result:

RFC 6854 formally updates the Internet Message Format specification (RFC 5322) to allow the use of "group" syntax in the "From:" and "Sender:" header fields [1][2]. Key details regarding this update include: 1. Permissibility: While RFC 5322 previously restricted "group" syntax to fields such as "To:" and "Cc:", RFC 6854 explicitly relaxes this restriction to allow group syntax in "From:", "Sender:", "Resent-From:", and "Resent-Sender:" [1][3]. 2. Recommendations: Despite permitting this syntax, the RFC includes an applicability statement that recommends against the general use of group syntax in these originator fields [4]. It states that the "From:" and "Sender:" fields should ideally continue to use standard mailbox-list and mailbox syntax, respectively [1][3]. 3. Constraints: If group syntax is used in the "Sender:" field, the group must not contain more than one mailbox [1][3]. Additionally, if the "From:" field contains more than one mailbox (whether listed directly or contained within groups), the message must include a "Sender:" field with a single address [1][3]. This update effectively reconciles the syntax rules for originator fields with those used elsewhere in the Internet Message Format [2][4].

Citations:


Preserve RFC 6854 group-form From values. From: can legally use group syntax under RFC 6854, so toParsedAddress() shouldn’t collapse a group-form sender to null. A value like From: Automated Systems:alice@example.test; currently loses its members; make ParsedEmail.from group-aware (or add a separate group representation) and cover named/empty-group fixtures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mail/parse.ts` around lines 169 - 182, Update toParsedAddress and the
ParsedEmail.from type to preserve RFC 6854 group-form senders instead of
returning null when only addr.group is present. Add a group representation
compatible with existing ParsedAddress consumers, retain the group name and
member addresses (including empty groups), and add fixtures/tests covering named
groups and empty-group From values.

Sources: Coding guidelines, MCP tools

Comment thread src/mail/parse.ts
…ening

Parser handles untrusted input (emails from anyone), so these matter:

- 🔒 Security (Minor): toHeaderRecord now uses a null-prototype object.
  A header literally named __proto__ or constructor is stored as an
  ordinary own key instead of mutating the prototype (dropping the
  header) or reading an inherited value (garbage). + regression test.
- 🎯 Correctness (Major, RFC 6854): a group-form From no longer collapses
  to null — it maps to the group's first member so the sender isn't lost.
- 🎯 Correctness (Minor, threading-critical): References are extracted as
  angle-bracketed <...> message-ids via regex, so CFWS/comments legally
  interspersed between ids don't leak in as bogus 'message-ids'.

Three regression tests added (parse.test.ts: 15 -> 18). Verified: lint
clean, typecheck exit 0, all tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/mail/parse.ts (1)

65-79: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stale doc: references field comment describes the old whitespace-splitting behavior.

parseReferences (Lines 146-156) now extracts angle-bracketed <...> message-ids via regex to correctly ignore CFWS/comments, but this field-level doc still says "This function splits that string on runs of whitespace, which correctly handles both space-separated and folded/newline-separated ids". Since this field is documented as threading-critical, a stale description here could mislead future changes to threading logic.

📝 Proposed doc fix
-   * Threading-critical: postal-mime exposes `References` as a single raw
-   * string (already unfolded — folded continuation lines are merged with
-   * an embedded newline, not collapsed to a header object per id). This
-   * function splits that string on runs of whitespace, which correctly
-   * handles both space-separated and folded/newline-separated ids without
-   * losing or reordering any entry. specs/mail/threading.md §3 rule 1
+   * Threading-critical: postal-mime exposes `References` as a single raw
+   * string (already unfolded — folded continuation lines are merged with
+   * an embedded newline, not collapsed to a header object per id). This
+   * function extracts angle-bracketed `<...>` message-id tokens (RFC 5322
+   * §3.6.4), ignoring CFWS/comments interspersed between them, without
+   * losing or reordering any entry. specs/mail/threading.md §3 rule 1
    * scans this array most-recent-first — i.e. the CONSUMER reverses it;
    * this parser preserves wire order and does not reverse.
    */
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mail/parse.ts` around lines 65 - 79, Update the field-level documentation
for references to describe parseReferences extracting angle-bracketed
message-ids via regex while ignoring CFWS/comments, rather than splitting on
whitespace. Preserve the documented wire order, empty-header behavior, and
consumer-side most-recent-first reversal guidance.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/mail/parse.ts`:
- Around line 65-79: Update the field-level documentation for references to
describe parseReferences extracting angle-bracketed message-ids via regex while
ignoring CFWS/comments, rather than splitting on whitespace. Preserve the
documented wire order, empty-header behavior, and consumer-side
most-recent-first reversal guidance.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 50f2be7b-c3c0-4b4e-9909-1ec43fe6d745

📥 Commits

Reviewing files that changed from the base of the PR and between fa89751 and a386ef8.

📒 Files selected for processing (2)
  • src/mail/parse.test.ts
  • src/mail/parse.ts

The References field-doc still described the old whitespace-splitting
behavior after the code moved to regex message-id extraction. Updated
to match — a doc contradicting the code on threading-critical parsing
is exactly what misleads future changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b
@zaridan

zaridan commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@zaridan
zaridan merged commit a8daccd into main Jul 10, 2026
4 checks passed
@zaridan
zaridan deleted the feat/ht-11-mail-parser branch July 10, 2026 17:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant