Email

Email is a conversational channel here, not a broadcast pipe. An inbound reply lands as an entry on a thread exactly like a WhatsApp message does, and the same ticketing, assignment and realtime machinery applies. The email adapter serves messages and connection on the email network, and nothing else — email is in MESSAGING_NETWORKS, so publish and comments are structurally impossible.

Like WhatsApp Business, there is no self-serve OAuth: EmailAdapter.startConnect throws capability_unavailable with "Email senders are provisioned by Infobip. Import the sender credentials to connect it." You get a connection three ways — import, provision, or a connection request an operator actions.

Provisioning a sender

POST /v1/connections/email/provision
Idempotency-Key: 8b2c…
x-axis-tenant: ws_…

{
  "senderAddress": "support@example.com",
  "senderName": "Example Support",
  "replyTo": "support@example.com",
  "apiKey": "…",
  "baseUrl": "https://…",
  "domain": "example.com"
}

Requires the admin scope — it writes a provider credential directly. senderAddress and apiKey are mandatory and validated before anything is stored; domain defaults to the part of senderAddress after the @.

It does two things in one call: completes an email connect (encrypting the credential onto a ConnectionBinding like any other connection), then registers the sending domain. The response is { connection, domain }.

Domain lifecycle

A sending domain has to prove you control it before a provider will relay for it. The routes are on /v1/email-domains, scope connections.

POST /v1/email-domains/register        { connectionId, domain }  → dnsRecords to publish
   ↓  you add those records at your DNS host, then wait for propagation
POST /v1/email-domains/:id/verify      ask the provider to check them
POST /v1/email-domains/:id/refresh     re-read the provider's view; update status + records
GET  /v1/email-domains?connectionId=…  list
GET  /v1/email-domains/:id             one

InboxEmailDomain is unique on (tenantId, domain) and holds:

Field Meaning
status pending on register, active once verified, failed when the provider call errored
verified Boolean, derived from the provider's view of the DNS records
dnsRecords JSON — the records to publish. Read them from the register or refresh response
infobipDomainId The provider's own id for the domain
lastCheckedAt / lastVerifiedAt Timestamps for the last check and the last successful one
lastErrorCode / lastErrorMessage Why the last attempt failed

verify asks the provider to re-check, then reconciles; refresh re-reads the provider's current state and rewrites status, verified and dnsRecords together. Both leave status: 'failed' with lastErrorMessage populated when the provider call itself errors, which is a different condition from "DNS is not there yet" (pending). Domain state stays authoritative even if a binding refresh fails — credentials are never exposed by these routes.

Contact email validation is a different thing

Verifying your sending domain and verifying a contact's mailbox are unrelated operations on unrelated models, and they are easy to conflate because both are called "email verification".

POST /v1/email-validation/single   { identifierId }
POST /v1/email-validation/bulk     { identifierIds: [...] }
GET  /v1/email-validation/:id

Scope inbox. These check whether a mailbox exists and write the result onto InboxContactIdentifier.status, which is one of valid, invalid or unknown, alongside a statusReason (mailbox_valid, mailbox_invalid, or provider_<value> when the provider returned something the classifier does not recognise) and a checkedAt. A valid result also sets verifiedAt.

Note that InboxContactIdentifier is unique on (kind, value) globally — contact identity is not tenant-scoped in inbox. See Data model.

Webhook verification: the asymmetry

Every other adapter verifies inbound webhooks with an HMAC over the raw body. Email does not. POST /webhooks/email does a plain string equality check:

const expected = this.cfg.webhookSecret;
const actual = headers['x-infobip-auth'] ?? headers['x-axis-signature'];
const value = Array.isArray(actual) ? actual[0] : actual;
if (!expected || !value || value !== expected) return { ok: false };

Call this out in your own threat model. A shared secret in a header is a bearer token: anyone who sees it can forge deliveries, and unlike an HMAC it does not bind the signature to the body. It also uses a plain !==, not the timing-safe comparison the HMAC paths use. It is what the provider offers, so it is what the adapter implements — but it means the email webhook secret needs the same handling discipline as an API key, and rotating it is the only remediation if it leaks.

The route is at /webhooks/email, not under /v1, and it is public — verification is the only gate. verifyChallenge returns null: there is no subscription handshake for this provider.

Inbound email delivery events normalize to message.delivery, with the provider's notification type mapped to delivered, opened, clicked or failed.

Gotcha: these POSTs need an Idempotency-Key

register, verify, refresh, single and bulk all look like reads — verify and refresh especially, since they mostly ask a provider a question. They are still POSTs, and inbox's idempotency policy defaults every POST to dataWrite unless the route explicitly opts out with @ReadOnlyPost(). None of these do.

So an Idempotency-Key header is required on all five, or the request is rejected before the handler runs. See Idempotency.