Skip to content
Go To Dashboard

Email

Email gives a deployed agent real, addressable inboxes. An agent can send, reply, and forward from them, read what arrives, and attach a custom sending domain. Agent steps call the typed ctx.sapiom.email capability; Sapiom supplies the authenticated cloud connection when the deployed agent runs.

Where an inbox may send depends on its domain. Every inbox also has a daily recipient budget. Read Who an inbox can send to and Limits before you design a sending workflow.

Operations are grouped by resource. An inboxId is the inbox’s email address; pass it unchanged as the first argument to every message and thread call.

ResourceOperations
inboxescreate, list, get, delete
messagessend, list, get, reply, replyAll, forward
threadslist, get
domainscreate, verify, get, list, delete
webhookscreate, delete

That list is the whole surface. Sapiom runs email on AgentMail, but it exposes only the operations above, and AgentMail’s own API is not reachable through Sapiom. Specifically, these are not available:

  • Attachments. Sends carry no attachments. Received messages report attachment metadata (attachmentId, filename, contentType, size), but the file contents cannot be downloaded.
  • Updates to existing resources. You cannot rename an inbox, change labels on a stored message, or change a domain’s feedbackEnabled after you create it. Delete and recreate instead.
  • Listing webhooks. Keep the id returned by webhooks.create; you need it to delete the webhook.
  • Other AgentMail resources, such as drafts, pods, allow and block lists, and metrics. The one exception is your organization’s bounce and complaint rates, which you read over HTTP as shown in Your sending reputation.

messages.list and threads.list return metadata only. Call messages.get or threads.get for bodies. messages.get also returns extractedText / extractedHtml: the new content of a reply, with the quoted history removed. Lists are cursor-paginated: pass nextPageToken back as pageToken, with limit from 1 to 100.

const inbox = await ctx.sapiom.email.inboxes.create({
username: "support",
displayName: "Acme Support",
clientId: "acme-support-inbox",
});
const sent = await ctx.sapiom.email.messages.send(inbox.inboxId, {
to: input.recipient,
subject: "Your request was received",
text: "We will reply within one business day.",
});
return { inboxId: inbox.inboxId, messageId: sent.messageId, threadId: sent.threadId };

clientId guards against duplicates: a second create with a clientId that already exists fails with HTTP 409 instead of creating another inbox. On a 409, find the existing inbox with inboxes.list, whose items include clientId. Without a domain, the inbox gets a generated address on the default domain. Which default domain your organization gets determines the send policy, as the next section explains. Pass domain to create the inbox on a verified custom domain.

send requires to and accepts cc, bcc, replyTo, subject, text, html, labels, and custom headers. send, reply, replyAll, and forward each return { messageId, threadId }. A reply with no to goes to the original sender.

The send policy depends only on the domain of the sending inbox:

Sending inbox domainPolicy
<id>.sapiom.email (free Sapiom inbox)Organization only: the organization’s owner and its active members
agentmail.toUnrestricted
A custom domain you added with domains.createUnrestricted

Sapiom is moving inboxes created without a domain to a per-organization <id>.sapiom.email subdomain, one organization at a time. Until your organization is moved, those inboxes get an agentmail.to address. Existing inboxes keep their address. To know which policy applies, read the domain of the returned inboxId.

On an organization-only inbox:

  • Every address on to, cc, and bcc must belong to the organization’s owner or an active member. A reply is also checked against the original sender, so replying to an outside sender is blocked. If any recipient is outside that set, the send fails with HTTP 403, error: "recipient_not_allowed", and a disallowedRecipients list.
  • replyAll, forward, and reply with replyAll: true are blocked outright with HTTP 403 and error: "internal_only_send_blocked". Use reply with explicit recipients instead.
  • Membership changes can take about a minute to apply.
  • If Sapiom cannot resolve the member list, the send fails rather than going out unchecked.

To send outside your organization, send from your own domain.

LimitValue
Recipients per field (to, cc, bcc, replyTo)50 addresses each, so one call can address up to 150 recipients
Recipient deliveries per inbox per day200, resetting at 00:00 UTC
limit on list calls1–100

The daily limit applies to every inbox, including inboxes on agentmail.to and custom domains. It counts recipient deliveries:

  • send and forward count the distinct addresses across to, cc, and bcc. A send to 12 people uses 12.
  • reply counts the addresses it names, with a minimum of 1.
  • replyAll, and reply with replyAll: true, count the replied-to message’s sender plus its to and cc, plus any address the call adds.
  • Addresses are compared case-insensitively, and the budget belongs to the inbox address.

A call that would exceed the budget is rejected whole and nothing is sent: HTTP 429, error: "daily_send_limit_exceeded", with limit, requested, retryAfterSeconds, and a Retry-After header that counts down to 00:00 UTC. A rejected call does not consume budget and is not charged. A send that fails after it was admitted gets its budget back.

Plan bulk sends around these limits: split large audiences across days or across inboxes on your own domain, and on a 429, wait retryAfterSeconds before sending from that inbox again.

Bounces and complaints count against your whole organization, and crossing a threshold suspends sending for every inbox. Read Your sending reputation before you send at volume.

headers is a map of header name to string value. The policy is a denylist: any header not on the list below is passed through. That includes List-Unsubscribe and List-Unsubscribe-Post for one-click unsubscribe, and your own X- headers.

These names are rejected with HTTP 400, compared case-insensitively:

Header groupRejected namesUse instead
Addressing and identityTo, Cc, Bcc, From, Sender, Reply-To, Return-Path, Bounces-To, Errors-Toto, cc, bcc, replyTo, and the inbox itself
ThreadingIn-Reply-To, Referencesreply, replyAll, or forward, which set them
Envelope and MIMEMessage-ID, Date, Received, Subject, MIME-Version, Content-Type, Content-Transfer-Encoding, DKIM-Signaturesubject, text, html

Every header value must be a string. { "X-Attempt": 2 } is rejected; send { "X-Attempt": "2" }.

await ctx.sapiom.email.messages.send(input.inboxId, {
to: input.recipient,
subject: input.subject,
html: input.html,
headers: {
"List-Unsubscribe": `<${input.unsubscribeUrl}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
});

To continue a conversation, call reply with the received message’s messageId. Setting In-Reply-To on a fresh send is rejected.

A custom domain is the way to send to anyone from an address you control. An email domain is separate from a registered domain: Domains & DNS registers the name and manages its DNS, and email.domains tells the mail provider to sign and receive mail for it. Bring a domain registered through Sapiom, or one you manage elsewhere and can add DNS records to.

  1. Register the domain, if you do not already own it. Registration is a purchase: gate it as shown in Check before registering.
  2. Add it as an email domain. email.domains.create returns the DNS records to publish: TXT, CNAME, and MX records for DKIM, SPF, DMARC, and inbound mail.
  3. Publish every record. Each record.name is a fully qualified host name. Name.com-style DNS takes a host relative to the zone, so remove the registered domain from the end of the name.
  4. Verify. Call email.domains.verify(domainId), then read the result with email.domains.get(domainId). verify returns nothing; the status and each record’s MISSING / INVALID / VALID state appear on get.
  5. Create inboxes on it with inboxes.create({ domain }) once the status is VERIFIED.
const zone = input.registeredDomain; // e.g. "example.com"
const emailDomain = await ctx.sapiom.email.domains.create({
domain: input.sendingDomain, // "example.com", or a subdomain such as "mail.example.com"
});
const existing = await ctx.sapiom.domains.dns.list({ domainName: zone });
for (const record of emailDomain.records) {
const fqdn = record.name.replace(/\.$/, "");
const host =
fqdn === zone ? "" : fqdn.endsWith(`.${zone}`) ? fqdn.slice(0, -(zone.length + 1)) : fqdn;
const published = existing.some(
(r) => r.type === record.type && r.host === host && r.value === record.value,
);
if (published) continue;
await ctx.sapiom.domains.dns.create({
domainName: zone,
type: record.type,
host,
value: record.value,
...(record.priority !== undefined && { priority: record.priority }),
});
}
await ctx.sapiom.email.domains.verify(emailDomain.domainId);
return { domainId: emailDomain.domainId };

DNS changes take time to propagate, so status usually moves through PENDING and VERIFYING before it reaches VERIFIED. Check again from a later step or a scheduled run instead of polling in a tight loop. INVALID or FAILED means a record is wrong; compare each record’s status on get with what you published.

domains.create also takes feedbackEnabled (default false). When true, bounce and complaint notifications are delivered to the inbox as mail. Leave it off if your agent reads its inbox and should not mistake those notifications for replies; the message.bounced and message.complained webhook events carry the same signals.

ConstraintDetail
Registration termOne year only, for register and for each renew. There is no multi-year option.
Auto-renewNot available. Track expiresAt and call renew before it.
Nameserver changesNot available. The domain uses the registrar’s nameservers; manage records with domains.dns.*.
URL forwardingNot available. Point records at a server that redirects.
Transfer inNot available. Only domains registered through Sapiom can be managed; transferOut moves one away.
Transfer lockA newly registered domain cannot be transferred out for 60 days.
PriceThe registrar’s live quote at the time of the call, charged as quoted. Read it from check or get; do not hard-code it. If the price changes between authorization and purchase, register fails with HTTP 409; retry to get the new quote.

A domain registered elsewhere still works for email: add it with email.domains.create and publish the records at your own DNS provider.

webhooks.create({ url, eventType }) registers an HTTPS endpoint for inbound and delivery events. eventType is one of message.received, message.received.spam, message.received.blocked, message.received.unauthenticated, message.sent, message.delivered, message.bounced, message.complained, message.rejected, domain.verified, or * for all of them.

The response includes a secret that is returned only once. Store it when you create the webhook and use it to verify the signature on delivered events.

Failed calls throw EmailHttpError, exported from @sapiom/tools, with the HTTP status and the parsed body:

Statusbody.errorCauseWhat to do
400variesInvalid address, more than 50 addresses in one field, a rejected header, or a non-string header valueFix the request; it will not succeed on retry
403recipient_not_allowedAn organization-only inbox addressed someone outside the organizationUse a custom-domain inbox, or drop disallowedRecipients
403internal_only_send_blockedreplyAll or forward from an organization-only inboxUse reply with explicit recipients, or a custom-domain inbox
409variesinboxes.create with a clientId or address that already exists, or an address whose previous inbox is still being deletedList inboxes to find the existing one, or retry the deletion case later
422variesinboxes.create on a custom domain that is not VERIFIED yetFinish verification first
429daily_send_limit_exceededThe inbox’s 200 daily recipient deliveries are spentResume after retryAfterSeconds, or split the batch
import { EmailHttpError } from "@sapiom/tools";
try {
await ctx.sapiom.email.messages.send(input.inboxId, input.message);
} catch (err) {
if (err instanceof EmailHttpError && err.status === 429) {
const body = err.body as { retryAfterSeconds?: number };
return { deferred: true, retryAfterSeconds: body.retryAfterSeconds ?? null };
}
throw err;
}

Do not swallow a send error and report the message as sent. A step that reports success after a failed send hides the failure in the run inspector.

Local Run replaces every email call with a deterministic stub and never sends mail. The stubs do not apply the send policy, the daily limit, or the header rules, so a passing local run does not prove a real send would be accepted. Stub the failure responses yourself to exercise those branches:

{
"version": 1,
"steps": {
"send": {
"email.messages.send": { "messageId": "msg-1", "threadId": "thread-1" }
}
}
}

Stub keys follow the call path: email.inboxes.create, email.messages.send, email.messages.reply, email.domains.get, and so on. Without an override, email.inboxes.create returns an example.com address and email.domains.get reports VERIFIED. After each run, require both unusedStubs and stubWarnings to be empty.