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
69 changes: 56 additions & 13 deletions specs/api/agent-inbox-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,11 @@ appears, not preemptively.
interface ApiError { error: { code: string; message: string } }
```
`code` is a machine-readable slug (`unauthorized`, `not_found`, `validation_failed`,
`server_error`); `message` is user-safe and MUST NEVER contain an internal detail — no
stack, no SQL, no upstream body, no id it wasn't given. HTTP status pairs with `code`:
400 validation, 401 auth, 404 not-found, 405 method-not-allowed, 500 server error.
`method_not_allowed`, `send_failed`, `server_error`); `message` is user-safe and MUST
NEVER contain an internal detail — no stack, no SQL, no upstream body, no id it wasn't
given. HTTP status pairs with `code`: 400 `validation_failed`, 401 `unauthorized`, 404
`not_found`, 405 `method_not_allowed`, 500 `server_error`, 502 `send_failed` (§4a, the
provider rejected an outbound reply).
- **Unknown routes / methods:** an unmatched path is `404 not_found`; a known path with an
unsupported method is `405` (with an `Allow` header). Both still require auth first — an
unauthenticated request gets `401` before routing details leak.
Expand Down Expand Up @@ -110,16 +112,57 @@ Returns a `ConversationDetail` — the conversation plus its `threads`, oldest-f
not_found` if `{id}` is not a conversation (or is a `deleted` one — a deleted conversation
is indistinguishable from a nonexistent one to this API, on purpose).

## 4. Write paths (HT-18 — specified here so reads and writes share one contract)

- **`POST /api/v1/conversations/{id}/replies`** — the Agent posts a reply. Body:
`{ text: string; html?: string }` (text 1–5000 chars, server-enforced). Calls
`sendReply` (`src/mail/send.ts`): mints the reply token, persists the outbound thread,
sends. Returns `201` with the created `ThreadView`. A reply to a `closed` conversation
reopens it (the store's existing policy); to a `deleted`/missing one is `404`.
- **`PATCH /api/v1/conversations/{id}`** — `{ status: 'open' | 'closed' }` to close or
reopen. Returns the updated `ConversationSummary`. Needs `setConversationStatus` on the
store. `deleted` is not settable through this endpoint.
## 4. Write paths (HT-18)

### 4a. `POST /api/v1/conversations/{id}/replies` — the Agent replies

Body: `{ text: string; html?: string }` — `text` 1–5000 chars, server-enforced; `html`
optional. The Agent supplies only the message; every mail header is DERIVED server-side
from the conversation, so the client never sets recipients or threading headers:

- **`to`** = the conversation's `customerEmail`.
- **`from`** = the deployment's configured support address (`supportAddress` dep).
- **`subject`** = the conversation's `subject`, prefixed with `Re:` plus a space if it
isn't already (case-insensitive check — never double-prefix to `Re: Re:`).
- **`In-Reply-To`** = the `messageId` of the conversation's most-recent INBOUND thread (the
customer message being answered), if it has one; **`References`** = the `messageId`s of
all prior threads in chronological order that have one. These are for the customer's mail
client to thread the reply in THEIR inbox — Helpthread's own threading never depends on
them (it is outbound-token-anchored; threading.md §2). Omitted when no prior message-id
exists (e.g. an inbound message that arrived without a `Message-ID`).

The handler then calls `sendReply` (`src/mail/send.ts`), which mints the reply token into
the outbound `Message-ID`, persists the outbound thread (`delivery_status` `pending`→`sent`),
and sends via the injected `EmailSender`.

Outcomes:
- **`201`** with the created `ThreadView` on success. A reply to a `closed` conversation
**reopens** it (the store's existing append policy).
- **`404 not_found`** if the conversation is missing or `deleted` — no message is sent; a
reply token minted before the append resolves is simply discarded (mirrors §3b).
- **`400 validation_failed`** on a body that violates the limits.
- **`502 send_failed`** if the provider rejects the message — nothing was delivered.
`sendReply` returns a `send-failed` result (it does not throw): the outbound thread is
left `delivery_status = 'failed'` (a future delivery worker, HT-16, retries it with the
same Message-ID) — or, if even that mark fails, stuck `pending`. The response therefore
says only that the reply *could not be delivered* — never a specific persisted state,
never a raw provider error. This is the one outcome where an undelivered reply is
surfaced to the caller distinctly from an internal error. (Note the asymmetry: once the
provider ACCEPTS the message it is delivered, so a subsequent failure to record `'sent'`
is NOT a `send_failed` — it resolves to `201`, since reporting a delivered message as
failed would invite a resend.)

### 4b. `PATCH /api/v1/conversations/{id}` — close or reopen

Body: `{ status: 'open' | 'closed' }`. Returns the updated `ConversationSummary` (`200`).
Needs a store `setConversationStatus(id, status)` that **excludes `deleted`** (a deleted
conversation is not reopenable through this endpoint): missing or deleted → `404 not_found`;
a body whose `status` is neither `open` nor `closed` (notably `deleted`, which is not
settable here) → `400 validation_failed`.

Both write paths grow `InboxApiDeps` with what `sendReply` needs — `sender` (`EmailSender`),
`keyring`, `mailDomain`, and `supportAddress` — injected at deploy time alongside `store`
and `apiToken`.

## 5. Security notes

Expand Down
275 changes: 267 additions & 8 deletions src/api/conversations.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
/**
* The two read handlers of the Agent Inbox API — `GET /api/v1/conversations`
* (the inbox list, specs/api/agent-inbox-v1.md §3a) and
* `GET /api/v1/conversations/{id}` (one conversation with its threads, §3b).
* The Agent Inbox API's handlers: the two HT-17 read paths —
* `GET /api/v1/conversations` (the inbox list, specs/api/agent-inbox-v1.md
* §3a) and `GET /api/v1/conversations/{id}` (one conversation with its
* threads, §3b) — plus the two HT-18 write paths — `POST
* /api/v1/conversations/{id}/replies` (the Agent replies, §4a) and `PATCH
* /api/v1/conversations/{id}` (close/reopen, §4b).
*
* Each handler is a pure function of an already-authenticated, already-
* routed `Request` plus its store dependency — `src/api/index.ts` is what
* authenticates and routes; nothing here re-checks either. Both return a
* `Response` built exclusively through `src/api/responses.ts`'s helpers, so
* every reply (success or error) carries the mandatory `Cache-Control:
* no-store` (spec §3) without each handler having to remember it.
* routed `Request` plus its dependencies — `src/api/index.ts` is what
* authenticates and routes; nothing here re-checks either. Every handler
* returns a `Response` built exclusively through `src/api/responses.ts`'s
* helpers, so every reply (success or error) carries the mandatory
* `Cache-Control: no-store` (spec §3) without each handler having to
* remember it.
*/

import type { Keyring } from '../mail/reply-token.js'
import { sendReply } from '../mail/send.js'
import type { EmailSender } from '../providers/index.js'
import type { ConversationStore, StoredThread } from '../store/conversations.js'
import { decodeCursor, encodeCursor } from './cursor.js'
import { apiError, json } from './responses.js'
Expand All @@ -23,6 +30,11 @@ const MAX_LIMIT = 50
/** Floor on `limit` — a caller-supplied `0` or negative value clamps up to this, not rejected. */
const MIN_LIMIT = 1

/** Minimum length of a reply's `text` field, server-enforced (spec §4a). */
const MIN_REPLY_TEXT_LENGTH = 1
/** Maximum length of a reply's `text` field, server-enforced (spec §4a). */
const MAX_REPLY_TEXT_LENGTH = 5000

/** 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 Down Expand Up @@ -174,6 +186,253 @@ export async function handleGetConversation(
return json(200, body)
}

/**
* Handle `POST /api/v1/conversations/{id}/replies` — the Agent replies to a
* conversation (spec §4a). The client supplies only `{ text, html? }`; every
* mail header (`to`, `from`, `subject`, `In-Reply-To`, `References`) is
* derived server-side from the conversation (see {@link deriveReplyHeaders})
* so the client can never set recipients or threading headers.
*
* Outcomes (spec §4a): `201` with the created `ThreadView` on success (a
* reply to a `closed` conversation reopens it, via `sendReply` →
* `ConversationStore.appendThread`'s existing policy); `404 not_found` if
* the conversation is missing or `deleted` (checked BEFORE minting/sending,
* and again as a race check on `sendReply`'s own result — see below);
* `400 validation_failed` on a body that violates the limits; `502
* send_failed` if the provider rejects the message — `sendReply` returns a
* `send-failed` result (it does not throw), the outbound thread is left
* `failed` OR, if even that mark failed, stuck `pending` (`persistedStatus`),
* and nothing was delivered — so the response says only that the reply could
* not be delivered, never a specific persisted state and never a raw provider
* error (spec §4a, §5's user-safe-message rule).
*/
export async function handleReply(
id: string,
request: Request,
deps: {
store: ConversationStore
sender: EmailSender
keyring: Keyring
mailDomain: string
supportAddress: string
},
): Promise<Response> {
if (!isUuid(id)) {
return apiError(404, 'not_found', 'No conversation with that id.')
}

const parsedBody = await parseJsonBody(request)
if (!parsedBody.ok) {
return apiError(400, 'validation_failed', 'Request body must be valid JSON.')
}

const replyBody = parseReplyBody(parsedBody.value)
if (replyBody === null) {
return apiError(
400,
'validation_failed',
`text is required and must be ${MIN_REPLY_TEXT_LENGTH}-${MAX_REPLY_TEXT_LENGTH} characters; html, if present, must be a string.`,
)
}

// Fetched BEFORE sendReply purely to derive the reply's headers (subject,
// In-Reply-To, References) from the conversation's current state — this is
// NOT the authoritative existence/deleted check for the write itself.
// `sendReply` (via `ConversationStore.appendThread`) re-checks under a row
// lock at write time and is what `result.ok === false` reflects below, so a
// conversation deleted in the gap between this read and that write is still
// caught, just as a `404` rather than a silent write.
const conversation = await deps.store.getConversation(id, { includeDeleted: false })
if (conversation === null || conversation.status === 'deleted') {
return apiError(404, 'not_found', 'No conversation with that id.')
}

const { subject, inReplyTo, references } = deriveReplyHeaders(conversation)

const result = await sendReply(
{
// Use the CANONICAL id from the fetched row, not the raw path segment:
// the id is minted verbatim into the outbound Message-ID token, and a
// non-canonical (e.g. upper-cased) path id would put a non-canonical
// conversationId in the token even though the stored row is lowercase.
conversationId: conversation.id,
from: deps.supportAddress,
to: [conversation.customerEmail],
subject,
text: replyBody.text,
html: replyBody.html,
inReplyTo,
references,
},
{
store: deps.store,
sender: deps.sender,
keyring: deps.keyring,
mailDomain: deps.mailDomain,
},
)

if (!result.ok) {
if (result.reason === 'send-failed') {
// The provider rejected the message — nothing was delivered (§4a). Safe
// to surface distinctly from an internal error; the raw provider error
// is never exposed (§5). NOT a "saved for retry" promise — the reply may
// be persisted 'failed' OR stuck 'pending' (result.persistedStatus), so
// this message claims only what is always true: it wasn't delivered.
return apiError(502, 'send_failed', 'The reply could not be delivered.')
}
// conversation-not-found / conversation-deleted — a race: the conversation
// went missing/deleted between the header-fetch above and appendThread's
// own check. Nothing was sent — mirrors §3b's generic not-found.
return apiError(404, 'not_found', 'No conversation with that id.')
}

const updated = await deps.store.getConversation(conversation.id, { includeDeleted: false })
const thread = updated?.threads.find((t) => t.id === result.threadId)
if (updated == null || thread === undefined) {
// Should be unreachable: sendReply just reported a successful append of
// exactly this thread id. Treated as an internal error, not a routine
// 404, if the invariant ever breaks.
return apiError(500, 'server_error', 'Internal server error.')
}

return json(201, toThreadViewJson(thread))
}

/**
* Handle `PATCH /api/v1/conversations/{id}` — close or reopen a conversation
* (spec §4b). Body: `{ status: 'open' | 'closed' }` — `'deleted'` is
* deliberately not a settable value here (`400`, not `404`, since the body
* itself is malformed regardless of whether `{id}` exists).
*
* Outcomes: `200` with the updated `ConversationSummary` on success; `404
* not_found` if `{id}` is missing or names a `deleted` conversation (a
* deleted conversation is not reopenable through this endpoint — spec §4b);
* `400 validation_failed` on any other `status` value.
*/
export async function handlePatchConversation(
id: string,
request: Request,
deps: { store: ConversationStore },
): Promise<Response> {
if (!isUuid(id)) {
return apiError(404, 'not_found', 'No conversation with that id.')
}

const parsedBody = await parseJsonBody(request)
if (!parsedBody.ok) {
return apiError(400, 'validation_failed', 'Request body must be valid JSON.')
}

const status = parsePatchStatusBody(parsedBody.value)
if (status === null) {
return apiError(400, 'validation_failed', "status must be 'open' or 'closed'.")
}

const updated = await deps.store.setConversationStatus(id, status)
if (updated === null) {
return apiError(404, 'not_found', 'No conversation with that id.')
}

return json(200, toConversationSummaryJson(updated))
}

/**
* Read and JSON-parse `request`'s body without ever throwing — a malformed
* or empty body is `400 validation_failed`, never an uncontrolled `500`
* (`request.json()` throws a `SyntaxError` on empty/invalid input, which
* this catches). Returns the parsed value (still unvalidated against any
* particular shape — that's each handler's own body-shape parser's job).
*/
async function parseJsonBody(
request: Request,
): Promise<{ ok: true; value: unknown } | { ok: false }> {
try {
return { ok: true, value: await request.json() }
} catch {
return { ok: false }
}
}

/** Validated shape of `POST .../replies`'s request body (spec §4a). */
interface ReplyRequestBody {
text: string
html?: string
}

/**
* Validate a parsed reply body against spec §4a: `text` must be a string of
* `[MIN_REPLY_TEXT_LENGTH, MAX_REPLY_TEXT_LENGTH]` chars; `html`, if
* present, must be a string. Returns `null` on any violation — never throws.
*/
function parseReplyBody(raw: unknown): ReplyRequestBody | null {
if (typeof raw !== 'object' || raw === null) return null
const { text, html } = raw as Record<string, unknown>

if (
typeof text !== 'string' ||
text.length < MIN_REPLY_TEXT_LENGTH ||
text.length > MAX_REPLY_TEXT_LENGTH
) {
return null
}
if (html !== undefined && typeof html !== 'string') return null

return html === undefined ? { text } : { text, html }
}

/**
* Validate a parsed PATCH body against spec §4b: `status` must be exactly
* `'open'` or `'closed'` — notably `'deleted'` is NOT settable here. Returns
* `null` on any violation — never throws.
*/
function parsePatchStatusBody(raw: unknown): 'open' | 'closed' | null {
if (typeof raw !== 'object' || raw === null) return null
const { status } = raw as Record<string, unknown>
return status === 'open' || status === 'closed' ? status : null
}

/**
* Derive a reply's mail headers from the conversation being replied to
* (spec §4a):
*
* - `subject`: the conversation's subject, `Re: `-prefixed unless it already
* starts with `re:` (case-insensitive) — never double-prefixed.
* - `inReplyTo`: the `messageId` of the most-recent INBOUND thread that has
* one. Threads are stored oldest-first, so this walks from the end
* looking for the first (i.e. most recent) inbound thread with a
* non-null `messageId`. `undefined` if there is none (e.g. every inbound
* message arrived without a `Message-ID`).
* - `references`: every thread's `messageId`, in chronological order, that
* is non-null. `undefined` (the key omitted entirely, per spec §4a) when
* NO thread has one — never an empty array in that case.
*/
function deriveReplyHeaders(conversation: { subject: string; threads: StoredThread[] }): {
subject: string
inReplyTo: string | undefined
references: string[] | undefined
} {
const subject = /^re:/i.test(conversation.subject)
? conversation.subject
: `Re: ${conversation.subject}`

let inReplyTo: string | undefined
for (let i = conversation.threads.length - 1; i >= 0; i--) {
const thread = conversation.threads[i]
if (thread.direction === 'inbound' && thread.messageId !== null) {
inReplyTo = thread.messageId
break
}
}

const referencesList = conversation.threads
.map((t) => t.messageId)
.filter((messageId): messageId is string => messageId !== null)
const references = referencesList.length > 0 ? referencesList : undefined

return { subject, inReplyTo, references }
}

function toConversationSummaryJson(row: {
id: string
subject: string
Expand Down
Loading
Loading