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.