# eml.dev for agents

eml.dev is a transactional email service hosted in Germany. This file tells an AI agent how to send email through it and how to set it up. Everything below is scriptable; a human is needed once, to create the account and hand you an API key.

- JSON API: `https://eml.dev/api/v1` (bearer API key)
- CLI: `eml`, a single static binary that wraps the same API. Install: `curl -fsSL https://eml.dev/install.sh | sh` (Linux and macOS; lands in `~/.local/bin`, set `EML_INSTALL_DIR` to change). Every command takes `--json`, exit codes are stable, and `eml guide` prints a guide written for coding agents.
- SMTP: `smtp.eml.dev`, port 587 with STARTTLS, username `apikey`, password = the API key. `eml doctor` checks that the port is reachable from where you run; `eml env` prints the settings.
- This file: `https://eml.dev/AGENTS.md`. Index of the docs: `https://eml.dev/llms.txt`

## Rules

1. Use a sandbox key in development, tests and CI. Sandbox mail is processed and logged, never delivered. Sandbox keys cannot create keys, delete domains, remove suppressions or manage webhooks.
2. Send only from a domain the account has verified (`dkim_status: "verified"`). Anything else is rejected with 422.
3. Never print, log or commit an API key. Read it from `EML_API_KEY`. `eml env --reveal` output belongs in an ignored `.env`, never in a repository.
4. On 402 `insufficient_credits`, stop and tell a human. Credits are prepaid; you cannot buy them yourself yet.
5. After sending, read delivery status with `GET /emails/{message_id}` or `eml send --wait`. Do not resend while a delivery is `pending`.
6. A 422 `recipient_suppressed` means the address hard-bounced before or its owner reported your mail as spam. Do not retry it. Remove a bounce suppression only once a human confirms the address works again; a complaint suppression only once the recipient has asked for mail again.
7. One key per agent. If a key leaks, revoke it with `POST /api_keys/{id}/revoke` from another key, or ask a human to do it in the dashboard.
8. No account yet: `eml signup --email you@acme.com --org "Acme" --accept-terms --json` emails a six-digit code to that address; ask the human for it, then `eml signup --code 123456` creates the account and stores a live API key in the profile. Use an address whose inbox the human reads: disposable domains and addresses that already have an account are refused (`email_taken`: run `eml login` instead). Without the CLI, `https://eml.dev/sign_up?via=agents` is the form.

## Authentication

Send `Authorization: Bearer <API key>` on every request. Keys are created in the dashboard (`https://eml.dev/api_keys`) or, with a live key, via `POST /api_keys`. Check any key before storing it: `EML_API_KEY=... eml doctor --json`, or:

```
GET /me
-> { "organization": { "id", "name", "credit_balance" },
     "api_key": { "id", "name", "sandbox", "valid_until", "revoked_at", "active", "created_at" } }
```

`credit_balance` is the number of emails the account can still send. A new account has 0; the first DKIM-verified domain adds 100, once per registrable domain (acme.com covers its subdomains) and once per account.

## Setup, once per sending domain

```
POST /domains
{ "domain": { "name": "acme.com" } }
-> 201 { "id", "name", "dkim_status", "return_path_status", "created_at",
         "dns_records": [
           { "purpose": "dkim", "type": "TXT", "host": "...", "value": "...", "required": true, "status": "pending" },
           { "purpose": "return_path", "type": "CNAME", "host": "...", "value": "bounces.eml.dev", "required": false, "status": "pending" } ] }
```

1. Publish the `dns_records`. The DKIM TXT record is required; the Return-Path CNAME is optional but improves deliverability.
2. `eml domains check acme.com` looks the records up in public DNS without changing anything, so you can see propagation. `POST /domains/acme.com/verify` (or `eml domains verify acme.com`) asks eml.dev to run both checks and returns the domain with updated statuses. DNS can take minutes to propagate; if the status stays `pending`, wait and verify again. A 503 `dns_lookup_failed` means the check itself failed; retry later.
3. `eml doctor --json` checks the key, credits, every domain, live DNS and SMTP reachability. Fix every check with `"level": "fail"`; each one carries a `fix`. Exit code 8 means something still blocks sending.
4. `GET /domains` lists domains (paginated), `GET /domains/acme.com` shows one, `DELETE /domains/acme.com` removes one.

Subdomains of a verified domain may send too: `agent@mail.acme.com` is authorized by a verified `acme.com`.

## Sending

```
POST /emails
{
  "from": "Agent <agent@acme.com>",
  "to": "sam@example.com",              // string or array
  "cc": ["ops@acme.com"],               // optional
  "subject": "Your refund is on its way",
  "body": "Hi Sam, the refund for order #1234 is on its way.",   // plain text; alias of "text_body"
  "html_body": "<p>Hi Sam, ...</p>",   // optional
  "attachments": [ { "filename": "receipt.pdf", "content_type": "application/pdf", "io": "<base64>" } ]
}
-> 202 { "message_id": "0199c1...@eml.dev", "status": "queued", "sandbox": false, "recipients": ["sam@example.com", "ops@acme.com"] }
```

- `from` must be an address on a verified domain. `subject` is 1 to 255 characters. At least one recipient.
- HTML bodies are checked before sending: no `script`, `iframe`, `object`, `embed` or form elements, no `on*` attributes, no `javascript:` or `data:` links. Inline styles and tables are fine.
- Attachments are Base64 in `io`. Executable extensions (`exe`, `bat`, `msi`, `vbs`, `scr`, ...) are rejected.
- Recipients that hard-bounced before or complained are refused up front with 422 `recipient_suppressed`; `details.addresses` names them.
- Each recipient costs one credit (€0.001).
- 202 means the mail server accepted the message. The email record appears in `GET /emails` once the server has spooled it, usually within seconds. A 404 right after sending means it is not recorded yet: wait and retry the lookup, do not resend.

CLI: `echo "Hi Sam" | eml send --from agent@acme.com --to sam@example.com --subject "Your refund" --text - --wait` blocks until every recipient is delivered or bounced (`--html FILE`, `--attach FILE`, `--cc` are also available). `eml test --to you@acme.com` sends a canned test email from your first verified domain and waits for its final status.

SMTP: connect to `smtp.eml.dev` on port 587, upgrade with STARTTLS, log in with username `apikey` and the API key as password, and send a normal message. The same domain and credit rules apply. Prefer the HTTP API where outbound SMTP may be blocked.

## Delivery status

```
GET /emails/{id or message_id}
-> { "id", "message_id", "from", "to", "cc", "subject", "sandbox", "created_at",
     "deliveries": [ { "recipient", "status": "pending" | "delivered" | "bounced", "delivered_at", "bounced_at", "last_error" } ] }

GET /emails?page=1&per_page=25[&message_id=...]
-> { "data": [ ...emails ], "meta": { "page", "per_page", "total", "total_pages" } }
```

`pending` for a long time with a `last_error` is a deferral: the receiving server is refusing temporarily and the message will be retried.

CLI: `eml emails get <message-id>`, `eml emails list`, `eml emails tail --json` (streams new emails and status changes as NDJSON).

## Suppressions

Addresses that hard-bounced (`reason` `hard_bounce` or `oob_bounce`) or whose owner reported your mail as spam (`complaint`) are blocked for this organization until removed.

```
GET    /suppressions[?address=...]   -> { "data": [ { "address", "reason", "details", "created_at" } ], "meta": {...} }
DELETE /suppressions/{address}       -> 204; live keys only. If it hard-bounces again it is re-suppressed.
```

CLI: `eml suppressions list`, `eml suppressions rm <address>`.

## Webhooks

Signed HTTPS POSTs (Standard Webhooks) for `email.delivered`, `email.deferred`, `email.bounced`, `email.complained` and `recipient.suppressed`. Prefer them to polling `/emails` for bounces.

```
GET    /webhooks                     -> { "data": [ { "id", "url", "events", "created_at" } ], "meta": {...} }
POST   /webhooks                     { "webhook": { "url": "https://...", "events": ["email.bounced"] } }
                                     -> 201 { ...endpoint, "signing_secret": "whsec_..." }; live keys only. `events` defaults to all.
DELETE /webhooks/{id}                -> 204; live keys only
```

Verify `webhook-signature` with the signing secret and dedupe on `webhook-id`: delivery is at least once. See /docs/webhooks.

## Keys

```
GET  /api_keys                       -> { "data": [ ...keys ], "meta": {...} }
POST /api_keys                       { "api_key": { "name": "support-agent", "sandbox": false, "valid_until": "2027-01-01" } }
                                     -> 201 { ...key, "key": "<plain key, shown once>" }
POST /api_keys/{id}/revoke           -> the revoked key; a key cannot revoke itself
```

`valid_until` is optional and at most a year out. Only live (non-sandbox) keys may create or revoke keys.

CLI: `eml keys create --name ci --sandbox`, `eml keys list`, `eml keys revoke <id>`.

## Wiring it into an app

- `eml env --format dotenv --reveal` prints `EML_API_KEY` and the SMTP settings (also `--format shell` or `json`; the key is masked without `--reveal`).
- `eml snippet <target>` prints integration code: `curl`, `node`, `python`, `ruby`, `go` (HTTP API) or `rails`, `nodemailer`, `django` (SMTP).
- `eml open docs` opens the human-readable docs; `eml completions <shell>` prints shell completions.

## Errors

Every error has one shape:

```
{ "error": { "code": "validation_failed", "message": "Subject can't be blank", "details": { "subject": ["can't be blank"] } } }
```

| Status | `code` | What to do |
|---|---|---|
| 400 | `bad_request` | A required parameter is missing; read `message`. |
| 401 | `unauthorized` | The key is missing, invalid, revoked or expired. Ask for a new key. |
| 402 | `insufficient_credits` | The prepaid balance is empty. Stop and tell a human. |
| 403 | `forbidden` | A sandbox key tried to manage keys or webhooks, delete a domain or remove a suppression. Use a live key. |
| 404 | `not_found` | Wrong id, Message-ID or address, or the email is not recorded yet (see Sending). |
| 422 | `validation_failed`, `domain_not_authorized`, `recipient_suppressed`, `rejected`, `cannot_revoke_self` | Fix the request; `details` names the field or the addresses. |
| 503 | `mta_unavailable`, `dns_lookup_failed` | Temporary. Retry with backoff. |

The CLI prints the same error object to stderr and exits with a stable code: 0 success, 1 other error, 2 usage, 3 authentication, 4 not found, 5 invalid request, 6 out of credits, 7 API or mail server unreachable, 8 a check failed (`doctor` found problems, or `--wait` / `test` ended bounced or timed out).

## Pricing and data

- Prepaid credits, €0.001 per email, no subscription. Minimum top-up €1.
- Sent and stored in Germany, under German and EU law. No Google, Amazon or Microsoft infrastructure. GDPR and DPA included.
- Human-readable docs: `https://eml.dev/docs`. Pricing: `https://eml.dev/pricing`.
