# Sovereign Mail (Sovmail) — integration reference

> Sovmail (Sovereign Mail) is a UK-sovereign transactional email API. This document is the complete, authoritative
> contract for integrating it. It is written for AI coding assistants and the
> developers using them. Everything below reflects production as served from
> https://sovmail.co.uk.

## Before any code works: account setup

These steps happen in the web dashboard (https://sovmail.co.uk), not through the API. An
integration cannot complete them, so tell the developer which are outstanding.

1. Create an account at https://sovmail.co.uk/register and verify the account's email address.
2. Add a sending domain (Domains page). The dashboard lists the DNS records to publish
   for it — three are required (SPF, DKIM, DMARC) and three recommended.
3. Publish the records at the domain's DNS provider, then click "Check DNS records".
   The domain becomes "verified" once the required records resolve.
4. Create an API key (API keys page). A test key (`smk_test_…`) works at once, even
   before the domain verifies. A live key (`smk_live_…`) sends real mail once the
   domain is verified AND the account has passed manual review (new accounts are
   reviewed by a person, usually within one working day).

So an integration can be built and fully tested with a test key while DNS and review
are still pending, and moved to a live key later with no code change.

## If you were given only an API key

That is enough. Call the account endpoint with it before writing any code:

```bash
curl -s https://sovmail.co.uk/api/v1/account -H "Authorization: Bearer $SOVMAIL_API_KEY"
```

It returns the key type, the account's verified sending domains, a
`suggestedFrom` address that will be accepted, and a `blockers` list — each with a
`fix` a human must carry out in the dashboard. Use `suggestedFrom` (made
configurable) as the from-address, build the integration, and pass any blockers on to
the developer verbatim. A test key (`smk_test_…`) can send immediately whatever the
blockers say; nothing it sends is delivered.

Machine-readable spec: https://sovmail.co.uk/openapi.json (OpenAPI 3.1).

## Instructions for AI coding assistants

The block below is the same prompt the dashboard hands to customers. Follow it.

```text
Integrate Sovereign Mail (a UK transactional email API) into this codebase.

Use this contract exactly as written — do not guess endpoints, fields or responses:

  POST https://sovmail.co.uk/api/v1/email
  Authorization: Bearer <key>        # read from the SOVMAIL_API_KEY env var
  Content-Type: application/json
  Body: {
    "from":    "noreply@your-verified-domain.co.uk",   # a bare address — no "Name <addr>" display name
    "to":      "someone@example.com",   # exactly ONE address, not a list
    "subject": "...",
    "text":    "...",       # text and/or html; at least one is required
    "html":    "..."
  }
  Success: HTTP 202 with { "id", "status" }
           status is "queued" | "test" | "suppressed" — all three are success.
           "queued" means accepted for delivery; it is what a live send returns.

  Templates (optional): instead of subject/text/html, send
    "templateId": "<uuid>", "variables": { "name": "Ada" }   # values are strings
  using a template saved in the Sovereign Mail dashboard.

  Batch: POST https://sovmail.co.uk/api/v1/email/batch
         { "messages": [ { ...same fields as above... } ] }    # 1 to 500
  Returns HTTP 202 with { "results": [ { "to", "status", "id"?, "error"? } ] },
  one entry per message in the same order. The 202 does NOT mean every message was
  accepted: any entry can have status "error" with a reason. Check each one.

  Account check: GET https://sovmail.co.uk/api/v1/account  (same Authorization header)
  Returns { "key": { "mode" }, "sendingDomains": { "verified", "pending" },
            "suggestedFrom", "canSendLive", "blockers": [ { "code", "message", "fix" } ],
            "usage" }

  Errors are JSON { "error": "<reason>", "docs": "<url>" }:
    400 invalid body (with an "issues" array) · 401 missing or invalid key
    402 monthly quota exceeded · 403 from-domain not verified, account email not
    verified, account pending review or suspended · 404 templateId not found
    422 content rejected · 429 daily limit or per-minute rate limit reached

Keys: live keys start smk_live_ and deliver. Test keys start smk_test_ and are a
sandbox — fully validated and recorded, never delivered, not counted against quota
(status "test"). Use a test key in development, CI and automated tests; only
production should hold a live key.

Rules that matter:
- If you can run commands and have the key, call GET /account first (e.g. curl) and
  use it: suggestedFrom is a valid from-address, and blockers lists anything that
  stops live mail. Blockers need a human in the dashboard — report them to me with
  their "fix" text rather than trying to work around them.
- "from" MUST be on a verified sending domain. For this account use noreply@your-verified-domain.co.uk.
  A from-address on any other domain returns 403. Make it configurable (for
  example a SOVMAIL_FROM env var) rather than hardcoding it.
- Treat 202 as success and store the returned id — it is how sends are traced later.
- "suppressed" is a SUCCESSFUL response, not an error: the recipient previously
  hard-bounced or complained and is deliberately not mailed again. Do not retry it
  and do not treat it as a failure.
- Retry only on 429 and 5xx, with exponential backoff. Never retry other 4xx.
- For batch, retry only the entries that failed, never the whole batch — the
  others were already accepted and would be sent twice.
- Never hardcode the API key. Never log it.
- Do NOT add open or click tracking, and do not rewrite links. This service
  deliberately does not do it, and rewriting magic links breaks them.

Please:
1. Add a small mail client module written in the idiomatic style of this codebase.
2. Route existing transactional email (sign-up verification, password reset,
   receipts, alerts) through it.
3. Add a unit test that mocks the HTTP call and asserts the request shape.
4. Show me a diff before changing anything.
```

## API reference

Base URL `https://sovmail.co.uk/api/v1`. All requests and responses are JSON. Authenticate every request
with `Authorization: Bearer <api key>`.

### GET /account — what this key can do

No body. Response `200`:

```json
{
  "key": { "mode": "live", "note": "Live key: mail is delivered." },
  "sendingDomains": { "verified": ["mail.acme.co.uk"], "pending": [] },
  "suggestedFrom": "noreply@mail.acme.co.uk",
  "canSendTest": true,
  "canSendLive": false,
  "blockers": [
    { "code": "account_pending_review",
      "message": "New accounts are reviewed by a person before they can send live mail …",
      "fix": "Nothing to do but wait. Test keys work in the meantime." }
  ],
  "usage": { "plan": "starter", "limit": 10000, "used": 12, "remaining": 9988 },
  "docs": "https://sovmail.co.uk/llms-full.txt"
}
```

Blocker codes: `account_suspended`, `account_rejected`, `account_pending_review`,
`email_not_verified`, `no_verified_domain`, `quota_exhausted`. Blockers apply to
live sending only. `GET /api/v1` (no key) returns links to these docs.

### POST /email — send one message

Request:

```json
{
  "from": "noreply@your-verified-domain.co.uk",
  "to": "recipient@example.com",
  "subject": "Reset your password",
  "text": "Plain-text body",
  "html": "<p>HTML body</p>",
  "track": false
}
```

- `from` — required. A bare email address on a verified domain. No display name.
- `to` — required. Exactly one address.
- `subject` — required unless `templateId` is given.
- `text` / `html` — at least one, unless `templateId` is given.
- `templateId` + `variables` — send a stored template instead of subject/body.
  `variables` is an object of string values.
- `track` — optional. `true` records delivery confirmation (the receiving server
  accepted it) for this message. Never open or click tracking.

Response `202`:

```json
{ "id": "0b6e1c3a-…", "status": "queued" }
```

`status` is `queued` (accepted for delivery — what every successful live send returns), `test` (test key or a test
recipient domain — recorded, not delivered) or `suppressed` (recipient previously
bounced or complained — deliberately not sent; this is not an error).

### POST /email/batch — send up to 500

Request: `{ "messages": [ { …same fields as /email… } ] }` (1–500 entries).

Response `202`:

```json
{ "results": [
  { "to": "a@example.com", "status": "queued", "id": "…" },
  { "to": "b@example.com", "status": "error", "error": "from-domain example.net is not verified" }
] }
```

One result per message, in request order. **A 202 does not mean every message was
accepted** — check each entry's `status`. Retry only the entries with `"error"`;
retrying the whole batch sends the others twice. If the request body itself is invalid,
the whole batch fails with 400 and nothing is sent.

### GET /messages — list recent messages

Query: `limit` (1–500), `status`, `q` (search), `track` (`true`/`false`).

Response `200`: `{ "messages": [ { "id", "to", "from", "subject", "status", "track",
"createdAt", "sentAt" } ] }`

Message status is one of `queued`, `sending`, `sent`, `delivered` (only with
tracking), `bounced`, `failed`, `suppressed`, `test`.

### GET /templates, POST /templates

`GET` returns `{ "templates": [ { "id", "name", "subject" } ] }`.

`POST` body: `{ "name", "subject", "html"?, "text"?, "isLayout"?, "layoutId"? }` (html
or text required) → `201 { "id", "name" }`. Templates use `{{variable}}`,
`{{#if key}}…{{/if}}` and `{{#unless key}}…{{/unless}}`; values are HTML-escaped in
the html part.

### GET /usage

Response `200`: `{ "plan", "limit", "used", "remaining" }` for the current calendar
month.

### PUT /webhook — receive events

Body: `{ "url": "https://your-app.example/webhooks/sovmail" }` → `200 { "url",
"secret" }`. Store the secret; it signs every event. Private, loopback and cloud
metadata addresses are refused.

Events are POSTed as JSON:

```json
{ "type": "message.sent", "data": { "messageId", "to", "from", "subject" } }
```

- `type` is `message.sent`, or `message.delivered` for messages sent with tracking.
- Header `x-sovmail-signature: sha256=<hex>` is the HMAC-SHA256 of the raw request
  body with your secret. Verify it with a constant-time comparison before trusting the
  event.
- Delivery is best-effort: one attempt, 3-second timeout, no retries, redirects not
  followed. Do not build logic that depends on receiving every event — use
  `GET /messages` to reconcile.
- Bounces and complaints are not sent as webhook events. They are applied
  automatically: the address is suppressed, and later sends to it return
  `"suppressed"`.

## Errors

Every error is JSON `{ "error": "<reason>", "docs": "<link to this file>" }`; a 400
from validation also carries `"issues"`.

| Status | Meaning | Retry? |
|---|---|---|
| 400 | Invalid request body | No — fix the request |
| 401 | Missing or invalid API key | No |
| 402 | Monthly quota exceeded | No |
| 403 | From-domain not registered or verified, account email not verified, account pending review, rejected or suspended | No |
| 404 | `templateId` not found | No |
| 422 | Content rejected by checks | No |
| 429 | Daily limit or per-minute rate limit reached | Yes, with backoff |
| 5xx | Server error | Yes, with backoff |

## Limits

- Monthly quota set by the plan (`GET /usage`).
- A daily cap derived from the monthly quota, and a per-minute rate cap. Both answer 429.
- New accounts start on conservative limits while their sending history builds; contact
  support@sovmail.co.uk to discuss raising them.

## What this service deliberately does not do

- No open or click tracking, no link rewriting, no tracking pixels.
- No bulk or marketing email. Transactional only.
- No SMTP submission for customers today — integrate over the HTTPS API.
