Send your application's own email - a helpdesk reply, a portal sign-in code - for a workspace that subscribes to you, without an email provider account of your own.

### Who it is from

By default every email leaves from Connect's sending address, `support@cosmoops.com`, under a display name: the `fromName` you send, or the workspace's name when you send none. Put your inbound address in `replyTo` so the customer's answer reaches you.

With the `email:send_as` scope you may name `fromAddress` instead - on your application's hosted domain (set by CosmoOps in Console), or on one of the workspace's verified sending domains. Anything else is refused with `sending_domain_unverified` and nothing is sent: Connect never quietly swaps the From, so the fallback is yours to choose. A domain verified in the last 7 days sends at most 500 emails a day while it warms up. The response's `from` names the address that was used.

### Threads

Mail clients thread on `In-Reply-To` and `References` and nothing else. Every send answers the `messageId` the email actually left under - Connect's mail provider replaces any Message-ID you propose, so store the one in the response, not your own. Reply with `headers.inReplyTo` set to the message you are answering and `headers.references` to the thread so far.

### Addresses that cannot receive

Connect keeps one suppression list for the whole platform: an address that hard-bounced or marked mail as spam is never sent to again until an operator clears it. Your mail shares that list and that reputation. A suppressed recipient is skipped, the others are sent, and the skipped ones come back in `suppressed` - show the address as undeliverable rather than retrying, because a retry is skipped the same way.

### Budgets

Per key, and per key and recipient - a ticket thread is a few replies an hour. Send an `Idempotency-Key` with every call: a retried send without one is a second copy in somebody's inbox.


## Endpoints

### POST `/api/v1/application/email/send`

Send an email on a workspace's behalf

**Scope** `email:send` &nbsp;·&nbsp; **Rate limit** 120 requests / min per key, 30 requests / hour per key+recipient

Sends your application's own email - a support reply, a portal sign-in code - for a workspace that subscribes to your application. Server-to-server only: a request carrying a member's session token is refused.

#### Implementation

**1. Prerequisite** - an API key scoped to `email:send`.

**2. Send** - `html` and `text` are both required (256 KB each); `to` and `cc` take up to 10 addresses each:
```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/email/send \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: ticket-1042-reply-3" \
  -d '{"workspaceId":"WORKSPACE_ID","to":["priya@example.com"],"subject":"Re: Refund for order 1042","html":"<p>Done - it is on its way.</p>","text":"Done - it is on its way.","replyTo":"t-1042@in.example.com","fromName":"Acme Support","headers":{"inReplyTo":"<CAF0x9@mail.gmail.com>","references":"<CAF0x9@mail.gmail.com>"}}'
```

**3. The sender.** Without `fromAddress`, the email leaves from Connect's address (`support@cosmoops.com`) with `fromName` as the display name - the workspace's name when you send none. Put your own inbound address in `replyTo` to receive the answer.

With the `email:send_as` scope, `fromAddress` names the sender: any address on your application's hosted domain, or one on the workspace's verified sending domains (`/email/domains`). An unverified domain is refused with `sending_domain_unverified` and nothing is sent - re-send without `fromAddress` if you want Connect's address instead. The response's `from` is the address actually used.

**4. Threading.** Store the response's `messageId`: it is the Message-ID the email actually left under (Connect's mail provider replaces any `headers.messageId` you send), and a customer's reply names it in `In-Reply-To`. Send the next reply with `headers.inReplyTo` and `headers.references` set from the thread. Header values may not contain line breaks.

**5. Suppression.** An address that hard-bounced or marked Connect's mail as spam is skipped and listed in `suppressed`; the rest are sent. When every recipient is suppressed nothing is sent and the call still answers `201` - show the customer's address as undeliverable rather than retrying.

#### Errors
`validation_error` (400 - bad body, too many recipients, an oversize body, or a line break in a header value), `forbidden` (403 - key missing `email:send`, a session token was forwarded, or your application is not installed in that workspace), `from_address_not_allowed` (403 - `fromAddress` without the `email:send_as` scope), `idempotency_key_reused`/`request_in_progress` (409), `sending_domain_unverified` (409 - `fromAddress` is not on your hosted domain or a verified sending domain of this workspace; nothing was sent), `rate_limited` (429 - per key, and per key and recipient), `domain_warming_up` (429 - a domain verified in the last 7 days has sent its 500 for today; honour `Retry-After`), `delivery_failed` (502 - retry).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `to` | `array` | yes |  |
| `cc` | `array` | no |  |
| `subject` | `string` | yes |  |
| `html` | `string` | yes |  |
| `text` | `string` | yes |  |
| `replyTo` | `email` | no |  |
| `fromAddress` | `email` | no |  |
| `fromName` | `string` | no |  |
| `headers` | `object` | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `201` | Sent (or every recipient suppressed - see `suppressed`) |
| `400` | validation_error - bad body, recipient cap, oversize body, or a line break in a header |
| `403` | forbidden - key missing email:send, a forwarded session, or not installed in that workspace; from_address_not_allowed - fromAddress without email:send_as |
| `409` | idempotency_key_reused / request_in_progress; sending_domain_unverified - fromAddress is on no verified sending domain, nothing sent |
| `429` | rate_limited; domain_warming_up - a newly verified domain's daily cap |
| `502` | delivery_failed - the mail provider refused the message; retry |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `messageId` | `string` | yes |  |
| `suppressed` | `array` | yes |  |
| `from` | `string` | yes |  |

### GET `/api/v1/application/email/domains`

List a workspace's sending domains

**Scope** `email:domains` &nbsp;·&nbsp; **Rate limit** 60 requests / min per key

Every domain the workspace has registered for sending, each with the DNS records it must publish and whether each one is in place as of the last check. A sending domain belongs to the workspace, not to your application - every application with `email:domains` sees the same list. Server-to-server only.

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | query | yes | A workspace subscribed to your application. |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The workspace's sending domains |
| `400` | validation_error - workspaceId missing |
| `403` | forbidden - key missing email:domains, a forwarded session, or not installed in that workspace |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `domains` | `array` | yes |  |

### POST `/api/v1/application/email/domains`

Add a sending domain

**Scope** `email:domains` &nbsp;·&nbsp; **Rate limit** 60 requests / min per key

Registers the workspace's own domain (`acme.com`) so applications may send From addresses on it, and answers the records to publish. Idempotent: adding a domain the workspace already holds answers the existing one, with its unchanged token.

#### Records
| Purpose | Name | Type | Required |
|---|---|---|---|
| `ownership` | `_cosmoops.<domain>` | TXT `cosmoops-verification=<token>` | yes |
| `dkim` ×3 | `<token>._domainkey.<domain>` | CNAME `<token>.dkim.amazonses.com` | yes |
| `mail_from_mx` | `bounce.<domain>` | MX 10 `feedback-smtp.<region>.amazonses.com` | recommended |
| `mail_from_spf` | `bounce.<domain>` | TXT `v=spf1 include:amazonses.com ~all` | recommended |
| `dmarc` | `_dmarc.<domain>` | TXT `v=DMARC1; p=none;` | only listed when the domain has none |

The domain is `verified` once the ownership TXT and DKIM both pass. Connect re-checks pending domains every ten minutes for 72 hours and verified ones hourly; a verified domain whose records break is `failing` (it still sends) and, after 72 hours, `failed`. The workspace's admins are emailed on each change.

#### Errors
`validation_error` (400 - not a domain name), `forbidden` (403), `domain_claimed` (409 - another workspace has verified it), `conflict` (409 - the workspace holds the maximum number of domains), `domain_not_allowed` (422 - a free-mail provider, a public suffix, or a CosmoOps domain).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `domain` | `string` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `201` | The domain, pending, with the records to publish |
| `400` | validation_error - not a domain name |
| `403` | forbidden - key missing email:domains, a forwarded session, or not installed in that workspace |
| `409` | domain_claimed - verified by another workspace; conflict - domain limit reached |
| `422` | domain_not_allowed - free mail, a public suffix, or a CosmoOps domain |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `domain` | `object` | yes |  |

### GET `/api/v1/application/email/domains/{domain}`

Get a sending domain

**Scope** `email:domains` &nbsp;·&nbsp; **Rate limit** 60 requests / min per key

One domain as of its last check - no DNS lookup is made. Use `POST /email/domains/{domain}/check` to re-check now.

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `domain` | path | yes | The domain, e.g. `acme.com`. |
| `workspaceId` | query | yes | A workspace subscribed to your application. |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The domain |
| `403` | forbidden |
| `404` | not_found - the workspace holds no such domain |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `domain` | `object` | yes |  |

### POST `/api/v1/application/email/domains/{domain}/check`

Re-check a sending domain

**Scope** `email:domains` &nbsp;·&nbsp; **Rate limit** 60 requests / min per key

Checks SES and DNS now and answers the updated domain, with a status on every record (`ok`, `missing`, `mismatch`, `unknown`). Connect also does this on its own, so there is no need to poll.

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `domain` | path | yes | The domain, e.g. `acme.com`. |

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The domain, freshly checked |
| `403` | forbidden |
| `404` | not_found - the workspace holds no such domain |
| `503` | unavailable - SES could not be reached; retry |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `domain` | `object` | yes |  |

### DELETE `/api/v1/application/email/domains/{domain}`

Remove a sending domain

**Scope** `email:domains` &nbsp;·&nbsp; **Rate limit** 60 requests / min per key

Removes the domain from the workspace; mail can no longer be sent From it. `204` on success.

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `domain` | path | yes | The domain, e.g. `acme.com`. |
| `workspaceId` | query | yes | A workspace subscribed to your application. |

**Responses**

| Status | Meaning |
| --- | --- |
| `204` | Removed |
| `403` | forbidden |
| `404` | not_found - the workspace holds no such domain |