Skip to content

feat(mail): persist inbound attachment bytes to BlobStore (HT-46) - #47

Merged
zaridan merged 3 commits into
mainfrom
feat/ht-46-attachment-blob-persistence
Jul 16, 2026
Merged

feat(mail): persist inbound attachment bytes to BlobStore (HT-46)#47
zaridan merged 3 commits into
mainfrom
feat/ht-46-attachment-blob-persistence

Conversation

@zaridan

@zaridan zaridan commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Implemented HT-46: inbound attachment bytes are now persisted to BlobStore instead of being silently dropped. Migration 015 adds a thread_attachments table (thread_id FK cascade, filename nullable, content_type, size, blob_key, created_at) plus a src/store/attachments.ts module (ThreadAttachmentStore.listByConversationId, transaction-scoped insertThreadAttachmentsInTx). src/mail/ingest.ts now writes each attachment's bytes to the BlobStore between step 4 (decide) and step 5 (store) under a mailbox-namespaced key <mailboxId>/<attachmentId>/<filename> (attachmentId a fresh UUID, filename sanitized to strip //\), then persists only the blob-key reference inside the same step-5 transaction that writes the thread — so a step-5 abort orphans the already-written blob (tolerable per the ticket's design) and a retry writes a fresh blob rather than repairing the orphan. The loop guard runs before the blob write so a suppressed own-message reflection never writes attachment bytes it would have nothing to reference. On the read side, GET /api/v1/conversations/{id} gained an optional attachment surface: ThreadView.attachments (metadata + a BlobStore.getSignedUrl signed URL, 1-hour expiry), wired as an absent-by-default dependency on InboxApiDeps (mirroring the existing openTracking pattern) so no untouched deployment or test is affected; the composition root wires it for the RIQ dogfood. Updated specs/mail/inbound-ingestion.md (§3's closing paragraph + §8 acceptance bullets) and specs/api/agent-inbox-v1.md (ThreadView/AttachmentView shape, §6, §7 changelog) to document the new behavior. GC for orphaned blobs is explicitly flagged as a follow-up, not built. Fixed one pre-existing test (src/db/postgres.test.ts) that hardcoded the full migrated-table list and needed thread_attachments added.

Design decisions

Followed the ticket's design exactly: blob write before the step-5 transaction, reference-only inside it, orphan-tolerant retry. For the read-path (explicitly optional in the ticket), chose the minimal, additive shape: ThreadView.attachments: AttachmentView[], absent-by-default via an optional attachments?: { store, blobStore } dependency on InboxApiDeps/handleGetConversation — this is the exact pattern already used for openTracking, gmailPush, gmailConnect in this codebase, so it required no changes to any existing test or caller (handleReply/handlePostNote's freshly-created threads always report attachments: [] by default parameter, since a brand-new outbound/note thread cannot yet have any). Chose a JOIN-through-threads read query (listByConversationId) over an IN-list/array-param query, since SqlValue in this codebase's Db seam has no array type — the join keeps every attachment for a conversation fetchable in one round trip without widening that seam. Attachment filename sanitization strips / and \ only (not full slugification) to keep the key's three-segment shape guaranteed while doing the minimum needed. Signed-URL expiry (3600s) is a reasonable, documented default, not derived from any spec value. Did not touch the web/ UI — this ticket is engine/API-only, and the API addition is backward-compatible (new optional field).

Review

0 adversarial findings raised, 0 actionable, all addressed.

Verification

Independent gate: typecheck 0, lint 0, tests 0 (exit codes), clean tree.

Plus implementer evidence: Ran from /Users/tjbaker/Projects/helpthread-worktrees/feat-ht-46-attachment-blob-persistence throughout (after npm install, which reported "added 137 packages"):

  • npm run typechecktsc --noEmit -p tsconfig.json, exit 0, no output (ran twice, both clean).
  • npm run lintbiome check . → "Checked 182 files in 103ms. No fixes applied." exit 0 (ran lint:fix once first to auto-format 4 newly-written test/source files, then confirmed lint clean after).
  • Targeted runs while iterating: npx vitest run src/store/attachments.test.ts src/db/migrate.test.ts → 27 passed; npx vitest run src/mail/ingest.test.ts → 18 passed; npx vitest run src/api/index.test.ts → 98 passed.
  • Full suite, first attempt (npm test 2>&1 | tail -200, i.e. vitest run): reported 15 failed / 746 passed / 2 skipped. Investigated: the machine was under extreme concurrent load from sibling agent worktrees also running full test suites simultaneously (uptime showed load averages of ~160-170 on this box); 14 of the 15 failures were bare Test timed out in 20000ms / Hook timed out in 10000ms across files I never touched (gmail-oauth, mailbox-tokens, postgres-queue, gmail-watch-state, inbound-deliveries, conversations.ts's own store tests, root.test.ts) — confirmed as pure resource contention, not a regression, by re-running src/db/postgres.test.ts alone with --testTimeout=60000 --hookTimeout=60000: it passed 20/20 once given headroom. The 15th failure was real and mine to fix: src/db/postgres.test.ts's hardcoded full migrated-table-name list didn't include the new thread_attachments table — fixed by adding it in alphabetical position (matching that test's ORDER BY table_name).
  • Re-ran the FULL suite with generous timeouts to rule out further contention-induced flakes: npx vitest run --testTimeout=60000 --hookTimeout=60000 > /tmp/ht46-full-test.log 2>&1; echo "EXIT_CODE=$?" >> /tmp/ht46-full-test.log (captured vitest's own exit code, not a pipe's, per the HT-42 gate-verification lesson). Result: Test Files 42 passed (42), Tests 763 passed (763), EXIT_CODE=0.
  • Re-ran npm run typecheck and npm run lint one final time after the postgres.test.ts fix: both exit 0, no findings.
  • Committed with git commit; git log --oneline -3 shows the new commit 588d260 on top of f69ba48 (the branch's base); git status --short is clean afterward; author email verified as the repo's configured noreply address via git show HEAD.

Link https://resonantiq.atlassian.net/browse/HT-46

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Conversation thread views now include inbound email attachment metadata (attachments) when attachment read access is configured; otherwise they return an empty list.
    • Each attachment includes a time-limited signed download link (expires in 1 hour).
    • Supports multiple attachments per message.
  • Bug Fixes
    • Attachment filenames are sanitized for safe storage.
    • Improved retry behavior for partial failures to ensure correct attachment references.
  • Documentation
    • Clarified attachment handling in inbound ingestion and reiterated there’s still no attachment upload on reply.
  • Tests
    • Added coverage for attachment listing, signed URL behavior, and ingestion retry/orphan scenarios.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Inbound thread attachments are persisted in BlobStore with transactional database references, then optionally exposed in Agent Inbox conversation responses as per-thread metadata with signed URLs. Ingestion, retry behavior, migrations, storage, API wiring, specifications, and tests are updated.

Changes

Inbound thread attachments

Layer / File(s) Summary
Attachment schema and store
src/db/migrate.ts, src/store/attachments.ts, src/store/index.ts, src/store/attachments.test.ts, src/db/*.test.ts
Adds the thread_attachments table, transactional inserts, conversation-scoped listing, public store exports, and persistence/migration tests.
Attachment blob ingestion
src/mail/ingest.ts, src/mail/ingest.test.ts, specs/mail/inbound-ingestion.md
Writes attachment bytes before step five, stores references transactionally, sanitizes blob-key filename segments, and covers multiple attachments, failures, retries, and orphaned blobs.
Conversation attachment read path
src/api/conversations.ts, src/api/index.ts, src/composition/root.ts, src/api/index.test.ts, specs/api/agent-inbox-v1.md
Adds optional attachment dependencies, per-thread attachment metadata, one-hour signed URLs, default empty arrays, composition wiring, and API coverage.

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

Sequence Diagram(s)

sequenceDiagram
  participant MailIngest
  participant BlobStore
  participant ConversationStore
  participant ThreadAttachmentStore
  participant AgentInboxAPI

  MailIngest->>BlobStore: write attachment bytes
  MailIngest->>ConversationStore: persist thread and delivery transaction
  MailIngest->>ThreadAttachmentStore: insert blob-key references in step-five transaction
  AgentInboxAPI->>ThreadAttachmentStore: list attachments by conversation
  AgentInboxAPI->>BlobStore: mint one-hour signed URLs
  AgentInboxAPI-->>AgentInboxAPI: return attachments per thread
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: persisting inbound attachment bytes to BlobStore for HT-46.
Docstring Coverage ✅ Passed Docstring coverage is 81.25% which is sufficient. The required threshold is 80.00%.
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-46-attachment-blob-persistence

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: 4

🤖 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/api/conversations.ts`:
- Around line 271-280: Update the attachment processing in the conversation
handler around toAttachmentViewJson to resolve all attachment views concurrently
with Promise.all, then iterate over the ordered results to populate byThreadId.
Preserve the existing grouping behavior and attachment order while avoiding
sequential BlobStore calls.

In `@src/api/index.test.ts`:
- Line 527: Remove the duplicated test declarations in src/api/index.test.ts: at
lines 527-527, retain only one const body = await res.json() declaration; at
lines 600-600, retain only one threads property in the response type. No other
test behavior needs to change.

In `@src/mail/ingest.ts`:
- Around line 399-417: Update sanitizeAttachmentFilename to map complete "." and
".." results to the existing "attachment" placeholder, while preserving the
current sanitization for all other filenames and the existing handling of null,
undefined, and empty values. Add regression coverage asserting both dot-segment
inputs return "attachment".

In `@src/store/attachments.test.ts`:
- Around line 113-122: Update the test around listByConversationId to preserve
and verify the store’s ordering rather than sorting the returned rows. Make the
two inserts use distinct timestamps so the expected ordering is unambiguous,
then assert the returned filenames directly in the sequence produced by
listByConversationId.
🪄 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: fc2fe694-9b5b-4dff-ab40-58ecb14840a1

📥 Commits

Reviewing files that changed from the base of the PR and between f69ba48 and 6d9003c.

📒 Files selected for processing (14)
  • specs/api/agent-inbox-v1.md
  • specs/mail/inbound-ingestion.md
  • src/api/conversations.ts
  • src/api/index.test.ts
  • src/api/index.ts
  • src/composition/root.ts
  • src/db/migrate.test.ts
  • src/db/migrate.ts
  • src/db/postgres.test.ts
  • src/mail/ingest.test.ts
  • src/mail/ingest.ts
  • src/store/attachments.test.ts
  • src/store/attachments.ts
  • src/store/index.ts

Comment thread src/api/conversations.ts
Comment thread src/api/index.test.ts
const { conversationId } = await store.createConversation(newConversation())

const res = await api(get(`/api/v1/conversations/${conversationId}`))
const body = (await res.json()) as { threads: Array<{ attachments: unknown[] }> }

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 | 🔴 Critical | ⚡ Quick win

Remove accidentally duplicated test source. These duplicate declarations prevent clean typechecking/compilation.

  • src/api/index.test.ts#L527-L527: retain only one const body = await res.json() declaration.
  • src/api/index.test.ts#L600-L600: retain only one threads property in the response type.
📍 Affects 1 file
  • src/api/index.test.ts#L527-L527 (this comment)
  • src/api/index.test.ts#L600-L600
🤖 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/api/index.test.ts` at line 527, Remove the duplicated test declarations
in src/api/index.test.ts: at lines 527-527, retain only one const body = await
res.json() declaration; at lines 600-600, retain only one threads property in
the response type. No other test behavior needs to change.

Comment thread src/mail/ingest.ts
Comment on lines +399 to +417
/**
* The filename segment of an attachment's blob key — NOT the `filename`
* column value (that stays the original, verbatim `ParsedAttachment.filename`,
* `null` included). `BlobStore` implementations (e.g. Supabase Storage,
* `src/providers/adapters/supabase-storage/`) reject object keys containing
* anything outside a restricted ASCII allowlist (letters, digits, `_`, `.`,
* `-`) — no unicode, no `/` (a path separator, which would otherwise let an
* attacker- or client-supplied filename nest the object under an unintended
* "folder" inside this attachment's own namespace slot), no `%`/`#`/quotes/
* control characters. Every other character is replaced with `_` so the key
* stays exactly three segments deep and adapter-valid, whatever the filename
* contains. A missing OR empty filename (`null`, `undefined`, or `''` — a
* blank `''` is not caught by `??`) falls back to a fixed placeholder — the
* key still needs SOME non-empty final segment.
*/
export function sanitizeAttachmentFilename(filename: string | null): string {
const sanitized = (filename ?? '').replaceAll(/[^A-Za-z0-9._-]/g, '_')
return sanitized === '' ? 'attachment' : sanitized
}

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 | 🟡 Minor | ⚡ Quick win

Reject complete . and .. filename segments.

The allowlist leaves these names unchanged, but URI processors treat complete dot segments specially, so a storage adapter or signed URL may normalize or reject the resulting key. (rfc-editor.org)

Proposed fix and regression cases
 export function sanitizeAttachmentFilename(filename: string | null): string {
   const sanitized = (filename ?? '').replaceAll(/[^A-Za-z0-9._-]/g, '_')
-  return sanitized === '' ? 'attachment' : sanitized
+  return sanitized === '' || sanitized === '.' || sanitized === '..'
+    ? 'attachment'
+    : sanitized
 }

Also assert that both . and .. produce attachment.

As per coding guidelines, use RFCs and public specifications as the primary source for semantics.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* The filename segment of an attachment's blob key — NOT the `filename`
* column value (that stays the original, verbatim `ParsedAttachment.filename`,
* `null` included). `BlobStore` implementations (e.g. Supabase Storage,
* `src/providers/adapters/supabase-storage/`) reject object keys containing
* anything outside a restricted ASCII allowlist (letters, digits, `_`, `.`,
* `-`) no unicode, no `/` (a path separator, which would otherwise let an
* attacker- or client-supplied filename nest the object under an unintended
* "folder" inside this attachment's own namespace slot), no `%`/`#`/quotes/
* control characters. Every other character is replaced with `_` so the key
* stays exactly three segments deep and adapter-valid, whatever the filename
* contains. A missing OR empty filename (`null`, `undefined`, or `''` a
* blank `''` is not caught by `??`) falls back to a fixed placeholder the
* key still needs SOME non-empty final segment.
*/
export function sanitizeAttachmentFilename(filename: string | null): string {
const sanitized = (filename ?? '').replaceAll(/[^A-Za-z0-9._-]/g, '_')
return sanitized === '' ? 'attachment' : sanitized
}
/**
* The filename segment of an attachment's blob key — NOT the `filename`
* column value (that stays the original, verbatim `ParsedAttachment.filename`,
* `null` included). `BlobStore` implementations (e.g. Supabase Storage,
* `src/providers/adapters/supabase-storage/`) reject object keys containing
* anything outside a restricted ASCII allowlist (letters, digits, `_`, `.`,
* `-`) no unicode, no `/` (a path separator, which would otherwise let an
* attacker- or client-supplied filename nest the object under an unintended
* "folder" inside this attachment's own namespace slot), no `%`/`#`/quotes/
* control characters. Every other character is replaced with `_` so the key
* stays exactly three segments deep and adapter-valid, whatever the filename
* contains. A missing OR empty filename (`null`, `undefined`, or `''` a
* blank `''` is not caught by `??`) falls back to a fixed placeholder the
* key still needs SOME non-empty final segment.
*/
export function sanitizeAttachmentFilename(filename: string | null): string {
const sanitized = (filename ?? '').replaceAll(/[^A-Za-z0-9._-]/g, '_')
return sanitized === '' || sanitized === '.' || sanitized === '..'
? 'attachment'
: sanitized
}
🤖 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 399 - 417, Update sanitizeAttachmentFilename
to map complete "." and ".." results to the existing "attachment" placeholder,
while preserving the current sanitization for all other filenames and the
existing handling of null, undefined, and empty values. Add regression coverage
asserting both dot-segment inputs return "attachment".

Comment on lines +113 to +122
// Both inserts above ran inside the SAME transaction, so `created_at`
// (bound to that transaction's `now()`) ties for both rows — the `id`
// tiebreak then decides order, which is not insertion order. Sort by
// filename before asserting so this test doesn't depend on that tie's
// resolution.
const rows = (await attachmentStore.listByConversationId(conversationId)).sort((a, b) =>
(a.filename ?? '').localeCompare(b.filename ?? ''),
)
expect(rows).toHaveLength(2)
expect(rows.map((r) => r.filename)).toEqual(['a.txt', 'b.png'])

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 | 🟡 Minor | ⚡ Quick win

Assert the store’s ordering instead of sorting it away.

This test would still pass if listByConversationId returned attachments in the wrong order. Insert rows with distinct timestamps, then assert the returned sequence directly.

As per coding guidelines, “Convert vague requests into verifiable success criteria, preferably beginning with a failing test.”

🤖 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/attachments.test.ts` around lines 113 - 122, Update the test around
listByConversationId to preserve and verify the store’s ordering rather than
sorting the returned rows. Make the two inserts use distinct timestamps so the
expected ordering is unambiguous, then assert the returned filenames directly in
the sequence produced by listByConversationId.

Source: Coding guidelines

@zaridan

zaridan commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Adversarial review + re-gate (orchestrator)

Independent gate (re-run from a clean checkout of this branch, after pushing 1 pending local commit): typecheck 0, lint 0, tests 0 (exit codes), clean tree.

  • npm run typecheck → exit 0
  • npm run lint (biome) → exit 0
  • npm test (vitest) → exit 0 — 769/769 tests passing across 42 files

(Note: an earlier gate attempt showed ~40 unrelated tests failing with Test timed out in 20000ms across files this PR never touches — traced to CPU contention from other concurrent test runs on the same machine, not a regression. Re-ran once contention cleared and confirmed a fully clean pass.)

Adversarial review of record: 3 findings, all actionable, fixed and re-gated

  1. Stray raw NUL byte in src/mail/ingest.test.ts (the sanitizeAttachmentFilename "control chars" test). The committed source contained a literal 0x00 byte instead of a visible character — invisible in most editors/diffs, and enough to make the whole file read as binary to text tools that skip binary files by default (confirmed: grep -I-style tools silently skipped this file). Fixed: replaced with an explicit \x00 escape plus a comment explaining what happened, preserving the control-character test case the it(...) name already promised.

  2. Serial signed-URL minting in attachmentViewsByThreadId (src/api/conversations.ts). Each attachment's BlobStore.getSignedUrl call was awaited one at a time in a for loop, so a conversation with N attachments paid N sequential signing round trips on every GET /api/v1/conversations/{id}. Fixed: parallelized with Promise.all (the calls are independent, no shared state).

  3. No length cap on the sanitized attachment filename (sanitizeAttachmentFilename, src/mail/ingest.ts). ParsedAttachment.filename comes verbatim from an attacker-controlled Content-Disposition header with no length limit; an oversized sanitized segment could exceed a real object-storage backend's key-length limit, and since ingest retries reuse the same filename, that failure would repeat identically forever instead of resolving on retry. Fixed: truncated the sanitized segment to a fixed 200-character cap.

Two other things were investigated and cleared as non-issues (noted for the record, not fixed):

  • The new migration id (015) isn't contiguous with the prior one (013) — 014 is reserved by a sibling, not-yet-merged ticket (HT-45's inbound_delivery_lease). The migration runner orders strictly by id, not array position (documented in migrate.ts), so this resolves correctly whichever branch merges first.
  • The composition root wires attachments deps unconditionally, unlike openTracking's deliberate omission. Confirmed intentional per the PR description ("the composition root wires it for the RIQ dogfood") — the absent-by-default posture is a property of the API contract for other deployments, not a gap in this one.

All three fixes are pushed to this branch (24e587c) and the gate above reflects that commit.

🤖 Generated with Claude Code

zaridan and others added 3 commits July 16, 2026 15:09
Writes each attachment's bytes to the BlobStore under a mailbox-namespaced
key (<mailboxId>/<attachmentId>/<filename>) BEFORE the ingest pipeline's
step-5 transaction opens, then persists only the blob-key reference inside
that transaction (new thread_attachments table, migration 015). A step-5
abort after a successful blob write leaves that blob orphaned and
unreferenced — the failure mode spec §4 already blessed — and a retry
writes a fresh blob rather than reusing or repairing the orphan.

Also wires an optional attachment read path into the Agent Inbox API:
GET /api/v1/conversations/{id}'s ThreadView now carries `attachments`
(metadata + a BlobStore signed URL), absent-by-default like open tracking
so no existing deployment or test is affected unless the composition root
opts in (this ticket wires it in for the RIQ dogfood).

Follow-up not built here: a GC sweep for orphaned blobs left behind by
aborted ingest attempts (tolerable per the ticket's design, cross-
referenced against thread_attachments in a future pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st (HT-46)

sanitizeAttachmentFilename only stripped '/' and '\\', so any unicode,
'%', '#', quote, or control character in an inbound attachment's
filename produced a Supabase Storage key the adapter's server-side
validation rejects on every attempt — dead-lettering the whole
delivery (body included) after MAX_INGEST_ATTEMPTS. Switch to an
allowlist (letters, digits, '_', '.', '-') and treat '' the same as
null, since '' ?? 'attachment' let an empty filename attribute through
unchanged and produced a key with an empty final segment.

Also move ATTACHMENT_SIGNED_URL_EXPIRY_SECONDS above
handleGetConversation's doc comment (it had been inserted between the
comment and the function, orphaning the doc), and add direct unit
coverage for sanitizeAttachmentFilename plus an ingest-level test
proving a hostile filename still produces a valid three-segment blob
key end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sistence)

- Fix a stray raw NUL byte embedded in src/mail/ingest.test.ts's
  sanitizeAttachmentFilename control-character test — invisible in most
  editors/diffs, and enough to make the file read as binary to grep tools
  that skip binary files by default. Replaced with an explicit \x00 escape.
- Parallelize signed-URL minting in attachmentViewsByThreadId
  (src/api/conversations.ts) — was awaiting BlobStore.getSignedUrl one
  attachment at a time in a loop; now Promise.all across independent calls.
- Cap the sanitized attachment filename segment's length (src/mail/ingest.ts)
  — an attacker-controlled Content-Disposition filename has no length limit
  of its own, and an oversized blob-key segment would otherwise fail the
  same way on every retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zaridan
zaridan force-pushed the feat/ht-46-attachment-blob-persistence branch from 24e587c to 36235b9 Compare July 16, 2026 22:12

@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.

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

768-783: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a boundary assertion for the 200-character filename cap.

The hostile-input battery does not verify the explicit HT-46 length limit, so removing or miscounting the cap would pass this suite.

Proposed test
     it('every result matches the adapter-safe charset and is non-empty, for a battery of hostile inputs', () => {
       for (const filename of [
         null,
         '',
         '/',
         '\\',
         '///',
         'Résumé.pdf',
         'a/b/../c.txt',
         '文件.txt',
       ]) {
         const sanitized = sanitizeAttachmentFilename(filename)
         expect(sanitized.length).toBeGreaterThan(0)
+        expect(sanitized.length).toBeLessThanOrEqual(200)
         expect(sanitized).toMatch(ADAPTER_SAFE)
       }
+
+      expect(sanitizeAttachmentFilename('a'.repeat(201))).toHaveLength(200)
     })

As per coding guidelines, “Convert vague requests into verifiable success criteria, preferably beginning with a failing test.”

🤖 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.test.ts` around lines 768 - 783, Add a focused boundary
assertion in the hostile-input test around sanitizeAttachmentFilename: verify a
filename longer than 200 characters is sanitized to exactly 200 characters, and
verify the 200-character boundary remains accepted. Preserve the existing
non-empty and ADAPTER_SAFE assertions.

Source: Coding guidelines

🤖 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.

Nitpick comments:
In `@src/mail/ingest.test.ts`:
- Around line 768-783: Add a focused boundary assertion in the hostile-input
test around sanitizeAttachmentFilename: verify a filename longer than 200
characters is sanitized to exactly 200 characters, and verify the 200-character
boundary remains accepted. Preserve the existing non-empty and ADAPTER_SAFE
assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: eba8f58c-43c4-498a-bac1-cb2b136c1852

📥 Commits

Reviewing files that changed from the base of the PR and between 24e587c and 36235b9.

📒 Files selected for processing (14)
  • specs/api/agent-inbox-v1.md
  • specs/mail/inbound-ingestion.md
  • src/api/conversations.ts
  • src/api/index.test.ts
  • src/api/index.ts
  • src/composition/root.ts
  • src/db/migrate.test.ts
  • src/db/migrate.ts
  • src/db/postgres.test.ts
  • src/mail/ingest.test.ts
  • src/mail/ingest.ts
  • src/store/attachments.test.ts
  • src/store/attachments.ts
  • src/store/index.ts
🚧 Files skipped from review as they are similar to previous changes (11)
  • src/db/postgres.test.ts
  • src/store/index.ts
  • src/api/index.ts
  • src/db/migrate.ts
  • src/db/migrate.test.ts
  • src/composition/root.ts
  • src/store/attachments.test.ts
  • src/api/conversations.ts
  • src/mail/ingest.ts
  • src/store/attachments.ts
  • src/api/index.test.ts

@zaridan
zaridan merged commit 9c00c9f into main Jul 16, 2026
5 checks passed
@zaridan
zaridan deleted the feat/ht-46-attachment-blob-persistence branch August 2, 2026 19:19
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