Skip to the documentation
CLI

Domains and addresses

Add and verify domains, read the DNS records they need, manage the addresses on them, and check what you can send as.

Overview

Two namespaces cover your domains. openemail domains manages the domains attached to the workspace: adding and removing them, the DNS records each one needs, whether it can receive and send, its catch-all, its tracking and files domains, and the addresses on it. openemail addresses answers a narrower question: which addresses the key or sign-in you are using may send as.

  • A domain command takes the domain id, a UUID from domains list or domains create. The hostname is not accepted in its place, so openemail domains get acme.com is a 404 and exits with code 5.
  • An address command takes the domain id and then the address id, a UUID from domains list-addresses or domains create-address.
  • domain and address work as namespace names too. The domain verbs answer to the usual aliases, such as ls, show, new, edit and rm, and so does addresses list. The five verbs for the addresses on a domain, such as create-address, have none.
  • openemail <command> --help lists every argument and flag with its type, the scope the call needs, its method and path, and what comes back. Add --json for the same page as data.

Every command

CommandWhat it does
openemail domains listList the workspace’s domains, alphabetically, with their receiving, sending, tracking and files state
openemail domains get <id>Read one domain with its addresses, every DNS record it uses and whether each was found, and its DMARC reading
openemail domains create --domain <value>Add a domain. The answer holds every DNS record to publish, already checked once
openemail domains verify <id>Check the domain’s DNS straight away and return the domain as the check left it
openemail domains update <id>Turn the catch-all on or off, and set or remove the tracking domain and the files domain
openemail domains delete <id>Remove the domain and every address on it. Asks you to confirm
openemail domains list-addresses <id>List the addresses on a domain with their ids, labels, enabled state and when each last received mail
openemail domains create-address <id> --local-part <value>Create an address on the domain, enabled, with an optional --label
openemail domains get-address <id> <address-id>Read one address on a domain
openemail domains update-address <id> <address-id>Rename an address with --label, or turn it off and on with --no-enabled and --enabled
openemail domains delete-address <id> <address-id>Remove an address from its domain. Asks you to confirm
openemail addresses listList the addresses you may send as with this key or sign-in, and each domain’s receiving and sending state

Every flag is in the help of its command, for example openemail domains update --help or openemail domains create-address --help.

Receiving and sending

A domain reports two independent facts. receiving.verified is true once public DNS answers with its MX records and its _openemail-challenge TXT record, and from then on it receives mail. sending.status is the signing state as the last check saw it: verified, pending, failed, no_identity or unknown. sending.canSend says whether a send from the domain would be accepted right now, and a negative verdict older than a day counts as unknown, so a script should branch on canSend rather than on status. While it is false, a send from the domain is refused with 409 domain_not_sendable.

  • domains create runs the first DNS check during the call, so each entry in records already carries a status: found, missing, or null when it has not been checked yet. Publish every record exactly as given, since the values are specific to the domain.
  • domains verify checks straight away. Within 10 seconds of the last check it checks nothing new and returns the domain as it stands. On a verified domain it checks the signing records again, so sending is fresh.
  • domains get on an unverified domain checks again when the last check is more than 20 seconds old, so polling get also works and needs only domains:read, where verify needs domains:write.
  • A record published a moment ago can take a few minutes to show up in public DNS.

On a terminal, get, create and verify print one field per line, with nested blocks such as receiving, sending and records as compact JSON. Add --json and read them with a tool such as jq, as the examples below do.

Catch-all, tracking and files domains

domains update changes three settings that do not depend on each other. A flag you leave out is left alone, and with no flags at all the domain comes back unchanged.

FlagWhat it changes
--catch-all, --no-catch-allOn accepts mail to any address on the domain that nobody created, and the address shows up in list-addresses from its first message. Off refuses mail to every address not created by hand, including the ones the catch-all picked up before. A new domain starts with it on
--tracking-host <value>A subdomain such as links.acme.com for tracked links and the open pixel. null removes it
--storage-host <value>A subdomain such as files.acme.com for the download links of files sent from the domain. null removes it
  • A new host is saved and checked in the same call. Publish a CNAME record named record.name with the value record.value from the tracking or storage block of the answer, with any proxying turned off. Setting a host up again can give it a different value, so publish the one the latest answer reports.
  • Until a check passes, the host reads pending and new mail keeps the default OpenEmail host. Once one passes it reads active. OpenEmail keeps checking on its own, and an active host that fails three checks in a row, or whose last passing check is 2 hours old, reads failed while new mail goes back to the default host.
  • Remove a host with null, as in --tracking-host null. An empty value such as --tracking-host= is a usage error in the CLI and exits with code 2.
  • A new host needs the domain verified, or at least its _openemail-challenge TXT record published. Otherwise the call is refused with 409 domain_not_verified.
  • The flags apply in order: the catch-all, then the tracking domain, then the files domain. A later flag that is refused can leave an earlier change saved, so send them in separate calls when each has to stand on its own.

Addresses on a domain

A domain holds the addresses created by hand or through the API, and the ones its catch-all picked up when mail first arrived for them. list-addresses shows both kinds, disabled ones included. The catch-all itself is not a row: it is receiving.catchAll on the domain.

  • create-address takes --local-part, the part in front of the @, and an optional --label. The domain does not have to be verified yet, but the address receives nothing until it is. * on its own is refused, since that is how the catch-all is written.
  • Creating an address that already exists, or one that was removed, is not an error. It comes back enabled, with the label you sent or none, and keeps its id. An address the catch-all picked up becomes one created by hand, so it keeps receiving after the catch-all is turned off.
  • With the catch-all on, a new address starts with the per-address settings of the catch-all, such as its signature and tracking, apart from the privacy settings. They are copied once and not kept in step.
  • update-address --no-enabled stops the address taking mail, so senders get a bounce, and nothing can be sent from it. It keeps its mail, its settings and the people who can reach it, and --enabled picks up where it left off. --label renames it, and --label null removes the name.
  • delete-address goes further. Mail to the address is refused even with the catch-all on, its forwarding stops, its settings are deleted, the people given access to it lose that access, and its password sign-in is revoked. Mail it already received stays in the mailbox. Creating it again brings back the same id, without the old settings or access.

What you can send as

openemail addresses list answers the question behind a 403 from_address_forbidden: which addresses the key or sign-in you are calling with may put in From. It needs emails:send rather than a read scope, because it describes what a send would accept.

  • On a terminal it prints two tables: the addresses, each with whether it is enabled and whether you can send from it, then the domains, each with whether it is verified for receiving and for sending, and its catch-all.
  • unrestricted is true when the credential is narrowed by nothing. Any local-part on the workspace’s domains can then be sent from, including ones nobody created. Otherwise canSend is true only for an enabled address the credential covers, through a whole domain it holds or its own list of addresses.
  • canSend is false for a disabled address, for one the credential does not cover, and for one whose domain cannot sign yet.
  • Only created addresses are listed. A credential that holds a whole domain can still send as any local-part on it, and an address on its list with no mailbox behind it can be sent from without showing up here.
  • With --json it prints { unrestricted, addresses, domains, hasMore, nextCursor } for one page, and { unrestricted, addresses, domains } with --all, rather than the { items, hasMore, nextCursor } document other lists print. With --all in a pipe, or with --ndjson, it prints one address per line.

status, open and DNS providers

openemail status reads your sign-in, addresses list and domains list at the same time and prints them together. Its Sender addresses table shows each address with whether it can send and whether it is enabled. Its Domains table shows each domain as verified or not verified for receiving, its sending status, and its catch-all. It shows the first 100 of each and names the --all command for the rest.

  • A part your credential may not read, such as the domains without domains:read or the addresses without emails:send, says Not available with the reason, and the rest still prints.
  • With no addresses yet, it suggests openemail domains create --domain example.com.
  • openemail status --json prints one object with account, addresses, domains and unavailable, where unavailable gives the reason for each part that could not be read.

Linking a DNS provider, so the records of a new domain are written for you, happens only in the web app in 0.0.2. openemail open providers, or open dns, opens that page. open domains opens the domains and their DNS records, and open addresses the addresses. Forwarding is in the web app too, and open forwarding <address> opens it for one address. --print prints the link instead of opening a browser.

Where OpenEmail wrote a domain’s DNS itself, domains delete takes those records back and lists any it could not in leftBehind, for you to remove at your DNS provider. Records you published yourself are never touched, so remove them too once the domain is gone.

Examples

Add a domain and publish its records
openemail domains create --domain acme.com --json > acme.jsonjq -r '.records[] | [.type, .name, .value, (.priority // "")] | @tsv' acme.jsonopenemail domains verify "$(jq -r .id acme.json)"
Wait until it receives, then check sending
id=b3e1f0a4-6c2d-4e8a-9f17-2d5c8a0b4e6funtil openemail domains get "$id" --json | jq -e .receiving.verified > /dev/null; do  sleep 30doneopenemail domains get "$id" --json | jq '.sending | {status, canSend, error}'
Turn the catch-all off, keeping one address
openemail domains list-addresses "$id" --allopenemail domains create-address "$id" --local-part invoices --label Invoicesopenemail domains update "$id" --no-catch-all --dry-runopenemail domains update "$id" --no-catch-all

Creating invoices by hand keeps it receiving once the catch-all is off, while mail to every other address the catch-all picked up is refused. The dry run prints the PATCH and its body without sending it.

Set a tracking domain, then remove it
openemail domains update "$id" --tracking-host links.acme.com --json | jq '.tracking | {status, record, error}'openemail domains update "$id" --tracking-host null
Retire an address
address_id=$(openemail domains list-addresses "$id" --all | jq -r 'select(.address == "[email protected]") | .id')openemail domains update-address "$id" "$address_id" --no-enabledopenemail domains delete-address "$id" "$address_id" --yes

Turning the address off first can be undone with --enabled. The delete cannot, and in a script it needs --yes. With a browser sign-in it also asks for a verification code, which --yes never skips.

Audit from a script
openemail domains list --all | jq -r 'select(.sending.canSend | not) | [.domain, .sending.status] | @tsv'openemail addresses list --all --json | jq -r '.addresses[] | select(.canSend) | .address'

Scopes, confirmations and errors

ScopeCommands
domains:readdomains list, get, list-addresses, get-address
domains:writedomains create, verify, update, delete, create-address, update-address, delete-address
emails:sendaddresses list
  • A sign-in or key without the scope stops with exit code 4, names the missing scope and says how to get it.
  • domains delete and domains delete-address ask you to confirm. Answering no exits with code 10 and changes nothing. Unattended and without --yes, they stop with exit code 2 before anything is sent.
  • With a browser sign-in, those two deletes also ask for a verification code, as the web app does. Unattended nobody can type it, so the command stops with exit code 4. Run openemail verify first and the next 60 minutes need no code. An API key is never asked.
  • --dry-run prints the request a change would send, with its body, and exits with code 0 without sending it or asking you to confirm.
  • A list reads one page: --limit takes 1 to 100 and the server sends 25 when it is left out, and --cursor takes the nextCursor of the page before. --all reads every page, --max <n> stops after that many items, and --ndjson, or --all in a pipe, prints one JSON object per line. With --json, domains list and list-addresses print one { items, hasMore, nextCursor } document.
  • A key or sign-in limited to particular domains or addresses still sees every domain and address. It cannot add a domain, and every other change needs the whole domain among the domains it holds, or the call is refused with 422 capability_unsupported.
  • A refusal exits with the code of its status: 4 for a 403, such as domain_allowance_reached when the plan allows no more domains, 5 for a 404, 6 for a 409, such as domain_already_added or domain_claimed, and 7 for a 422, such as invalid_tracking_host or workspace_limit_reached.
  • The last domain in a workspace cannot be removed from the CLI. That is a 409 last_domain, because removing it deletes the whole mailbox, which the web app confirms first. A domain holding reserved account addresses is a 409 domain_holds_reserved_addresses.
  • domains create and the two deletes are never retried after a network failure. A 409 domain_already_added, or a 404 on your own second try after a lost answer, means the first one worked. verify, update, create-address and update-address are retried on their own, since sending one twice leaves the same result.

Where to go next

Your inbox,
on your own terms.

Email infrastructure for businesses, AI, agents and personal email. Built for scale, privacy and control. Everything email should have had from day one.

OpenEmail

Email infrastructure for businesses, AI, agents and personal email. Built for scale, privacy and control. Everything email should have had from day one.

© 2026 OpenEmail. All rights reserved.