> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hq.zone/llms.txt
> Use this file to discover all available pages before exploring further.

# Agent email: inbox, drafts, and triage

> Give an agent an email address, then review, approve, send, or discard the replies it drafts — plus assign unrouted mail and clear quarantine.

HQ lets an agent receive and reply to email. You mint an **email endpoint** (an inbound address), mail lands on it, and — depending on the endpoint's policy — the agent either replies on its own or **drafts a reply for a human to review**. This guide walks the operator/integrator flow: create an endpoint, watch the review queue, read a message, approve/edit and send (or discard) the draft, assign mail that couldn't be routed, and clear the quarantine.

Two API surfaces are involved:

* **Endpoint admin** (`/v1/api/email/endpoints`, `/v1/api/email/workspace-mailbox`) — manage the addresses. Every operation is **admin-scoped**.
* **Triage** (`/v1/api/email/...`) — the read + action surface over inbound mail. Read/list operations require **`conversations:read`**, reply/discard require **`conversations:write`**, and the rest (`assign`, message/attachment content, quarantine) are **admin-scoped**.

All requests use the base URL `https://api.hq.zone` and a PAT bearer token:

```bash theme={null}
export HQ_TOKEN="hq_pat_..."
```

<Note>Most of this feature is **admin only**. The scope is stated per endpoint below; where it says "admin", a non-admin token is rejected. The triage list/badge endpoints (`list_review`, `list_activity`, `get_badge`) are scoped so a regular member sees only mail bound to them and the admin-only queues read empty.</Note>

## 1. Create an email endpoint (give an agent an address)

<Steps>
  <Step title="Create the endpoint">
    `POST /v1/api/email/endpoints` (admin) creates a custom inbox. The required fields are `localpart` and `kind`; an agent-handled inbox sets `plane` to `agent` and names a handler `agent_id` (the agent must belong to your workspace). A "light" quick-reply inbox is agentless — do **not** pass `agent_id`.

    ```bash theme={null}
    curl -X POST https://api.hq.zone/v1/api/email/endpoints \
      -H "Authorization: Bearer $HQ_TOKEN" -H "Content-Type: application/json" \
      -d '{
        "localpart": "sales.eu",
        "kind": "agent",
        "agent_id": "<AGENT_ID>",
        "plane": "agent",
        "reply_policy": "human_review",
        "sender_policy": "bound_users_only"
      }'
    ```

    The response is the created endpoint (`EndpointJson`) with its `id`, the stored config, and per-endpoint counters.
  </Step>

  <Step title="Read back the full address">
    The endpoint object does not contain the full address by itself — it carries the `localpart`. To assemble the address, call `GET /v1/api/email/endpoints` (List endpoints, admin), which returns `tenant_email_slug`, `domain`, and `outbound_mailbox_localpart` alongside the endpoints:

    ```bash theme={null}
    curl https://api.hq.zone/v1/api/email/endpoints \
      -H "Authorization: Bearer $HQ_TOKEN"
    # → { "tenant_email_slug": "...", "domain": "...", "outbound_mailbox_localpart": "team", "endpoints": [ ... ] }
    ```

    The full address is `<localpart>-<tenant_email_slug>@<domain>`. So the inbox above resolves to `sales.eu-<tenant_email_slug>@<domain>`.
  </Step>
</Steps>

<Tip>The `localpart` must be lowercase alphanumerics with `.` or `-` separators, start with an alphanumeric, and be at most 63 characters. Reserved localparts (such as `postmaster`, `noreply`) and the workspace mailbox localpart are rejected.</Tip>

### Endpoint behavior fields

`create_endpoint` and `update_endpoint` share the same config knobs. The validated enum fields:

* `plane` — one of `light`, `agent`. `light` (the default) is the agentless quick-reply plane; `agent` hands the thread to the named agent.
* `sender_policy` — one of `bound_users_only`, `anyone`, `domain_allowlist`, `addr_allowlist`.
* `reply_policy` — one of `agent_decides`, `always_reply`, `never_reply`, `human_review`.

Other fields include `instructions`, `allow_escalation`, `default_reply_lang`, `deliver_to`, `category_policy`, `spam_score_max`, `max_turns_per_sender_day`, `max_turns_per_day`, `enabled`, and `allow_patterns` (the allowlist; supplying it replaces the set wholesale).

<Warning>
  `create_endpoint` returns **402** if your plan's email-inbox limit is reached, and **409** if the address (or an agent's existing endpoint) already exists. The enum fields above are validated — an unknown value is a **400**.
</Warning>

### Manage existing endpoints

| Operation                                               | Method + path                         | Scope | Notes                                                             |
| ------------------------------------------------------- | ------------------------------------- | ----- | ----------------------------------------------------------------- |
| [List endpoints](/api-reference/admin/list-endpoints)   | `GET /v1/api/email/endpoints`         | admin | Endpoints + counters + slug/domain.                               |
| [Get endpoint](/api-reference/admin/get-endpoint)       | `GET /v1/api/email/endpoints/{id}`    | admin | One endpoint; unknown id → 404.                                   |
| [Update endpoint](/api-reference/admin/update-endpoint) | `PATCH /v1/api/email/endpoints/{id}`  | admin | Omitted fields keep current values; `localpart`/`kind` are fixed. |
| [Delete endpoint](/api-reference/admin/delete-endpoint) | `DELETE /v1/api/email/endpoints/{id}` | admin | The system workspace mailbox can't be deleted (400).              |

```bash theme={null}
# Pause an inbox without deleting it
curl -X PATCH https://api.hq.zone/v1/api/email/endpoints/<ENDPOINT_ID> \
  -H "Authorization: Bearer $HQ_TOKEN" -H "Content-Type: application/json" \
  -d '{"enabled": false}'
```

### The workspace outbound mailbox

Every workspace has one HQ-managed outbound mailbox — the address all agents send from (`is_system: true`). It is **never** edited through the endpoint editor (that returns 400) and can't be deleted. The only knob is its localpart, changed via [Update mailbox](/api-reference/admin/update-mailbox):

```bash theme={null}
curl -X PATCH https://api.hq.zone/v1/api/email/workspace-mailbox \
  -H "Authorization: Bearer $HQ_TOKEN" -H "Content-Type: application/json" \
  -d '{"localpart": "hello"}'
# → 200 { "ok": true }
```

<Note>The workspace mailbox never auto-replies, and that behavior is not configurable. An invalid or reserved localpart returns **400**.</Note>

## 2. Mail arrives and the agent drafts

When mail lands on an endpoint, HQ mirrors it as an `email_message` and routes it. The message moves through a small handling-state machine. The exact state strings are:

* `unrouted` — couldn't be routed to a handler (admin owns this queue).
* `awaiting_review` — the agent drafted a reply; a human must approve, edit & send, or discard it.
* `handled` — terminal: replied, discarded, or assigned.

When the endpoint's `reply_policy` keeps a human in the loop, the drafted reply lands in `awaiting_review` for you to act on. The badge and review queues count exactly the items needing a human.

## 3. The review queue

[List review](/api-reference/admin/list-review) (`GET /v1/api/email/review`, scope `conversations:read`) returns the three actionable queues plus counts:

```bash theme={null}
curl https://api.hq.zone/v1/api/email/review \
  -H "Authorization: Bearer $HQ_TOKEN"
```

The response (`ReviewQueues`) has:

* `awaiting_review` — rows of drafted replies (scoped to the caller; admins see all). Each is capped at 200 items.
* `unrouted` — **admin-only**; empty for a non-admin.
* `quarantined` — **admin-only**; empty for a non-admin.
* `counts` — `{ awaiting_review, unrouted, quarantined }`.

Each `awaiting_review` / `unrouted` row carries `email_message_id`, `direction`, `handling_state`, `from_addr`, `to_addrs`, `subject`, `llm_category`, `llm_summary`, `agent_id`, `endpoint_id`, `endpoint_localpart`, `endpoint_plane`, a `conversation_id` deep-link, and `occurred_at`.

<Tip>For a one-number nav badge, call [Get badge](/api-reference/admin/get-badge) (`GET /v1/api/email/badge`, scope `conversations:read`). It returns `count` (the sum the caller can see) broken down into `awaiting_review`, `unrouted`, and `quarantined`. For the full inbound + outbound feed with pagination and filtering, use [List activity](/api-reference/admin/list-activity) (`GET /v1/api/email/activity`, scope `conversations:read`) — it accepts `state` (`handled` / `awaiting_review` / `unrouted`), `endpoint_id`, `limit` (1–200, default 50), `offset`, and `sort` (`recent` default, or `oldest`).</Tip>

## 4. Read a message

Pick an `email_message_id` from the review queue and fetch the full message with [Get message](/api-reference/admin/get-message) (`GET /v1/api/email/messages/{id}/content`, **admin**):

```bash theme={null}
curl https://api.hq.zone/v1/api/email/messages/<MESSAGE_ID>/content \
  -H "Authorization: Bearer $HQ_TOKEN"
```

Headers, decoded bodies, and the attachment list are fetched on demand from the email store (the raw message is never kept on HQ's side). An unknown or cross-workspace id returns **404**.

To pull one attachment's raw bytes, use [Download attachment](/api-reference/admin/download-attachment) (`GET /v1/api/email/messages/{id}/attachments/{attachment_id}/download`, **admin**). It streams the bytes with the upstream `Content-Type` and `Cache-Control: no-store`; an attachment that doesn't belong to the message returns **404**.

## 5. Approve & send — or discard

<Steps>
  <Step title="Send the reply">
    [Send reply](/api-reference/admin/send-reply) (`POST /v1/api/email/messages/{id}/reply`, scope `conversations:write`) approves the drafted reply. The body (`ReplyReq`) is `{ "text": "...", "html": "..." }` — `text` is required and must be non-empty; `html` is optional. The UI pre-fills `text` with the draft so a human can edit before sending.

    ```bash theme={null}
    curl -X POST https://api.hq.zone/v1/api/email/messages/<MESSAGE_ID>/reply \
      -H "Authorization: Bearer $HQ_TOKEN" -H "Content-Type: application/json" \
      -d '{"text": "Thanks for reaching out — here is the quote you asked for..."}'
    # → 200 { "ok": true, "handling_state": "handled" }
    ```

    The reply is sent as the handler agent on behalf of the bound user, and the item settles to `handling_state: "handled"`.
  </Step>

  <Step title="Or discard the draft">
    [Discard draft](/api-reference/admin/discard-draft) (`POST /v1/api/email/messages/{id}/discard`, scope `conversations:write`) drops the draft without sending and settles the item to `handled`:

    ```bash theme={null}
    curl -X POST https://api.hq.zone/v1/api/email/messages/<MESSAGE_ID>/discard \
      -H "Authorization: Bearer $HQ_TOKEN"
    # → 200 { "ok": true, "handling_state": "handled" }
    ```
  </Step>
</Steps>

<Warning>
  Both actions require the item to be in `awaiting_review`. Acting on a message in any other state returns **400** (`message is <state>, not awaiting_review`). A regular member can act only on mail bound to them — someone else's message returns **404** (so it can't be probed). `send_reply` also returns **400** if the message has no handler agent or no bound user.
</Warning>

## 6. Assign unrouted mail

Mail that couldn't be routed sits in the `unrouted` queue (admin-only). [Assign email](/api-reference/admin/assign-email) (`POST /v1/api/email/messages/{id}/assign`, **admin**) attaches a handler and re-drives it:

```bash theme={null}
curl -X POST https://api.hq.zone/v1/api/email/messages/<MESSAGE_ID>/assign \
  -H "Authorization: Bearer $HQ_TOKEN" -H "Content-Type: application/json" \
  -d '{"agent_id": "<AGENT_ID>"}'
# → 200 { "ok": true, "handling_state": "handled" }
```

The request body (`AssignReq`) is just `{ "agent_id": "..." }`. The agent must belong to your workspace. The handler sets the message's agent, re-emits the routed event so a turn runs, and — if the mail hit a handler-less mailbox endpoint — wires the chosen agent onto that endpoint so future mail routes without a human. The item settles to `handled`.

<Warning>
  The message must be in `unrouted` — anything else returns **400** (`message is <state>, not unrouted`). An unknown agent, or a message with no bound user, also returns **400**.
</Warning>

## 7. The quarantine path

Suspicious inbound mail (for example, `unknown_sender` or a spam-threshold hit) is held in quarantine instead of being routed. This is an **admin-only** surface; the pending items appear as the `quarantined` queue in `list_review`.

<Steps>
  <Step title="View the quarantined content">
    [Get quarantine](/api-reference/admin/get-quarantine) (`GET /v1/api/email/quarantine/{id}/content`, **admin**) fetches the raw message for inspection before you decide. Attachments aren't materialized until accept, so the `attachments` list is empty here.

    ```bash theme={null}
    curl https://api.hq.zone/v1/api/email/quarantine/<QUARANTINE_ID>/content \
      -H "Authorization: Bearer $HQ_TOKEN"
    ```

    Note the id here is the **quarantine id**, not an `email_message_id`. An unknown id returns **404**.
  </Step>

  <Step title="Accept (optionally binding the sender)">
    [Accept quarantine](/api-reference/admin/accept-quarantine) (`POST /v1/api/email/quarantine/{id}/accept`, **admin**) marks the item accepted. The body (`AcceptReq`) optionally carries `bind_user_id` — binding the sender to a workspace user so their future mail routes. A `bind_user_id` is **required** to clear an `unknown_sender` quarantine.

    ```bash theme={null}
    curl -X POST https://api.hq.zone/v1/api/email/quarantine/<QUARANTINE_ID>/accept \
      -H "Authorization: Bearer $HQ_TOKEN" -H "Content-Type: application/json" \
      -d '{"bind_user_id": "<USER_ID>"}'
    # → 200 { "ok": true, "note": "Accepted and re-delivered to the agent." }
    ```

    When the destination is now reachable (a freshly bound `unknown_sender`, or a `spam_threshold` item whose sender is already bound), HQ re-delivers this message so the agent acts on it immediately; otherwise it routes on the sender's next mail. The response (`ActionOk`) returns `ok: true` and an optional explanatory `note`.
  </Step>

  <Step title="Or reject">
    [Reject quarantine](/api-reference/admin/reject-quarantine) (`POST /v1/api/email/quarantine/{id}/reject`, **admin**) marks the item rejected — a terminal decision:

    ```bash theme={null}
    curl -X POST https://api.hq.zone/v1/api/email/quarantine/<QUARANTINE_ID>/reject \
      -H "Authorization: Bearer $HQ_TOKEN"
    # → 200 { "ok": true }
    ```
  </Step>
</Steps>

<Note>Accept and reject act only on a **pending** quarantine. If no pending item with that id exists, both return **404**. Accepting an `unknown_sender` without a `bind_user_id` only records the decision — the response `note` reminds you to pass `bind_user_id` to route the sender's future mail.</Note>

## Where it lands

Once an item is `handled`, it drops out of the review queue and the badge. It stays visible in the [Activity feed](/api-reference/admin/list-activity), where you can filter by `state` or `endpoint_id` to audit what the agent and your reviewers did.
