# Email DNS

> Plan the MX, SPF, DKIM and DMARC records for your email providers and check the mail records of a zone.

The email assistant computes the records a zone needs for the chosen mailbox and sending providers (one merged SPF, MX, DKIM, DMARC) and checks what the public DNS shows. Nothing is applied by these endpoints; apply a plan with [Batch records](https://developers.openwaf.dev/api/dns-records/#batch-records).

| Operation | Endpoint |
|---|---|
| [List email providers](https://developers.openwaf.dev/api/email/#list-email-providers) | `GET /v1/orgs/{org}/zones/{zone}/email/providers` |
| [Plan email records](https://developers.openwaf.dev/api/email/#plan-email-records) | `POST /v1/orgs/{org}/zones/{zone}/email/plan` |
| [Check email records](https://developers.openwaf.dev/api/email/#check-email) | `GET /v1/orgs/{org}/zones/{zone}/email/check` |

## List email providers

```http
GET /v1/orgs/{org}/zones/{zone}/email/providers
```

Lists the provider templates of the email assistant, mailbox (`mailbox`, e.g. Google Workspace, Microsoft 365, Zoho Mail, Proton Mail, Fastmail), sending (`sending`, e.g. Amazon SES, Resend, Mailgun, SendGrid, Brevo) and `other`, with the parameters each one asks for (defaults already computed for the zone).

**Required role:** `readonly` or higher.

#### Parameters

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `org` | path | string (uuid) | Yes | Organization id. Every route checks that it is the token's organization (otherwise `404`). |
| `zone` | path | string (uuid) | Yes | Zone id (see [List zones](https://developers.openwaf.dev/api/zones/#list-zones)). |

#### Response

- `200`: Returns `items`, an array of [EmailProvider](https://developers.openwaf.dev/api/schemas/#email-provider).

| Field | Type | Description |
|---|---|---|
| `items[].id` | string | Id. |
| `items[].name` | string | Name. |
| `items[].kind` | string | One of `mailbox`, `sending`, `other`. |
| `items[].docs` | string |  |
| `items[].spf` | array of string | Mechanisms it adds to the domain's SPF record. |
| `items[].params` | array of object |  |
| `items[].params[].key` | string |  |
| `items[].params[].kind` | string | One of `text`, `dkim`, `cname`, `select`, `list`. |
| `items[].params[].required` | boolean |  |
| `items[].params[].default` | string |  |
| `items[].params[].selector` | string | DKIM selector where the value goes (kind dkim/cname). |
| `items[].params[].options` | array of string |  |
| `items[].params[].placeholder` | string |  |

#### Errors

| Status | Description |
|---|---|
| `404` | Not found, or not visible to the token (`not_found`). |

#### Example

**curl**


```sh
curl "https://dash.openwaf.dev/v1/orgs/$OPENWAF_ORG/zones/6f1d2c3b-8a4e-4b7d-9c21-5e3f7a8b9c0d/email/providers" \
  -H "Authorization: Bearer $OPENWAF_TOKEN"
```

**TypeScript**


```ts
import { OpenWAF } from "./openwaf";

const owf = new OpenWAF({ baseUrl: "https://dash.openwaf.dev" }); // token from OPENWAF_TOKEN
const result = await owf.listEmailProviders({
  zone: "6f1d2c3b-8a4e-4b7d-9c21-5e3f7a8b9c0d"
});
```

**Python**


```py
from openwaf import OpenWAF

owf = OpenWAF(base_url="https://dash.openwaf.dev")  # token from OPENWAF_TOKEN
result = owf.list_email_providers(zone="6f1d2c3b-8a4e-4b7d-9c21-5e3f7a8b9c0d")
```



```json
{
  "items": [
    {
      "id": "example",
      "name": "example.com",
      "kind": "mailbox",
      "docs": "example",
      "spf": [
        "example"
      ],
      "params": [
        {}
      ]
    }
  ]
}
```

## Plan email records

```http
POST /v1/orgs/{org}/zones/{zone}/email/plan
```

Computes the email records the zone needs for the chosen providers, compared with its current records (the public DNS for an external zone): one SPF record merging the providers' mechanisms with the existing ones (keeping the existing `~all`/`-all`), the MX records of the mailbox provider, the DKIM keys pasted by the user (validated), verification records, sending subdomains and DMARC (`p=none` by default). It also counts the DNS lookups of the resulting SPF record (more than 10 is an error).

Nothing is applied. Problems are listed in `issues` (errors leave `batch` null). Send `batch` as is to [Batch records](https://developers.openwaf.dev/api/dns-records/#batch-records), first with `dry_run=1`. In external zones `applicable` is false and the actions are `manual`.

**Required role:** `readonly` or higher.

#### Parameters

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `org` | path | string (uuid) | Yes | Organization id. Every route checks that it is the token's organization (otherwise `404`). |
| `zone` | path | string (uuid) | Yes | Zone id (see [List zones](https://developers.openwaf.dev/api/zones/#list-zones)). |

#### Request body

Content type `application/json`. Schema: [EmailPlanInput](https://developers.openwaf.dev/api/schemas/#email-plan-input).

| Field | Type | Required | Description |
|---|---|---|---|
| `providers` | array of object | Yes |  |
| `providers[].id` | string | Yes | Id. |
| `providers[].params` | map of string | No |  |
| `dmarc` | object or null | No | Null keeps the existing DMARC record (or adds p=none if there is none). |
| `dmarc.policy` | string | No | One of `none`, `quarantine`, `reject`. |
| `dmarc.rua` | string | No | Address for aggregate reports: absent keeps the one of the existing record, empty removes it. |
| `spf_all` | string | No | One of `""`, `~all`, `-all`. |

#### Response

- `200`: Returns [EmailPlan](https://developers.openwaf.dev/api/schemas/#email-plan).

| Field | Type | Description |
|---|---|---|
| `zone` | string |  |
| `dns_mode` | string | One of `delegated`, `external`. |
| `applicable` | boolean |  |
| `records` | array of object |  |
| `records[].type` | string |  |
| `records[].name` | string | Relative to the zone (@ = the zone). |
| `records[].fqdn` | string |  |
| `records[].content` | string |  |
| `records[].priority` | integer | MX and SRV priority (null for other types). |
| `records[].purpose` | string | One of `mx`, `spf`, `dkim`, `dmarc`, `verification`, `autoconfig`, `return_path`, `tracking`. |
| `records[].provider` | string |  |
| `records[].action` | string | One of `create`, `update`, `keep`, `manual`. |
| `records[].record_id` | string |  |
| `records[].current` | string |  |
| `delete` | array of object |  |
| `delete[].record_id` | string |  |
| `delete[].type` | string |  |
| `delete[].name` | string | Name. |
| `delete[].content` | string |  |
| `delete[].priority` | integer | MX and SRV priority (null for other types). |
| `delete[].reason` | string | Reason of the change. One of `replaced_mx`, `duplicate_spf`, `replaced_dmarc`, `replaced_record`. |
| `batch` | object or null | Body for POST …/records/batch. |
| `issues` | array of object |  |
| `issues[].severity` | string | One of `error`, `warning`. |
| `issues[].code` | string |  |
| `issues[].field` | string |  |
| `issues[].message` | string |  |
| `issues[].params` | map of string |  |
| `spf` | object or null |  |
| `spf.value` | string |  |
| `spf.lookups` | integer | -1 if they were not counted. |
| `spf.error` | string |  |
| `dmarc` | string |  |

#### Errors

| Status | Description |
|---|---|
| `400` | Malformed request (`bad_request`). |
| `404` | Not found, or not visible to the token (`not_found`). |
| `502` | `dns_unavailable`: the public DNS did not answer (external zone). |
| `503` | The check is not available right now (external zone), or `busy`: too many checks in progress. |

#### Example

**curl**


```sh
curl -X POST "https://dash.openwaf.dev/v1/orgs/$OPENWAF_ORG/zones/6f1d2c3b-8a4e-4b7d-9c21-5e3f7a8b9c0d/email/plan" \
  -H "Authorization: Bearer $OPENWAF_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "providers": [
      {
        "id": "google"
      }
    ],
    "dmarc": {
      "policy": "none"
    }
  }'
```

**TypeScript**


```ts
import { OpenWAF } from "./openwaf";

const owf = new OpenWAF({ baseUrl: "https://dash.openwaf.dev" }); // token from OPENWAF_TOKEN
const result = await owf.planEmailRecords({
  zone: "6f1d2c3b-8a4e-4b7d-9c21-5e3f7a8b9c0d",
  body: {
    providers: [
      {
        id: "google"
      }
    ],
    dmarc: {
      policy: "none"
    }
  }
});
```

**Python**


```py
from openwaf import OpenWAF

owf = OpenWAF(base_url="https://dash.openwaf.dev")  # token from OPENWAF_TOKEN
result = owf.plan_email_records(
    zone="6f1d2c3b-8a4e-4b7d-9c21-5e3f7a8b9c0d",
    body={
        "providers": [
            {
                "id": "google"
            }
        ],
        "dmarc": {
            "policy": "none"
        }
    },
)
```



```json
{
  "zone": "example.com",
  "dns_mode": "delegated",
  "applicable": false,
  "records": [
    {
      "type": "A",
      "name": "example.com",
      "fqdn": "example",
      "content": "203.0.113.10",
      "priority": 0,
      "purpose": "mx",
      "provider": "example",
      "action": "block",
      "record_id": "aa747ff3-b956-4f78-aa3e-6502aeccf60a",
      "current": "example"
    }
  ],
  "delete": [
    {
      "record_id": "aa747ff3-b956-4f78-aa3e-6502aeccf60a",
      "type": "A",
      "name": "example.com",
      "content": "203.0.113.10",
      "priority": 0,
      "reason": "Point www at the new origin"
    }
  ],
  "batch": {},
  "issues": [
    {
      "severity": "error",
      "code": "example",
      "field": "example",
      "message": "",
      "params": {
        "key": "example"
      }
    }
  ],
  "spf": {
    "value": "4f1c2e9a7b3d",
    "lookups": 0,
    "error": ""
  },
  "dmarc": "example"
}
```

## Check email records

```http
GET /v1/orgs/{org}/zones/{zone}/email/check
```

Checks the mail records of the zone on public resolvers, MX present and consistent, a single SPF record and its DNS lookups (at most 10 including nested includes and redirects), DKIM keys, DMARC, proxied mail names, CNAME conflicts and, in delegated zones, whether the world sees the zone's records. Each check is `ok`, `warn`, `fail` or `info` with a stable `code` and its `params`. The result is cached for 60 seconds.

**Required role:** `readonly` or higher. `refresh=true` needs `tech`.

#### Parameters

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `org` | path | string (uuid) | Yes | Organization id. Every route checks that it is the token's organization (otherwise `404`). |
| `zone` | path | string (uuid) | Yes | Zone id (see [List zones](https://developers.openwaf.dev/api/zones/#list-zones)). |
| `selectors` | query | string | No | Additional comma-separated DKIM selectors (at most 10). |
| `refresh` | query | boolean | No | Check again without using the cached result (tech role). |

#### Response

- `200`: Returns [EmailReport](https://developers.openwaf.dev/api/schemas/#email-report).

| Field | Type | Description |
|---|---|---|
| `zone` | string |  |
| `dns_mode` | string | One of `delegated`, `external`. |
| `checked_at` | string (date-time) | Last check. |
| `summary` | object |  |
| `summary.status` | string | One of `ok`, `warn`, `fail`. |
| `summary.ok` | integer |  |
| `summary.warn` | integer |  |
| `summary.fail` | integer |  |
| `summary.info` | integer |  |
| `providers` | array of string |  |
| `checks` | array of object |  |
| `checks[].id` | string | Id. One of `mx`, `spf_record`, `spf_lookups`, `spf_policy`, `dkim`, `dmarc`, `proxied`, `conflicts`, `world`. |
| `checks[].status` | string | One of `ok`, `warn`, `fail`, `info`. |
| `checks[].code` | string |  |
| `checks[].subject` | string |  |
| `checks[].message` | string |  |
| `checks[].params` | map of string |  |
| `public` | object |  |
| `public.mx` | array of string |  |
| `public.spf` | array of string |  |
| `public.dmarc` | array of string |  |
| `public.dkim` | array of object |  |
| `spf` | object or null |  |
| `spf.lookups` | integer |  |
| `spf.void_lookups` | integer |  |
| `spf.truncated` | boolean | The count stopped at 30 lookups. |
| `spf.all` | string |  |
| `spf.tree` | array of object |  |
| `spf.problems` | array of object |  |

#### Errors

| Status | Description |
|---|---|
| `403` | `refresh=1` needs the tech role. |
| `404` | Not found, or not visible to the token (`not_found`). |
| `503` | The check is not available right now, or `busy`: too many checks in progress. |

#### Example

**curl**


```sh
curl "https://dash.openwaf.dev/v1/orgs/$OPENWAF_ORG/zones/6f1d2c3b-8a4e-4b7d-9c21-5e3f7a8b9c0d/email/check" \
  -H "Authorization: Bearer $OPENWAF_TOKEN"
```

**TypeScript**


```ts
import { OpenWAF } from "./openwaf";

const owf = new OpenWAF({ baseUrl: "https://dash.openwaf.dev" }); // token from OPENWAF_TOKEN
const result = await owf.checkEmail({
  zone: "6f1d2c3b-8a4e-4b7d-9c21-5e3f7a8b9c0d"
});
```

**Python**


```py
from openwaf import OpenWAF

owf = OpenWAF(base_url="https://dash.openwaf.dev")  # token from OPENWAF_TOKEN
result = owf.check_email(zone="6f1d2c3b-8a4e-4b7d-9c21-5e3f7a8b9c0d")
```



```json
{
  "zone": "example.com",
  "dns_mode": "delegated",
  "checked_at": "2026-10-27T09:30:00Z",
  "summary": {
    "status": "ok",
    "ok": 0,
    "warn": 0,
    "fail": 0,
    "info": 0
  },
  "providers": [
    "example"
  ],
  "checks": [
    {
      "id": "mx",
      "status": "ok",
      "code": "example",
      "subject": "example",
      "message": "",
      "params": {
        "key": "example"
      }
    }
  ],
  "public": {
    "mx": [
      "example"
    ],
    "spf": [
      "example"
    ],
    "dmarc": [
      "example"
    ],
    "dkim": [
      {
        "selector": "google",
        "name": "example.com",
        "value": "4f1c2e9a7b3d",
        "cname": "example"
      }
    ]
  },
  "spf": {
    "lookups": 0,
    "void_lookups": 0,
    "truncated": false,
    "all": "example",
    "tree": [
      {}
    ],
    "problems": []
  }
}
```
