Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion specs/api/agent-inbox-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,21 @@ interface ThreadView {
// v1.1: outbound only, and only when open tracking is
// enabled (§4g) — first time the customer viewed the reply;
// null until then, always null for inbound and notes
attachments: AttachmentView[]
// HT-46: inbound attachments this thread carries. [] when
// there are none, OR when the deployment hasn't wired the
// attachment read-path deps (config-gated, absent by default
// — same posture as open tracking, §4g)
createdAt: string // ISO-8601
}

interface AttachmentView {
id: string // uuid
filename: string | null // null when the attachment arrived with no filename
contentType: string
size: number // bytes
url: string // a time-limited signed URL (never a stable/public path)
}
```

**Status semantics (v1.1, HT-26).** `active` is the working state — inbound mail creates
Expand Down Expand Up @@ -366,12 +379,19 @@ above.
- No customer-side / self-service surface (a separate future API, designed native when
there are customers to serve).
- No mailbox management, no search, no realtime, no webhooks-out, no tag-filtered listing.
- No attachment upload on reply yet (the blob seam exists; wiring is later).
- No attachment upload on reply yet (HT-46 wired the READ side — inbound attachments
surfaced via `ThreadView.attachments` — but an Agent still cannot attach a file to an
outbound reply).
- Framework-agnostic by construction: handlers are `Request → Response`; a Vercel/Next
adapter is a thin deploy-time wrapper, not part of this spec.

## 7. Changelog

- **v1.1 (2026-07-16, HT-46).** `ThreadView.attachments`: inbound attachment metadata +
a signed `BlobStore` URL, `[]` by default and config-gated (absent `attachments` deps
at the composition root, §4's `InboxApiDeps`, same posture as open tracking) — a
deployment that hasn't wired a `ThreadAttachmentStore` + `BlobStore` never surfaces
attachments. No attachment upload on reply (§6, unchanged).
- **v1.1 (2026-07-11, HT-25).** Adopted the contract the Agent Inbox UI was designed
against (the Claude Design prototype's `mock-api.js`, whose additions were each marked
`CONTRACT ADDITION`), after review of the drift between the designed surface and v1.0.
Expand Down
28 changes: 23 additions & 5 deletions specs/mail/inbound-ingestion.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,23 @@ Ordered, applied to each received message. Idempotent by step 1, so a whole re-r
- The store write **and** the ledger row's `received → stored` transition (recording the
resulting `threadId`) commit in **one transaction** — see §4.

**Attachments belong to the pipeline, not the transport** (§2). After the parse (step 2),
attachment bytes are written to the `BlobStore` under a **mailbox-namespaced** key
(`src/providers/blob.ts` makes namespacing the caller's responsibility) as part of the
step-5 store, and the stored thread carries blob references, never inline bytes.
**Attachments belong to the pipeline, not the transport** (§2). After the parse (step 2)
and the loop guard (step 3) — a suppressed reflection never writes attachment blobs it
would then have nothing to reference — each attachment's bytes are written to the
`BlobStore` under a **mailbox-namespaced** key, `<mailboxId>/<attachmentId>/<filename>`
(`src/providers/blob.ts` makes namespacing the caller's responsibility; `attachmentId` is
a freshly minted UUID, formable before any row id exists). This write happens **before**
step 5's transaction opens — `BlobStore.put` is a non-transactional external side effect,
so it cannot be undone if that transaction later aborts — and only the resulting blob-key
**reference** (`thread_attachments`, migration 015) is persisted inside the transaction,
stamped with the thread id that same transaction mints. HT-46 implements this: a
step-5 abort after a successful blob write leaves that blob orphaned (unreferenced by any
`thread_attachments` row, since the insert never committed) — exactly the partial-failure
mode this section's next paragraph already blesses, and a retry re-parses, re-decides, and
writes fresh blobs under fresh attachment ids rather than reusing or cleaning up the
orphan. Orphaned blobs are tolerable and GC-able (a future sweep cross-referencing
`thread_attachments` against the bucket — not built here) but never a correctness
problem: an orphan is simply never referenced, so it is never served.

## 4. Idempotency, the delivery ledger, and retries

Expand Down Expand Up @@ -268,7 +281,12 @@ engine's existing store/keyring fakes — no cloud required:
- Two concurrent deliveries of the same key → exactly one conversation (the §3-step-1
atomic claim; the second returns the first's outcome).
- A simulated partial failure (transaction aborts after a blob write) → ledger `failed`,
retried to `stored`, no orphaned/duplicate conversation.
retried to `stored`, no orphaned/duplicate conversation. HT-46: the ORIGINAL blob write
is left orphaned (never referenced), and the successful retry's `thread_attachments` rows
point at a FRESH blob write, not the orphan.
- A message with multiple attachments → one `thread_attachments` row per attachment, each
with its own blob key, all inserted in the same step-5 transaction as the thread they
belong to (HT-46).
- A verifiable own-message loop → `suppressed`, nothing created; a message that merely
*claims* our `From` without a verifiable correlation → **ingested**, not dropped.
- `append→deleted` → falls back to a fresh conversation, mail never lost.
Expand Down
109 changes: 105 additions & 4 deletions src/api/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@

import type { Keyring } from '../mail/reply-token.js'
import { sendReply } from '../mail/send.js'
import type { EmailSender } from '../providers/index.js'
import type { BlobStore, EmailSender } from '../providers/index.js'
import type { StoredThreadAttachment, ThreadAttachmentStore } from '../store/attachments.js'
import {
type ConversationFolder,
type ConversationStatus,
Expand Down Expand Up @@ -50,6 +51,19 @@ const MAX_REPLY_TEXT_LENGTH = 5000
*/
const MAX_IDEMPOTENCY_KEY_LENGTH = 255

/**
* The wire shape of one attachment on a `ThreadView` (specs/api/agent-inbox-v1.md
* §2, HT-46): attachment METADATA plus a time-limited signed URL — never a
* stable/public path (`BlobStore.getSignedUrl`'s contract, `src/providers/blob.ts`).
*/
interface AttachmentViewJson {
id: string
filename: string | null
contentType: string
size: number
url: string
}

/** The wire shape of one `ThreadView` (specs/api/agent-inbox-v1.md §2) — `StoredThread` with `Date` fields as ISO strings and `fromAddress` renamed to `from`. */
interface ThreadViewJson {
id: string
Expand All @@ -60,6 +74,8 @@ interface ThreadViewJson {
deliveryStatus: 'pending' | 'sent' | 'failed' | null
/** Open tracking (spec §4g, v1.1): first customer view of this outbound reply; null until then, always null for inbound/notes or with the feature off. */
customerViewedAt: string | null
/** HT-46: `[]` unless this thread has stored attachment references AND the deployment wired `attachments` deps (see {@link handleGetConversation}) — absent-by-default, like `openTracking`. */
attachments: AttachmentViewJson[]
createdAt: string
}

Expand Down Expand Up @@ -161,6 +177,16 @@ export async function handleListConversations(
return json(200, body)
}

/**
* How long a minted attachment signed URL stays valid (`BlobStore.getSignedUrl`'s
* `expiresInSeconds`, HT-46). One hour: long enough to cover an Agent opening
* the conversation and viewing/downloading an attachment in one sitting,
* short enough that a URL copied out of a stale API response doesn't stay a
* standing credential. Not tuned against any measured usage — a reasonable
* default, re-minted fresh on every `GET` since nothing here caches it.
*/
const ATTACHMENT_SIGNED_URL_EXPIRY_SECONDS = 3600

/**
* Handle `GET /api/v1/conversations/{id}` — fetch the conversation and
* shape it as a `ConversationDetail` (spec §3b). `id` is whatever the
Expand All @@ -180,7 +206,16 @@ export async function handleListConversations(
*/
export async function handleGetConversation(
id: string,
deps: { store: ConversationStore },
deps: {
store: ConversationStore
/**
* Attachment read-path deps (HT-46) — ABSENT BY DEFAULT, the same posture
* `InboxApiDeps.openTracking` uses: a deployment that hasn't wired a
* `ThreadAttachmentStore` + `BlobStore` here simply never surfaces
* attachments, and every `ThreadView.attachments` is `[]`.
*/
attachments?: { store: ThreadAttachmentStore; blobStore: BlobStore }
},
): Promise<Response> {
if (!isUuid(id)) {
return apiError(404, 'not_found', 'No conversation with that id.')
Expand All @@ -196,6 +231,11 @@ export async function handleGetConversation(
return apiError(404, 'not_found', 'No conversation with that id.')
}

const attachmentsByThreadId =
deps.attachments !== undefined
? await attachmentViewsByThreadId(conversation.id, deps.attachments)
: new Map<string, AttachmentViewJson[]>()

const body: ConversationDetailJson = {
id: conversation.id,
number: conversation.number,
Expand All @@ -208,12 +248,62 @@ export async function handleGetConversation(
assignee: conversation.assignee,
createdAt: conversation.createdAt.toISOString(),
updatedAt: conversation.updatedAt.toISOString(),
threads: conversation.threads.map(toThreadViewJson),
threads: conversation.threads.map((thread) =>
toThreadViewJson(thread, attachmentsByThreadId.get(thread.id)),
),
}

return json(200, body)
}

/**
* Fetch every attachment reference for `conversationId` in one round trip
* (`ThreadAttachmentStore.listByConversationId`) and mint each one's signed
* URL, grouped by the thread id it belongs to. Signing happens here, not in
* the store, so `ThreadAttachmentStore` stays a plain persistence seam with
* no `BlobStore` dependency of its own (mirroring how `ConversationStore`
* never touches a provider either).
*/
async function attachmentViewsByThreadId(
conversationId: string,
attachments: { store: ThreadAttachmentStore; blobStore: BlobStore },
): Promise<Map<string, AttachmentViewJson[]>> {
const rows = await attachments.store.listByConversationId(conversationId)
// Mint every row's signed URL concurrently (independent BlobStore calls,
// no shared state) rather than one at a time — a conversation with many
// attachments would otherwise pay one signing round trip per attachment,
// serially, on every GET.
const entries = await Promise.all(
rows.map(
async (row) =>
[row.threadId, await toAttachmentViewJson(row, attachments.blobStore)] as const,
),
)
const byThreadId = new Map<string, AttachmentViewJson[]>()
for (const [threadId, view] of entries) {
const existing = byThreadId.get(threadId)
if (existing === undefined) {
byThreadId.set(threadId, [view])
} else {
existing.push(view)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return byThreadId
}

async function toAttachmentViewJson(
row: StoredThreadAttachment,
blobStore: BlobStore,
): Promise<AttachmentViewJson> {
return {
id: row.id,
filename: row.filename,
contentType: row.contentType,
size: row.size,
url: await blobStore.getSignedUrl(row.blobKey, ATTACHMENT_SIGNED_URL_EXPIRY_SECONDS),
}
}

/**
* Derive a detail response's `preview` from the threads it already carries —
* the SAME rule the store applies for list summaries (`derivePreview`, spec
Expand Down Expand Up @@ -774,7 +864,17 @@ function toConversationSummaryJson(row: {
}
}

function toThreadViewJson(thread: StoredThread): ThreadViewJson {
/**
* Map one `StoredThread` to its wire shape. `attachments` defaults to `[]` —
* every caller EXCEPT {@link handleGetConversation} passes none, because a
* thread this API just created (a reply or a note) cannot yet have any
* (HT-46: attachments are inbound-only, and only `handleGetConversation`'s
* deps carry the `ThreadAttachmentStore`/`BlobStore` needed to look them up).
*/
function toThreadViewJson(
thread: StoredThread,
attachments: AttachmentViewJson[] = [],
): ThreadViewJson {
return {
id: thread.id,
direction: thread.direction,
Expand All @@ -784,6 +884,7 @@ function toThreadViewJson(thread: StoredThread): ThreadViewJson {
deliveryStatus: thread.deliveryStatus,
customerViewedAt:
thread.customerViewedAt === null ? null : thread.customerViewedAt.toISOString(),
attachments,
createdAt: thread.createdAt.toISOString(),
}
}
Loading
Loading