---
title: "Sending Mail"
description: "Send an email as one of your agents, make it idempotent, and confirm it was delivered through events."
canonical_url: "https://www.agent-identity.dev/docs/mail/sending"
markdown_url: "https://www.agent-identity.dev/docs/mail/sending.md"
last_updated: "2018-10-20"
x_farming_labs_generated_preamble: true
agent:
  tokenBudget: 900
  task: "Send an email as an agent and confirm it was accepted and delivered."
  outcome: "POST returns 202 with a message id and thread id, a mail.sent event is appended, and a later mail.delivered / mail.bounced event resolves the outcome."
  appliesTo:
    package:
      - "@aid/api"
      - "@agentidentity/sdk"
  prerequisites:
    - "An agent with a provisioned mailbox."
    - "The recipient is not blocked by an access rule for this agent."
  files:
    - "apps/api/src/services/mail.ts"
  commands:
    - run: "aid mail send --agent <identity-id> --to someone@example.com --subject Hello --text 'First message.'"
      description: "Send from the CLI."
  sideEffects:
    - "Counts against the org-scoped OUTBOUND_QUOTA_PER_DAY, default 5000 messages per UTC day."
    - "Creates a thread when in_reply_to_message_id is omitted."
  verification:
    - description: "Check the response status and the log."
      expect: "202 with status queued or sent, followed by a mail.sent event for aggregate_id equal to the returned message id."
  rollback:
    - "A queued message cannot be recalled. Suspend the agent to stop further sends, or delete the draft if it has not been sent yet."
  failureModes:
    - symptom: "409 on send."
      resolution: "The same idempotency-key was used with a different body. Use a fresh key, or replay the original body verbatim to get the original result."
    - symptom: "The send is rejected because the recipient is blocked."
      resolution: "An access rule with action block matches the recipient. List rules with GET /v1/contact-rules and delete the matching one, or send to a different address."
    - symptom: "202 but the message never leaves queued."
      resolution: "Delivery is asynchronous. Wait for the mail.sent or mail.bounced event instead of assuming a 202 means delivered, and check GET /v1/providers/health if nothing arrives."
---

# Sending Mail
URL: /docs/mail/sending
LLM index: /llms.txt
Description: Send an email as one of your agents, make it idempotent, and confirm it was delivered through events.
Related: /docs/mail/receiving, /docs/mail/attachments, /docs/concepts/events, /docs/mail/domains

<!-- farming-labs:agent-contract:start -->
## Agent Contract

Task: Send an email as an agent and confirm it was accepted and delivered.
Outcome: POST returns 202 with a message id and thread id, a mail.sent event is appended, and a later mail.delivered / mail.bounced event resolves the outcome.

### Applies To

- Package: `@aid/api`, `@agentidentity/sdk`

### Prerequisites

- An agent with a provisioned mailbox.
- The recipient is not blocked by an access rule for this agent.

### Files

- `apps/api/src/services/mail.ts`

### Commands

- `aid mail send --agent <identity-id> --to someone@example.com --subject Hello --text 'First message.'` — Send from the CLI.

### Side Effects

- Counts against the org-scoped OUTBOUND_QUOTA_PER_DAY, default 5000 messages per UTC day.
- Creates a thread when in_reply_to_message_id is omitted.

### Verification

- Check the response status and the log.
  - Expected: 202 with status queued or sent, followed by a mail.sent event for aggregate_id equal to the returned message id.

### Rollback

- A queued message cannot be recalled. Suspend the agent to stop further sends, or delete the draft if it has not been sent yet.

### Failure Modes

- 409 on send. — Recovery: The same idempotency-key was used with a different body. Use a fresh key, or replay the original body verbatim to get the original result.
- The send is rejected because the recipient is blocked. — Recovery: An access rule with action block matches the recipient. List rules with GET /v1/contact-rules and delete the matching one, or send to a different address.
- 202 but the message never leaves queued. — Recovery: Delivery is asynchronous. Wait for the mail.sent or mail.bounced event instead of assuming a 202 means delivered, and check GET /v1/providers/health if nothing arrives.
<!-- farming-labs:agent-contract:end -->

# Sending Mail

```bash title="terminal"
curl -sS -X POST "https://api.agent-identity.dev/v1/identities/$AID_AGENT/mail/send" \
  -H "authorization: Bearer $AID_KEY" \
  -H 'content-type: application/json' \
  -H "idempotency-key: order-4182-confirmation" \
  -d '{
    "to": ["customer@example.com"],
    "cc": ["billing@example.com"],
    "subject": "Your order",
    "text": "Shipped this morning.",
    "html": "<p>Shipped this morning.</p>"
  }'
```

The response is `202 Accepted`. The message row exists and is queued; the worker hands it
to the provider.

| Field | Required | Notes |
| --- | --- | --- |
| `to` | yes | At least one address, each validated as an email. |
| `cc` | no | Same validation. |
| `subject` | no | |
| `text` | no | Plain-text body. |
| `html` | no | HTML body. Send both when you want a multipart message. |
| `in_reply_to_message_id` | no | Continues that message's thread instead of starting one. |
| `attachments` | no | See [Attachments](/docs/mail/attachments). |

Sending as an agent means sending *from* its mailbox. There is no `from` field — the
address is the agent's, which is the point.

## Idempotency

The `idempotency-key` header is optional and worth using for anything triggered by an
external event, where a retry is plausible.

- Same key, same body → the original response, no second send.
- Same key, different body → `409`.

Derive the key from whatever made the send necessary — an order id, a ticket id, the
inbound message id you are replying to — rather than a random value, so a retry of the
whole operation produces the same key.

## Replying

```json
{
  "to": ["customer@example.com"],
  "text": "Following up.",
  "in_reply_to_message_id": "e133…"
}
```

The new message joins that thread, and outbound headers are set so the recipient's client
threads it too.

## Blind copy, reply-all and forward

```json
{ "to": ["customer@example.com"], "bcc": ["manager@example.com"], "text": "Shipped." }
```

`bcc` recipients get the message, but no header anyone receives names them, and only your own
copy lists them: a recipient's copy, and anything you forward, never shows a bcc. They are checked
like every other recipient: an access rule that blocks one refuses the whole send, and an unverified
organization cannot bcc a stranger. An address already on `to` or `cc` is not also blind-copied.

```json
{ "reply_all": true, "in_reply_to_message_id": "e133…", "text": "On it." }
```

`reply_all` answers everyone on the message: the person who wrote it, then everyone else on it,
never your own mailbox. `to` is then optional, and anything you give in `to`, `cc` or `bcc` is
added. On a message you sent yourself it goes to the people you sent it to.

```bash
curl -sS -X POST ".../v1/identities/$AID_AGENT/mail/messages/$MESSAGE_ID/forward" \
  -H "authorization: Bearer $AID_KEY" -H 'content-type: application/json' \
  -d '{ "to": ["accounting@example.com"], "text": "Can you take this?" }'
```

A forward starts a new conversation with a `Fwd:` subject, your note on top, the original quoted
beneath a header (who, when, subject, to, cc) and its attachments carried over. It emits
`mail.forwarded`.

```ts
await client.mail.send({ identityId, replyAll: true, inReplyToMessageId, text: "On it." });
await client.mail.forward({ identityId, messageId, to: "accounting@example.com", text: "Can you take this?" });
```

## Confirming it was delivered

`202` means accepted, not delivered. The real answer arrives as
[events](/docs/concepts/events):

```ts title="send-and-confirm.ts"
const since = new Date();

const message = await client.mail.send({
  identityId,
  to: ["customer@example.com"],
  subject: "Your order",
  text: "Shipped this morning.",
});

const { event, timedOut } = await client.events.wait({
  type: "mail.delivered",
  aggregateId: message.id,
  since,
  timeoutMs: 55_000,
});
```

Note `since` is captured *before* the send. Without it the wait would open its window
after the send had already happened, and a fast provider callback would be missed.

`mail.sent` fires when the provider accepts the message; `mail.failed` fires when it refuses it or
cannot be reached, with the reason in `detail`. `mail.delivered`,
`mail.bounced`, and `mail.complained` arrive later, from the provider's delivery-status
webhook, and only if that webhook is configured.

## Limits

Sends count against `OUTBOUND_QUOTA_PER_DAY` — org-scoped, default 5000 messages per UTC
day (5 until the owner is verified, and then only to the owner's own address). Exceeding it
answers `429` with code `mail.outbound_quota_exceeded` and a `Retry-After` of the seconds until
00:00 UTC; a refused send is not counted. `GET /v1/usage` (`client.usage()`) says what has been
used, what the limit is, when it resets, and how many agents the organization has against its cap. Contact rules with `action: "block"` reject matching
recipients before anything is queued; see [Contacts](/docs/mail/contacts).

## From the CLI

```bash title="terminal"
aid mail send \
  --agent "$AID_AGENT" \
  --to customer@example.com \
  --subject "Your order" \
  --text "Shipped this morning." \
  --idempotency-key order-4182-confirmation
```

`--to` and `--cc` repeat for multiple recipients.

## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
Docs-scoped sitemap: [/docs/sitemap.md](/docs/sitemap.md).
Well-known sitemap: [/.well-known/sitemap.md](/.well-known/sitemap.md).
