Skip to content

feat(mail): provider-agnostic inbound ingest pipeline (HT-37) - #38

Merged
zaridan merged 2 commits into
mainfrom
feat/ht-37-inbound-ingest-pipeline
Jul 14, 2026
Merged

feat(mail): provider-agnostic inbound ingest pipeline (HT-37)#38
zaridan merged 2 commits into
mainfrom
feat/ht-37-inbound-ingest-pipeline

Conversation

@zaridan

@zaridan zaridan commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Implements HT-37 [D] — the provider-agnostic inbound ingest pipeline, the correctness heart of inbound. Under the HT-33 epic.

What's here

  • src/mail/ingest.tsingestInboundMessage(raw, deps): the spec §3 five-step pipeline — atomic claim → parse (once) → loop-guard → decideThreading → store + ledger-mark in one transaction. Idempotent, at-least-once, dead-letters after MAX_INGEST_ATTEMPTS.
  • src/store/inbound-deliveries.ts — the delivery ledger store: atomic claim (get-or-insert + race-safe failed-row reclaim), markSuppressed/markFailed/markDeadLetter, and the transaction-scoped markStoredInTx.
  • src/store/conversations.ts — extracted createConversationInTx/appendThreadInTx (the existing transaction bodies) so the ingest can compose the store write + ledger mark into one transaction. The public methods are now thin wrappers — behavior byte-identical (verified in the diff; all pre-existing tests pass unchanged).

The crux — store-write + received→stored as one atomic unit (spec §4) — is solved by that extraction: ingest.ts opens one db.transaction and runs createConversationInTx/appendThreadInTx + markStoredInTx against the same tx; a crash rolls back both, never a half-written outcome.

Review notes (mine)

  • Verified the conversations.ts refactor is byte-identical to the original transaction bodies — the sacred store is behaviorally unchanged.
  • Loop-suppression judgment is exactly right: it suppresses only a verifiable own-Message-ID reflection and deliberately leaves In-Reply-To/References to normal threading — so a customer's auto-responder still gets ingested per auto-submitted.json / spec §5. Never suppresses on sender identity (invariant HT-3: clean-room protocol doc #1 held).
  • Full suite: 436 tests pass clean in isolation; typecheck + biome clean.

One real gap — flagged for a follow-up (not a happy-path bug)

A hard process crash (SIGKILL / OOM / redeploy) in the narrow window between claim() committing (received) and either the step-5 transaction committing (stored) or the catch-block's markFailed running leaves the delivery stuck at received. claim() reclaims a failed row but not a stuck received one, so on re-delivery it returns in-progress and the message is stranded — a never-drop-invariant edge. This mirrors the outbound side's own history: core send (HT-15) shipped before the lease + sweep recovery (HT-16/HT-22). The fix is a distinct mechanism — a received-staleness reclaim or a claimed_until lease column + an inbound sweep — best designed on its own ticket. A follow-up ticket will track it; it must land before the live dogfood.

Attachments are parsed but not persisted (deferred per scope; separate task). Observability (forgedTokenCount et al.) surfaced via a structured log line.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a provider-agnostic inbound email ingestion pipeline with at-least-once handling, including blob-backed message loading.
    • Added reply-token verification to suppress verified self-replies while keeping protection against spoofing.
    • Introduced inbound delivery status tracking (stored, suppressed, failed, dead-letter) with retry budgeting and threading behavior.
  • Bug Fixes
    • Prevented duplicate conversations/threads across repeated and concurrent deliveries.
    • Improved recovery from partial failures and added safe fallback when threads can’t be appended.
  • Tests
    • Added comprehensive ingestion and inbound-delivery store coverage for concurrency, retries, and edge cases.

ingestInboundMessage (src/mail/ingest.ts): spec §3's five steps — atomic claim -> parse (once) -> loop-guard -> decideThreading -> store + ledger 'received->stored' in ONE transaction. Idempotent, at-least-once, dead-letters after MAX_INGEST_ATTEMPTS.

inbound-deliveries.ts: the ledger store (atomic claim + race-safe failed-reclaim, mark* transitions, tx-scoped markStoredInTx). conversations.ts: extracted createConversationInTx/appendThreadInTx (byte-identical transaction bodies) so the store write + ledger mark compose into one transaction; public methods are thin wrappers.

Loop-suppression suppresses ONLY a verifiable own-Message-ID reflection (never sender identity, never In-Reply-To/References) so auto-responder replies still ingest per auto-submitted.json. 436 tests pass in isolation; typecheck + biome clean.

KNOWN GAP (follow-up): a hard process crash between claim() and the step-5 commit strands a delivery at 'received' (claim reclaims 'failed', not 'received') — needs a staleness/lease sweep, mirroring outbound HT-16/HT-22. Attachments deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b673340f-80cf-4032-80a1-246ff11f4333

📥 Commits

Reviewing files that changed from the base of the PR and between 62d8bc9 and 863bcba.

📒 Files selected for processing (1)
  • src/store/index.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/store/index.ts

📝 Walkthrough

Walkthrough

Adds provider-agnostic inbound mail ingestion with delivery claims, transactional storage, threading, loop suppression, retries, dead-letter handling, blob resolution, and comprehensive persistence tests.

Changes

Inbound ingestion

Layer / File(s) Summary
Delivery ledger state machine
src/store/inbound-deliveries.ts, src/store/inbound-deliveries.test.ts
Defines delivery statuses, atomic claim/reclaim behavior, terminal updates, transactional storage marking, and persistence coverage.
Transaction-scoped conversation writes
src/store/conversations.ts, src/store/index.ts
Adds transaction-scoped conversation creation and thread append helpers, preserves existing store behavior, and exports the new APIs.
Inbound processing orchestration
src/mail/ingest.ts
Claims deliveries, resolves and parses content, suppresses own reflections, applies threading decisions, stores messages with ledger updates in one transaction, and records retry outcomes.
Ingestion behavior coverage
src/mail/ingest.test.ts
Covers fresh and threaded ingestion, idempotency, concurrency, retries, dead-lettering, suppression, fallback conversations, and blob-backed content.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Provider
  participant ingestInboundMessage
  participant InboundDeliveryStore
  participant BlobStore
  participant ConversationStore
  Provider->>ingestInboundMessage: submit RawInboundMessage
  ingestInboundMessage->>InboundDeliveryStore: claim delivery
  ingestInboundMessage->>BlobStore: get blob when blobRef is present
  ingestInboundMessage->>ConversationStore: create or append conversation thread
  ingestInboundMessage->>InboundDeliveryStore: mark stored, suppressed, failed, or dead-letter
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: a provider-agnostic inbound ingest pipeline for mail.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-37-inbound-ingest-pipeline

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

Resolves the src/store/index.ts barrel-export conflict with HT-38 (both added store exports) — kept both sets. 486 tests pass, typecheck + biome clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

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

🧹 Nitpick comments (1)
src/store/inbound-deliveries.test.ts (1)

146-159: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding a concurrent-reclaim test for the failed row race.

The module doc emphasizes that concurrent retries of the same failed row can never both win, but only the fresh-key race is tested with Promise.all (lines 84-94); the failed-row reclaim (this test) is only exercised sequentially. Mirroring the existing concurrent test pattern here would give this documented atomicity guarantee actual regression coverage.

♻️ Suggested additional test
it('two concurrent claims on the SAME failed row resolve to exactly one reclaim', async () => {
  const { store, mailboxId } = await freshStore()
  const { delivery } = await store.claim(mailboxId, 'provider-msg-1')
  await store.markFailed(delivery.id, 'parse: boom')

  const [a, b] = await Promise.all([
    store.claim(mailboxId, 'provider-msg-1'),
    store.claim(mailboxId, 'provider-msg-1'),
  ])

  expect([a.claimed, b.claimed].sort()).toEqual([false, true])
  expect(a.delivery.id).toBe(b.delivery.id)
})
🤖 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/store/inbound-deliveries.test.ts` around lines 146 - 159, Add a
concurrent reclaim test alongside the existing failed-row test, using
Promise.all to call store.claim twice for the same failed delivery. Assert both
results reference the same delivery and that exactly one result has claimed set
to true, covering the atomicity guarantee for failed-row races.
🤖 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/ingest.ts`:
- Around line 279-294: Wrap the step-3
`deps.inboundDeliveryStore.markSuppressed` call in the same failure-handling
pattern used for the step-5 write, ensuring errors are caught and the delivery
claim cannot remain stuck at `received`. Keep `decideThreading` outside this
handling because it is pure.

---

Nitpick comments:
In `@src/store/inbound-deliveries.test.ts`:
- Around line 146-159: Add a concurrent reclaim test alongside the existing
failed-row test, using Promise.all to call store.claim twice for the same failed
delivery. Assert both results reference the same delivery and that exactly one
result has claimed set to true, covering the atomicity guarantee for failed-row
races.
🪄 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: aeba544d-cc2e-4151-b267-d426c4ebf21a

📥 Commits

Reviewing files that changed from the base of the PR and between 262ffc3 and 62d8bc9.

📒 Files selected for processing (6)
  • src/mail/ingest.test.ts
  • src/mail/ingest.ts
  • src/store/conversations.ts
  • src/store/inbound-deliveries.test.ts
  • src/store/inbound-deliveries.ts
  • src/store/index.ts

Comment thread src/mail/ingest.ts
Comment on lines +279 to +294
// --- Step 3: loop guard (spec §5). ----------------------------------------
if (isOwnMessageReflection(parsed, deps.keyring)) {
const reason = 'own-message-loop'
const updated = await deps.inboundDeliveryStore.markSuppressed(delivery.id, reason)
logIngestEvent({
...base,
outcome: 'suppressed',
reason,
parseSize,
attachmentCount: parsed.attachments.length,
})
return { ...base, kind: 'suppressed', reason: updated.lastError ?? reason }
}

// --- Step 4: decide (never re-implemented here). --------------------------
const decision = decideThreading(parsed, deps.keyring)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

set -euo pipefail

# Locate relevant files
git ls-files | rg '^src/mail/'

# Show structure of ingest.ts
ast-grep outline src/mail/ingest.ts --view expanded

# Read relevant slices with line numbers
sed -n '90,360p' src/mail/ingest.ts

# Locate threading decision implementation and store interfaces/usages
rg -n "decideThreading|markSuppressed|interface InboundDeliveryStore|type InboundDeliveryStore|class .*InboundDeliveryStore|claim\(" src/mail -S

Repository: Helpthread/helpthread

Length of output: 15939


🏁 Script executed:

set -euo pipefail

printf '\n### ingest.ts (selected lines)\n'
sed -n '90,360p' src/mail/ingest.ts

printf '\n### threading/store references\n'
rg -n "decideThreading|markSuppressed|interface InboundDeliveryStore|type InboundDeliveryStore|class .*InboundDeliveryStore|claim\(" src/mail -S

Repository: Helpthread/helpthread

Length of output: 14640


🏁 Script executed:

set -euo pipefail

printf '\n### thread.ts outline\n'
ast-grep outline src/mail/thread.ts --view expanded

printf '\n### thread.ts selected lines\n'
sed -n '1,240p' src/mail/thread.ts

printf '\n### ingest store interface / implementation references\n'
rg -n "markSuppressed|markFailed|markDeadLetter|interface InboundDeliveryStore|type InboundDeliveryStore|class .*InboundDeliveryStore|function .*markSuppressed" src -S

Repository: Helpthread/helpthread

Length of output: 10079


Guard markSuppressed in step 3 markSuppressed can fail like the step-5 write, but this path is still uncaught and can leave the claim stuck at received; decideThreading is pure and does not need to be wrapped.

🤖 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/ingest.ts` around lines 279 - 294, Wrap the step-3
`deps.inboundDeliveryStore.markSuppressed` call in the same failure-handling
pattern used for the step-5 write, ensuring errors are caught and the delivery
claim cannot remain stuck at `received`. Keep `decideThreading` outside this
handling because it is pure.

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