Send your app's own email
A transactional email is one your app owes one person: a receipt, a sign-in link, a password reset. It leaves from a mailbox kept for it, or from a sender address that sends through our mail servers, exactly as you wrote it. Start by reading what your app already sent. This sends nothing.
import { Colding } from "colding";
const colding = new Colding("cd_your_key");
const page = await colding.sends.list({ kind: "transactional" });
for (const send of page.items) console.log(send.to, send.status);Replace the key with yours. kind: "transactional" leaves out every other kind of email. The list is empty until your app sends its first one.
[email protected] sent
What you need first
Use a key from the first guide. Each permission covers one kind of action, and one does not include another.
- The
transactionalpermission on the key. A key with onlysendcannot send these emails, and a key with onlytransactionalcannot send campaigns. To follow an email afterwards, the key also needsread. - Where it leaves from: a mailbox kept for your app's email, with
purpose: "transactional"(the next section sets one), or a sender address (the Sender addresses section). - A plan with transactional email. The free plan has none, and the call answers 402
PLAN_GATE. - The workspace owner must have accepted the acceptable use policy. Until then, sending answers 403
AUP_REQUIRED. - A confirmed address to send from. A working login proves the password, not that the address is yours, so an owner confirms a mailbox's address in the console. Until then, the call answers 409. A sender address needs no confirming: the records of its domain prove it.
Read how mailboxes are connected
A paid plan lets one mailbox send 200 to 1,000 of these emails a day, by plan. The count starts again at midnight in the workspace's time zone.
Keep a mailbox for it
Every mailbox has one purpose at a time: cold for campaigns and messages, or transactional for your app's email. A transactional mailbox does not send campaigns, and the other kind does not send your app's email. Send the wrong kind from a mailbox and the call answers 409.
Set the purpose with mailboxes.update. The key needs operate and transactional, and the plan must include transactional email:
const mailbox = await colding.mailboxes.update({
mailboxId,
purpose: "transactional",
});
console.log(mailbox.purpose);It prints transactional. Going back to cold is the same call, and also needs transactional. A transactional mailbox still receives mail, and you can still reply from it.
A mailbox that campaigns send from, or that has emails scheduled, cannot become transactional. The call answers 409, and details.campaigns lists those campaigns by ID and name. Take the mailbox out of them, let its scheduled emails go or cancel them, then call again.
A send-only mailbox has no inbox. It is a sending service's SMTP login, and it is always transactional. An owner connects it in the console. An agent asks for it with a handoff.
Read how a send-only mailbox is connected
Keep your app's email on its own subdomain, such as mail.example.com, apart from the domain your campaigns use. A campaign's bounces and spam complaints are then less likely to reach your receipts. The console suggests this when both kinds share a domain. It is advice, not a rule.
Sender addresses
Sender addresses are not on any plan for sale yet. Until our team opens sending from our mail servers for your workspace, creating one answers 402 PLAN_GATE with the messageKey serverErrors.hostedTransactionalClosed. We open it for one business at a time: write to [email protected].
A sender address is the From address of your app's email, like [email protected]. It has no password and no login, and it sits on a mail domain of yours that is set up for transactional email. It does not receive mail.
A key can't make one. An owner makes it in the console, under Domains, once the domain is Active. An agent asks an owner with a manage_sender_addresses handoff. Behind the console are three operations only an owner can call: create_sender_address, update_sender_address and delete_sender_address. A key gets 403 on them.
Every sender address has a replyTo, the address where replies go. We never receive mail for the domain, so a reply to the sender address itself would go nowhere. Name an address someone reads, like your support desk. It can't be the sender address.
A key reads sender addresses with senderAddresses.list and senderAddresses.get. Replace the key with yours.
const page = await colding.senderAddresses.list();
for (const sender of page.items) console.log(sender.id, sender.address, sender.replyTo);Each one has id, address, domainId, displayName, replyTo, createdAt and createdBy. The id is the mailboxId that a send names. Narrow the list with domainId. The list is paged: pass nextCursor back as cursor. A deleted sender address is not listed, and getting it answers 404.
To get one made, create a handoff, give the owner its link, then read the result. When the owner has made the address, the handoff is completed and resultMailboxId is the new sender address's ID.
const request = await colding.handoffs.create({
kind: "manage_sender_addresses",
note: "Add [email protected]. Replies go to [email protected].",
});
console.log(request.url);
// Later, after the owner has opened the link:
const done = await colding.handoffs.get({ handoffId: request.id });
if (done.resultMailboxId) {
const sender = await colding.senderAddresses.get({ senderAddressId: done.resultMailboxId });
console.log(sender.address, sender.replyTo);
}An address your app already sends from, as a connected mailbox or a send-only mailbox, can't also be a sender address. To move it, disconnect the mailbox first. That deletes the messages stored here for it (mail at your provider stays), and the sender address gets a new ID that your app must send with. Until you change the ID in your app's config, a send that names the old one answers 404.
To send from a sender address, pass its id as mailboxId.
Send one email
transactional.send sends one email to one person, from one mailbox or sender address:
const send = await colding.transactional.send({
mailboxId: "66666666-6666-4666-8666-666666666666",
to: "[email protected]",
subject: "Your sign-in link",
bodyText: "Open this link to sign in: https://app.example.com/in/abc123",
intentKey: "sign-in-link-user-42-2030-01-15",
expiresAt: "2030-01-15T10:15:00Z",
});
console.log(send.id, send.status);The call returns the email with status reserved, shown as About to go out in the console. It counts toward today's sends but hasn't gone out yet. The worker, our background sender, sends it next.
Send bodyHtml, bodyText or both. The email goes out exactly as you wrote it: no signature, no unsubscribe line, no open or click tracking, no wait between emails and no undo window. toName sets the name on the To line, and replyTo says where replies go.
Set expiresAt for a code or a link that stops working, as a date and time with its offset. If the email has not started going out by then, it is canceled with errorCode expired and never sent. A time that has already passed answers 400.
You can cancel an email that has not started going out with sends.cancel. It needs the transactional permission. Once an email has started going out, it can't be canceled, and the call answers 409.
Name each email
intentKey is required. It is your own name for one email, up to 255 characters, such as receipt-order-1042. Use something your app already has, like the order number.
Send the identical request again and you get the same email back. Nothing more is sent. This holds for good, not only for 24 hours.
const request = {
mailboxId: "66666666-6666-4666-8666-666666666666",
to: "[email protected]",
subject: "Receipt for order 1042",
bodyText: "Thanks for your order. The receipt is in your account.",
intentKey: "receipt-order-1042",
};
const first = await colding.transactional.send(request);
const again = await colding.transactional.send(request);
console.log(first.id === again.id);true
The request is the mailbox, the address, the name, the subject, both bodies, expiresAt and replyTo. Build it once and send that same copy on every retry. Save the intentKey before you send, so a crash does not lose it.
The same intentKey with a different request is refused with 422 IDEMPOTENCY_KEY_REUSED, and nothing is sent. To send a second email on purpose, such as a receipt again, use a new intentKey.
A retry is answered before the plan, the payment and expiresAt are checked. So a retry that comes late still gets its email back, even if the payment has failed since.
Names that start with draft:, warmup: or campaign: are ours, and are refused.
From our servers
Pass a sender address's id as mailboxId. Nothing else in the call changes: the same intentKey, the same body, the same expiresAt. The email leaves from our mail servers, so your app needs no mailbox of its own to send it.
const send = await colding.transactional.send({
mailboxId: "77777777-7777-4777-8777-777777777777",
to: "[email protected]",
subject: "Your sign-in link",
bodyText: "Open this link to sign in: https://app.example.com/in/abc123",
intentKey: "sign-in-link-user-42-2030-01-16",
});
console.log(send.status, send.viaOurServers);reserved true
The call answers like any send, with status reserved. viaOurServers is true when the email leaves through our servers, and false when it leaves through a mailbox you connected.
Here sent means our mail server took the email and is passing it on to the person's server. It does not mean it reached an inbox. When the person's server answers, send.delivered says it took the email, or send.bounced says it refused it for good. A bounce comes after sent, and the status stays sent.
Two limits. expiresAt only stops an email our mail server does not have yet. Once it has the email, it keeps trying until the person's server answers, so a code can arrive after it expired. And in rare cases the person's server takes the email but its answer is lost on the way, and our server sends it again: the person gets it twice.
When our servers say no
A sender address can be refused for more reasons than a mailbox can. None of these reserves anything, so the identical request can go again. resetAt says when.
402 hostedTransactionalClosed- Your workspace is not open for email from our servers yet. Our team opens it for one business at a time: write to [email protected].
429 hostedTransactionalDay- Your workspace sent all the emails it may send from our servers today. The number is your workspace's own. Wait for
resetAt, midnight in the workspace's time zone. 429 domainRamp- A domain that is new to our servers starts with a small number of emails a day and can send a little more each day it sends.
details.limitis today's number. It starts over atresetAt, midnight UTC. 429 ipCeiling429 ipShare- These are our servers' limits, not your plan's. Other businesses send through the same servers, and each keeps some room for its own email. They start over at
resetAt, midnight UTC. 429 receiverPace- The person's mail provider asked our mail server to slow down, or our team paused mail to that provider for a while.
details.receiversays which provider. Send again afterresetAt. 409 senderIpNotReady409 mailIpHeld- Our side is not ready: no server of ours is open to carry app email yet, or the one your email leaves from is on hold. Nothing was sent. Send the identical request again later.
409 mailDomainNotSigning- Your domain's DNS records failed our check, so we can't sign its email. Nothing is sent from it until the records pass again. Fix them in the console, under Domains.
409 senderDomainUnderReview- Our team put the sender address's domain on hold because too much of its email bounced or was marked as spam. There is no
resetAt: nothing sends from the domain until our team turns it back on.details.caseNumberis the number to quote. Further down this page, you can read when we hold a domain and how the hold ends.
Your workspace's daily number starts over at midnight in the workspace's time zone. A new domain's daily limit and our servers' limits start over at midnight UTC.
With domainRamp, ipCeiling or ipShare, an app that already has another confirmed address connected for transactional email can send the same content from it now, with its own intentKey. The recipient sees that other address as the From. Otherwise, send the identical request again after resetAt. With hostedTransactionalDay, wait for resetAt: that number is your workspace's own.
Set up a second way to send before you need it
A send carries no files yet.
Set up a second way to send before you need it
- Before you need it, connect another address for transactional email: a mailbox kept for it, or your sending service's login. Confirm its sender address. It can't be the sender address you use through our servers.
- Keep its
mailboxIdin your app's config as the second sender. - Use it only after a 429 that reserved nothing, whose
details.scopeisdomainRamp,ipCeilingoripShare. Send the same content through thatmailboxIdwith its ownintentKey. The same key with a different email is refused. If this send is accepted, do not also retry the first one. Never send a second copy of an email that is alreadysentorunknown. - The recipient sees the second address as the From, not your sender address on our servers.
hostedTransactionalDayis your workspace's own number for the day. Wait forresetAton that one.
Follow it until it ends
sends.get answers for one email. sends.list answers for many, newest first, and kind: "transactional" keeps only these. Both need read. A status only moves forward. These emails cannot be scheduled, so scheduled never shows:
reserved- About to go out. It already counts toward today's sends and is waiting its turn.
sending- Going out now.
sent- The mail server it left through accepted it: your provider's, for a mailbox you connected, or ours, for a sender address. That does not mean it reached an inbox.
failed- It did not go out.
errorCodesays why. unknown- The connection broke while it was going out, so we cannot tell if the server took it. It counts as sent and is never sent again.
cancelled- It stopped before it left: a person or a key canceled it, or a check refused it when it was due.
errorCodesays why.
To hear how each email ended without asking, read the events. Every send ends in send.sent, send.failed, send.unknown, send.cancelled or send.bounced, campaign emails too, so the example checks kind:
const page = await colding.events.list({
types: ["send.sent", "send.failed", "send.unknown", "send.cancelled", "send.bounced"],
});
for (const event of page.items) {
if ("kind" in event.data && event.data.kind === "transactional")
console.log(event.type, event.subjectId);
}subjectId is the email's ID. These emails have no send.opened or send.clicked, because they carry no tracking.
sent means the mail server it left through accepted the email: your provider's, from a mailbox you connected, or ours, from a sender address. Neither says it reached an inbox. A bounce can arrive later. Then send.bounced fires, and bouncedAt says when.
Replies land in the inbox, or at replyTo when you set one. A send-only mailbox has no inbox, and neither has a sender address: replies go to replyTo, or else to the reply-to address the sender address was made with.
When it is refused or stopped
A refused call sends nothing. These are the answers a send can get:
400 VALIDATION_ERROR- The request is wrong: no body, a bad address, or an
expiresAtthat has already passed. 402 PLAN_GATE- The plan has no transactional email. A plan change lifts it, and
details.upgradeTonames a plan that has it. From a sender address, it can also mean our team has not opened our servers for the workspace yet. 402 PAYMENT_REQUIRED- The last payment failed. Paying lifts it.
403 FORBIDDEN- The key lacks
transactional, or an owner limited it: to some mailboxes, to one kind of email, or to a total of emails.details.scopesays which. 403 AUP_REQUIRED- The owner has not accepted the acceptable use policy yet.
404 NOT_FOUND- No such mailbox or sender address in this workspace. One of another workspace answers the same.
409 CONFLICT- Something about the mailbox, the address or the workspace blocks this send. The message says what: the mailbox is not transactional, its address is not confirmed, the recipient stopped your app's email or, from a sender address, one of our servers is not ready or our team put its domain on hold.
422 IDEMPOTENCY_KEY_REUSED- This
intentKeyalready names a different email. 429 QUOTA_EXCEEDED- The mailbox sent all its app emails for today, or the key reached its daily limit. From a sender address, it can also be the room on our servers: From our servers lists each one.
details.scopesays which, andresetAtsays when sending can start again. 429 RATE_LIMIT_EXCEEDED- The person's mail provider asked our mail server to slow down. This happens only from a sender address.
details.scopeisreceiverPace, and From our servers explains it.
The first guide lists every error code and what to do about it.
Stopped before it left
A call can succeed and the email still not go out. Just before it delivers, the worker looks once more. If something changed, it cancels the email with an errorCode and sends nothing:
expiredexpiresAtpassed before the email started going out.recipient_suppressed- The address went on the suppression list for app email after you made the call.
authority_revoked- The key or the person that sent it can no longer send app email.
key_stream_scopekey_mailbox_scope- An owner limited the key after you made the call, so it can no longer send this kind of email, or send from this mailbox.
payment_failed- A payment failed after you made the call.
workspace_posture- We limited the workspace before the email left, or the account that sent it can no longer send.
workspace_closing- The owner asked to delete the workspace.
sender_unconfirmed- The mailbox's address was not confirmed as the workspace's.
mailbox_disconnected- The mailbox was disconnected, or the sender address deleted, after you made the call.
mail_ip_held- The server of ours the email was to leave from was put on hold after you made the call.
domain_not_signing- The sender address's domain failed our check after you made the call, so we can't sign its email.
mailbox_paused- The mailbox was paused, or our team put the sender address's domain on hold, after you made the call.
failed is different: the email was tried, and the server or the mailbox refused it. errorCode says why. To send it again, use a new intentKey, because the old one still names the failed email.
relay_refused is ours: our mail server refused an email from a sender address. It is not a bounce, nobody is suppressed, and it does not count toward today's number. Send it again later with a new intentKey.
When we hold a mail domain
We watch the app email that leaves through our servers, one mail domain at a time. If too much of it bounces or gets marked as spam, we put the domain on hold. Every sender address on it stops sending until our team turns it back on. Other mail domains, and the mailboxes you connected, keep sending.
We count the last 7 days. A bounce counts when the person's mail server refused the address. A bounce that gives no reason counts for nothing, and so does a delay on our side. An email that a person marks as spam counts once.
We hold a domain when 0.5% of its emails were marked as spam, or 10% bounced. With fewer than 50 emails in those days, no rate counts. A spam hold also needs at least 3 different people who marked an email, so one person can't hold your domain alone. A bounce hold also needs at least 10 bounces that went to 3 or more different recipient domains (the part after the @), so one company's mail server can't hold your domain alone. These are today's numbers, and they may change.
A receiving server can also refuse your email as unwanted. We hold the domain for that only when other senders on our servers don't get the same refusal from that server.
While the hold lasts, a send from any sender address on the domain answers 409 with senderDomainUnderReview. details.caseNumber is our case number. details.ground says which rule held the domain: complaint_rate, bounce_rate, policy_bounce_rate or policy_abuse_burst. An email that was already waiting to go is canceled with mailbox_paused. You can't add a sender address on the domain either.
Only our team turns sending back on, and the count starts over when it does. Write to [email protected] and quote the case number.
The workspace gets one mail_domain_problem alert when the hold starts (under_review) and one when it ends (review_ended), and each sender address fires mailbox.review_changed. The console's page for the domain shows the last 7 days and the hold.
We also read your app email as its own group when we judge the whole workspace. The rates are the same, but they only count from 200 emails in those days. Spam marks still need 3 different people, and bounces still need 3 or more recipient domains. If your email passes them, or a domain is held on several different days, we can limit the workspace. posture.reasonCode then says transactional_bounce_rate, transactional_complaint_rate or sending_pauses.
Who stops getting it
Your app's email has its own suppression list. An address can stop campaigns and messages (cold in the API), your app's email (transactional), or both. What stops what depends on what came back, and about which email:
- The address bounced, about any email
- Both
- A person marked a campaign or message email as spam
- Campaigns and messages only
- A person marked your app's email as spam
- Both
- A person replied asking to stop, to a campaign or message email
- Campaigns and messages only
- A person replied asking to stop, to your app's email
- Both
An unsubscribe from your campaigns never stops your app's email.
No address check comes first, because the person asked for this email. Only the suppression list is read, twice: when you make the call, and again just before the email goes out.
See what has stopped your app's email:
const page = await colding.suppressions.list({ stream: "transactional" });
for (const row of page.items) console.log(row.value, row.source, row.twinSource);source says why each row is there. twinSource says why the same address is on the other list, or is null when it is not.
suppressions.add adds an address with stream: "transactional", and needs operate. Taking one off is for a signed-in owner or admin in the console. No key can.
suppression.added fires for each new row, with its stream and source.
Reports from a sending service
A send-only mailbox has no inbox, so a bounce cannot come back as mail. The sending service tells us another way: a report sent to an address of ours, the mailbox's report hook. We read Mailgun, Postmark, Resend, SendGrid and Amazon SES. A report counts only when the service proves it is theirs and it names an email this mailbox sent.
The owner turns the hook on in the console and copies its address into the service. A key cannot, because the hook holds or shows a secret. A key reads the rest:
const hook = await colding.mailboxes.reportHook({ mailboxId });
console.log(hook.service, hook.on, hook.lastReportAt);
const reports = await colding.mailboxes.reports({ mailboxId });
for (const report of reports) console.log(report.kind, report.outcome);reportHook says whether we read this service (supported) and whether the hook is on. It also gives the time of the last report (lastReportAt) and how many came in the last 30 days, by outcome (counts). It never shows the secret.
Amazon SES has no secret. The owner gives us the SNS topic. The hook says when AWS's subscription was confirmed, and the last message we refused, with the reason (refusal).
reports lists what came in, newest first: 20 by default, up to 100. Each says whether it was a bounce or a complaint and what became of it. It has no address and none of the service's words. Reports are kept 30 days.
The outcome says what we did:
suppressed- The report said the address is gone, or a person complained. That email's recipient is on the suppression list.
stamped- A permanent failure that does not say the address is gone, such as a refusal about the message or about us. The email is marked bounced, and nobody goes on the list.
no_send- No email of this mailbox matches the report. Nothing changed.
other_recipient- It names someone the email did not go to. Nothing changed.
not_final- A delay, or a failure the service will retry. Nothing changed.
Only the recipient of the email the report is about goes on the list. An address named inside a report never does.
The code that reads each service's reports was written from its documentation and has not yet run against a live account. Check the first report with reports before you rely on it.
Three guards against sending twice
A timeout does not tell you whether an email went out. Three separate guards stop a second one. They work at different points, and none of them replaces another.
A saved answer: the HTTP request key
Every write the SDK makes carries an Idempotency-Key. For 24 hours, the same workspace, operation, key and input get the saved answer back instead of a second run. If saving the answer fails, or the first try got no answer for 15 minutes, the call can run again. This guard alone cannot promise one email.
The send intent: one name for one email
This is why intentKey is required here: it is the guard that holds for good. The same key with the same request always returns the same email, long after the saved answer is gone.
The worker's claim
Before it delivers, the worker claims the email's row once, so two workers cannot both send it. If the connection breaks at the end of sending, the email is marked unknown, counts as sent and is never sent again.
None of the three proves the email arrived. sent means the mail server it left through accepted it: your provider's or, from a sender address, ours.