# Sending and replying

> Send new messages, reply in thread and retry safely with Idempotency-Key.

Source: https://mueslimail.com/docs/sending-and-replying/

## Send a new message

`POST /v0/inboxes/{inbox}/messages/send` starts a new thread. `to` needs at least one address; `text` or `html` is required. The message is created and then attempted once, inside the request: `200` with the `message_id` and `thread_id` means the transport accepted it, `502 email_delivery_failed` means it did not, and the body says why in `delivery_error_code` and `delivery_error`. Delivery after acceptance is reported as `message.sent`, `message.delivered`, `message.delivery_delayed`, `message.rejected` or `message.failed`, and the delivery timeline section below says which one and why. The bounce and complaint breaker pauses sending when a window crosses a limit: `sending_health` on the inbox carries the counts, the thresholds and `paused_scope`, and the call returns `403 sending_paused` until support resumes it.

```bash
curl -s -X POST "https://api.mueslimail.com/v0/inboxes/billing@agents.your-domain.eu/messages/send" \
  -H "Authorization: Bearer $MAI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-confirmation" \
  -d '{"to": ["customer@example.com"], "subject": "Order 1042", "text": "Your order has shipped."}'
```

## Reply

`POST /v0/inboxes/{inbox}/messages/{message}/reply` answers in the same thread. Recipients, subject, `In-Reply-To` and `References` are set for you. Set `reply_all` to include the other recipients: your own address and the original `Bcc` are never among them, so a reply all cannot hand a hidden recipient to everybody else.

## Forward

`POST /v0/inboxes/{inbox}/messages/{message}/forward` sends a message on with `to`, optional `cc`/`bcc` and an optional `comment`. The recipients are the ones you name and nobody else: the addresses of the original message are never the default, because that is how a conversation leaks. The subject becomes `Fwd: <original>` once, without stacking prefixes.

* The attachments of the original are re-shared unchanged: same bytes, same filenames, same `Content-ID`, so an inline image keeps resolving from the quoted HTML.
* The forward stays in the conversation: `References` accumulates the chain plus the forwarded message, and `In-Reply-To` is that message, the same rule a reply follows.
* The body is your comment, then the signature of the address, then the original under a `---------- Forwarded message ----------` header.
* A message held by screening cannot be forwarded: the answer is `404`, because its content must not leave before somebody reads it.

```bash
curl -s -X POST "https://api.mueslimail.com/v0/inboxes/billing@agents.your-domain.eu/messages/$MESSAGE_ID/forward" \
  -H "Authorization: Bearer ***" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: forward-invoice-1042" \
  -d '{"to": ["accounting@example.com"], "comment": "For the March close."}'
```

## Signature per address

A signature belongs to an address, not to the organization: `PATCH /v0/inboxes/{inbox}` with `signature_html` writes it for that inbox, and `GET` returns it. An empty string removes it. It is appended to every message the address composes: a send, a reply, a forward and a draft. In a reply and a forward it sits under your own words and above the quoted message; in a text only message the HTML is rendered to plain text, so a reader that shows no HTML still sees who wrote it.

> Note: Changing a signature never rewrites a draft that already exists. A draft is a composition of its own moment: one written before the change keeps the body it has, and one written after carries the new signature from the start. This is the rule Proton follows, and it is the difference between a signature and putting words in somebody's draft.

## Undo send

Undo send is a **state of the message**, not a timer in a client. With `MAI_UNDO_SEND_SECONDS` set (5, 10 or 20; 0 turns the feature off) a send is created `held` with `cancel_window_ends_at`, and nothing is attempted until that moment: the answer carries `status: held` and the deadline. The scheduler releases the message when the window closes, and the sender attempts it then.

* `POST /v0/inboxes/{inbox}/messages/{message}/cancel` inside the window withdraws the message: it becomes `cancelled`, `message.cancelled` fires, and `message.sent` never does, because it was never sent.
* Outside the window, on a message that is not held, or on inbound mail the answer is `409 invalid_state` naming the state the message is in. A cancel that quietly did nothing would tell you your mail did not leave when it did.
* `message.sent` fires at the delivery that actually happened, never at the request: a held send has no event until it leaves. A held send whose window closes while sending is paused becomes `failed` with the reason of the pause in `last_error`, rather than waiting for a resume that may never come.
* The window lives on the server: reload the page, close the console, open another device, the deadline is the same one.

## Idempotency-Key

Agents retry. To make retries safe, send an `Idempotency-Key` header (up to 256 characters) on send and reply:

* The same key with the same request within 24 hours returns the stored result and sends nothing new.
* The same key with a different request returns `409 conflict`.
* While the first request with a key is still running, a second one returns `409 conflict`: retry to get the result.

Derive the key from your own state (an order id, a task id, the id of the message you answer), not from a random value generated at retry time.

## Delivery

Messages are DKIM signed with your domain's key. Mail to another inbox on the platform is delivered locally (agent to agent) without leaving the system. Other mail goes out through an EU SMTP relay.

## Deliverability test addresses

Four local parts on the service domain `test.<shared domain>` produce an outcome without an SMTP attempt, so a webhook, a rule or a console state can be exercised without spending the reputation of a real domain and without a second environment: `delivered@`, `bounced@`, `complained@` and `suppressed@`. A `+label` after the local part is ignored (`delivered+invoice@test.<shared domain>`).

* Nothing addressed this way leaves the platform: the recipients are removed before any MX is resolved, so no relay and no recipient sees the message.
* Each one writes the timeline row and the webhook event it names, with source `test`, and adds the `bounced`, `complained` or `suppressed` label to the message.
* A simulated outcome is never an outcome of a real recipient: it does not count towards the bounce or complaint rate, so a test bounce can never pause your sending. The delivery test of the production paths stays a real send to a mailbox you control.

> Note: The service domain exists only when the deployment has a shared domain. It is not a catch-all: `anything@test.<shared domain>` that is not one of the four local parts is refused like any unknown address.

## When a message is not delivered

A send is attempted once, in the request, and the answer is what the mail server said. `502 email_delivery_failed` carries `delivery_error_code`, the reason in machine terms, and `delivery_error`, the reply of the server in one line (at most 500 characters, never a credential). The same two fields are on the message row, so a refused message stays visible in its thread as `status: failed` and the console marks it `Not delivered`.

* `unauthorized_ip`: an SMTP 525. The relay does not allow this server's address, so nothing can be delivered until the provider authorises it. Contact support.
* `auth_failed`: an SMTP 535. The relay rejected the credentials.
* `relay_unreachable`, `connection_refused`, `tls_failed`: the relay could not be reached, refused the connection, or the encrypted session failed.
* `unknown`: the server refused the message for another reason, for example a recipient address that does not exist. `delivery_error` carries the reply.

A permanent refusal is not retried: the message is `failed`, so nothing is sent twice. A temporary one (a 4xx reply, a relay that is momentarily down) keeps the message `queued` and retried after 1, 5 and 30 minutes, then 1 and 2 hours, then every 3 hours within 5 days, before it is marked `failed`. `GET /v0/organization` reports the state of outbound mail for the whole organization: `ok`, `degraded` after three refused sends in a row, or `blocked` when every attempt of the last 24 hours was refused for an authorization reason. The console shows that state at the top of the inbox and above the composer.

## Delivery timeline and reputation

`GET /v0/inboxes/{inbox}/messages/{message}/timeline` returns every state of one message in order, each with the source that knew it: `smtp` (a reply from the mail server), `dsn` (a delivery status notification), `arf` (a feedback loop report), `breaker` (the sending health breaker), `rspamd` (the spam filter), `rule` (a deterministic rule) or `test` (a deliverability test address, see below). `code` is the status and `text` the reply in one line, at most 500 characters; an `unknown` code always carries a written reason.

* `message.delivered` is the transport accepting the message; `message.sent` reports the same moment as the outcome of the send.
* `message.delivery_delayed` is an attempt the remote server asked to repeat, or a delay report that came back. It is never a rejection.
* `message.rejected` is a definitive 5xx refusal at send time, never a retry. `message.dropped` is a message discarded by policy without an attempt, such as the breaker pausing sending.
* `message.bounced` comes from a delivery status notification and `message.complained` from a feedback loop report. Both count against the reputation of the domain.
* `message.suppressed` is a send to an address on a suppression list: the message was not attempted. `message.delivered`, `message.bounced`, `message.complained` and `message.suppressed` with source `test` are simulated by a test address and never count against the reputation.

> Note: There is no `opened` and no `clicked`, and there will not be: both need a tracking pixel or a rewritten link, and with them the recipient's explicit consent, which this product does not take. The timeline says what the mail system knows, and the mail system does not know when a message is read.

`GET /v0/domains/{domain}/reputation` reports `bounce_rate`, `complaint_rate`, the window and the thresholds of the sending health breaker, plus `breach_state` (`ok` or `breached`) and the pause the breaker applied (`paused_scope`, `paused_at`, `paused_reason`). The window and the thresholds are the ones `sending_health` returns on an inbox, not a copy: a number that would pause the mail is the number this endpoint reads.

> Careful: The timeline needs `message_read`; `message_metadata` reads headers, and a delivery state is not a header. It never contains a recipient, so a key with a narrower scope learns what happened to the message and not who it went to.

## Limits

* Up to 50 recipients per message across `to`, `cc` and `bcc`.
* Daily and monthly send limits depend on the plan; going over returns `429 limit_exceeded`. See [Limits](https://mueslimail.com/docs/limits.md).
* On the free plan a short footer is appended: "Sent via Mueslimail".

## Drafts

A draft is a message saved for later: for a human to review, or to send at `send_at`. Create it with the same fields as a send; recipients and a body are only needed when it is sent. With `in_reply_to` (a message id) the draft is a reply: `to`, `subject` and the threading headers default to those of that message, and the sent message joins its thread. `POST .../drafts/{draft}/send` sends it now through the normal send path (plan limits, `403 sending_paused`, `Idempotency-Key`) and deletes the draft.

* `send_at` schedules the draft; the scheduler checks every minute. `PATCH` with `send_at: null` cancels it.
* If a scheduled send is refused (for example a plan limit), the draft gets `status: failed` and `last_error`. Edit it or send it by hand to retry.
* Creating a draft needs `message_send`; reading drafts needs `message_read`. `draft.created` fires for every new draft.

## Endpoints

### POST /v0/inboxes/{inbox}/messages/send

Send a new message (new thread)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |
| `Idempotency-Key` | string | in header, max length 256 |

Body (Send):

| Name | Type | Notes |
| --- | --- | --- |
| `to` | string (email)[] | required, at least 1 item |
| `cc` | string (email)[] |  |
| `bcc` | string (email)[] |  |
| `subject` | string |  |
| `text` | string |  |
| `html` | string |  |
| `labels` | string[] |  |
| `attachments` | Attachment[] |  |

Responses:

* `200` Accepted by the transport, or queued when the install has no transport: SendResult
* `403` Error envelope: Error
* `409` Error envelope: Error
* `422` Error envelope: Error
* `429` Error envelope: Error
* `502` email_delivery_failed: the transport did not accept the message, with delivery_error_code and delivery_error: Error
* `503` Error envelope: Error

### GET /v0/organization

The organization and the state of its outbound mail

Responses:

* `200` OK: Organization
* `401` Error envelope: Error
* `403` Error envelope: Error

### POST /v0/inboxes/{inbox}/messages/{message}/reply

Reply in the same thread

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |
| `message` | string | in path, required, Message UUID or RFC Message-ID (URL encoded) |
| `Idempotency-Key` | string | in header, max length 256 |

Body (Reply):

| Name | Type | Notes |
| --- | --- | --- |
| `text` | string |  |
| `html` | string |  |
| `reply_all` | boolean |  |
| `labels` | string[] |  |
| `attachments` | Attachment[] |  |

Responses:

* `200` Accepted by the transport, or queued when the install has no transport: SendResult
* `403` Error envelope: Error
* `422` Error envelope: Error
* `429` Error envelope: Error
* `502` email_delivery_failed: the transport did not accept the reply, with delivery_error_code and delivery_error: Error
* `503` Error envelope: Error

### POST /v0/inboxes/{inbox}/messages/{message}/forward

Forward a message with its original attachments and an optional comment

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |
| `message` | string | in path, required, Message UUID or RFC Message-ID (URL encoded) |
| `Idempotency-Key` | string | in header, max length 256 |

Body (Forward):

| Name | Type | Notes |
| --- | --- | --- |
| `to` | string (email)[] | required, at least 1 item |
| `cc` | string (email)[] |  |
| `bcc` | string (email)[] |  |
| `comment` | string | Optional text above the quoted message, below the signature |
| `labels` | string[] |  |

Responses:

* `200` Accepted by the transport, held by the undo window, or queued when the install has no transport: SendResult
* `400` Error envelope: Error
* `403` Error envelope: Error
* `404` Error envelope: Error
* `429` Error envelope: Error
* `502` email_delivery_failed: the transport refused the forward, with delivery_error_code and delivery_error: Error

### POST /v0/inboxes/{inbox}/messages/{message}/cancel

Cancel a held send inside its undo window (needs message_send)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |
| `message` | string | in path, required, Message UUID or RFC Message-ID (URL encoded) |

Responses:

* `200` Cancelled: Message
* `403` Error envelope: Error
* `404` Error envelope: Error
* `409` Error envelope: Error

### PATCH /v0/inboxes/{inbox}

Update the display name, the signature, or the support intake of an inbox

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |

Body:

| Name | Type | Notes |
| --- | --- | --- |
| `display_name` | string | max length 200 |
| `ticket_intake` | boolean | New threads on this inbox become tickets in the support queue. |
| `signature_html` | string | HTML signature of this address. Empty string removes it., max length 20000 |

Responses:

* `200` OK: Inbox

### POST /v0/inboxes/{inbox}/drafts

Create a draft (needs message_send)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |

Body (CreateDraft):

| Name | Type | Notes |
| --- | --- | --- |
| `to` | string (email)[] |  |
| `cc` | string (email)[] |  |
| `bcc` | string (email)[] |  |
| `subject` | string |  |
| `text` | string |  |
| `html` | string |  |
| `labels` | string[] |  |
| `attachments` | Attachment[] |  |
| `in_reply_to` | string | Message UUID or RFC Message-ID to reply to |
| `reply_all` | boolean | With in_reply_to: copy the other recipients |
| `send_at` | string (date-time) | Send automatically at this time (future, within 365 days) |

Responses:

* `201` Created: Draft
* `400` Error envelope: Error
* `404` Error envelope: Error

### GET /v0/inboxes/{inbox}/drafts

List drafts, newest first (needs message_read)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |
| `limit` | integer | in query, min 1, max 100, default 20 |
| `page_token` | string | in query |

Responses:

* `200` OK: DraftList

### GET /v0/inboxes/{inbox}/drafts/{draft}

Get a draft (needs message_read)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |
| `draft` | string (uuid) | in path, required |

Responses:

* `200` OK: Draft
* `404` Error envelope: Error

### PATCH /v0/inboxes/{inbox}/drafts/{draft}

Update a draft (needs message_send)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |
| `draft` | string (uuid) | in path, required |

Body (UpdateDraft):

| Name | Type | Notes |
| --- | --- | --- |
| `to` | string (email)[] |  |
| `cc` | string (email)[] |  |
| `bcc` | string (email)[] |  |
| `subject` | string |  |
| `text` | string |  |
| `html` | string |  |
| `labels` | string[] |  |
| `attachments` | Attachment[] |  |
| `send_at` | string,null (date-time) | null cancels a scheduled send |

Responses:

* `200` OK: Draft
* `400` Error envelope: Error
* `404` Error envelope: Error

### DELETE /v0/inboxes/{inbox}/drafts/{draft}

Delete a draft (needs message_send)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |
| `draft` | string (uuid) | in path, required |

Responses:

* `204` Deleted

### POST /v0/inboxes/{inbox}/drafts/{draft}/send

Send a draft now and delete it (needs message_send)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |
| `draft` | string (uuid) | in path, required |
| `Idempotency-Key` | string | in header, max length 256 |

Responses:

* `200` Accepted by the transport, or queued when the install has no transport: SendResult
* `400` Error envelope: Error
* `403` Error envelope: Error
* `404` Error envelope: Error
* `409` Error envelope: Error
* `429` Error envelope: Error
* `502` email_delivery_failed: the transport did not accept the message, with delivery_error_code and delivery_error: Error

### GET /v0/inboxes/{inbox}/messages/{message}/timeline

The delivery states of one message, oldest first, with the source of each (requires message_read)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `inbox` | string (email) | in path, required |
| `message` | string | in path, required, Message UUID or RFC Message-ID (URL encoded) |

Responses:

* `200` OK: MessageTimeline
* `403` Error envelope: Error
* `404` Error envelope: Error

### GET /v0/domains/{domain}/reputation

Bounce and complaint reputation of a domain, measured like the sending breaker (requires domain_read)

Parameters:

| Name | Type | Notes |
| --- | --- | --- |
| `domain` | string | in path, required |

Responses:

* `200` OK: DomainReputation
* `403` Error envelope: Error
* `404` Error envelope: Error

### Send object

| Name | Type | Notes |
| --- | --- | --- |
| `to` | string (email)[] | required, at least 1 item |
| `cc` | string (email)[] |  |
| `bcc` | string (email)[] |  |
| `subject` | string |  |
| `text` | string |  |
| `html` | string |  |
| `labels` | string[] |  |
| `attachments` | Attachment[] |  |

### Reply object

| Name | Type | Notes |
| --- | --- | --- |
| `text` | string |  |
| `html` | string |  |
| `reply_all` | boolean |  |
| `labels` | string[] |  |
| `attachments` | Attachment[] |  |

### Forward object

| Name | Type | Notes |
| --- | --- | --- |
| `to` | string (email)[] | required, at least 1 item |
| `cc` | string (email)[] |  |
| `bcc` | string (email)[] |  |
| `comment` | string | Optional text above the quoted message, below the signature |
| `labels` | string[] |  |

### SendResult object

| Name | Type | Notes |
| --- | --- | --- |
| `message_id` | string |  |
| `thread_id` | string (uuid) |  |
| `status` | queued \| held \| sent \| suppressed | State of the row the request created. held means the undo window is open: nothing has been attempted yet, and cancel may still withdraw it. suppressed means every recipient is on the suppression list: no message exists because there was nobody to write to. |
| `cancel_window_ends_at` | string (date-time) | Deadline of POST .../cancel, present when status is held. |
| `recipients` | RecipientOutcome[] | One entry per recipient of the request, in the order it named them. A suppressed recipient is reported here and not hidden: the message is never written for it. |

### Draft object

| Name | Type | Notes |
| --- | --- | --- |
| `draft_id` | string (uuid) |  |
| `inbox_id` | string (email) |  |
| `thread_id` | string (uuid) | Set for a reply draft |
| `in_reply_to` | string | RFC Message-ID of the message replied to |
| `references` | string[] |  |
| `to` | string[] |  |
| `cc` | string[] |  |
| `bcc` | string[] |  |
| `subject` | string |  |
| `text` | string |  |
| `html` | string |  |
| `labels` | string[] |  |
| `attachments` | object[] |  |
| `send_at` | string (date-time) |  |
| `status` | draft \| scheduled \| failed |  |
| `last_error` | string | Why a scheduled send was refused |
| `created_at` | string (date-time) |  |
| `updated_at` | string (date-time) |  |

### MessageTimeline object

| Name | Type | Notes |
| --- | --- | --- |
| `message_id` | string (uuid) |  |
| `inbox_id` | string | The address that holds the message, never the list of recipients |
| `status` | received \| queued \| sending \| sent \| failed \| held \| cancelled | The state of the row now; the events carry how it got there |
| `events` | MessageTimelineEvent[] |  |

### MessageTimelineEvent object

| Name | Type | Notes |
| --- | --- | --- |
| `type` | message.received \| message.received.spam \| message.sent \| message.delivered \| message.delivery_delayed \| message.failed \| message.rejected \| message.bounced \| message.complained \| message.dropped \| message.ruled |  |
| `source` | smtp \| dsn \| arf \| breaker \| rspamd \| rule | smtp a reply, dsn a delivery status notification, arf a feedback report, breaker the sending health breaker, rspamd the spam filter, rule a deterministic rule |
| `code` | string | The SMTP status, the DSN status or the policy code; empty when the source has none. unknown always carries a reason in text. |
| `text` | string | The reply or the sentence, one line, at most 500 characters, never a body and never a recipient |
| `at` | string (date-time) |  |

### DomainReputation object

| Name | Type | Notes |
| --- | --- | --- |
| `domain` | string |  |
| `window_hours` | integer | The rolling window the counts cover, the same as sending_health.window_hours |
| `sends` | integer | Messages that reached a final outcome in the window |
| `hard_bounces` | integer | Permanent rejections at send time plus bounces reported by a DSN |
| `complaints` | integer | Spam complaints reported through the feedback loop |
| `bounce_rate` | number | hard_bounces divided by sends, 0 when sends is 0 |
| `complaint_rate` | number | complaints divided by sends, 0 when sends is 0 |
| `thresholds` | SendingThresholds |  |
| `breach_state` | ok \| breached | breached when the window trips a limit or when the breaker has paused an inbox on the domain or the organization |
| `breach_reason` | string | The sentence naming the window and the limit crossed; absent while breach_state is ok |
| `paused_scope` | string | What paused sending: inbox, organization, or empty. A send is refused by both scopes, so this tells them apart. |
| `paused_at` | string (date-time) |  |
| `paused_reason` | string | The sentence of the pause, the same one the 403 sending_paused carries |
