emails.send
The mail your own code decides to send, with no automation and no rule behind it. This page is the send call. The templates it can name, the emails/ directory and its props, are Email templates.
const { deliveryId } = await cow.emails.send({ template: { key: "receipt", props: { orderId: "A-2291" } },});Or write the message inline:
await cow.emails.send({ subject: "Your receipt", html: "<p>Thanks for your order.</p>",});Or send to a profile, which is how a message its recipient can turn off goes out, such as a weekly report:
await cow.emails.send({ to: { userId: "shop_1" }, template: { key: "weekly-report", props: { visits: 120 } },});| Field | Required | Notes |
|---|---|---|
to |
yes | One address, or a list of up to 50: "[email protected]" or "Ada <[email protected]>". Or one identifier naming a profile, { userId: "shop_1" }, of any kind: userId, anonymousId, clerkId or email. |
cc |
no | One address, or a list. Refused when to is a profile. |
bcc |
no | Same as cc. |
replyTo |
no | One address, or a list. |
from |
yes | An address on a domain your organization verified. There is no default. |
template |
either | { key, props }: a template you pushed, and the props it declares. It writes the whole message, subject included. |
subject |
either | Required when you write the body yourself; refused beside a template. |
html / text |
either | The inline body, one or both. Refused beside a template. |
headers |
no | Passed on as you wrote them. |
sourceId |
on the wire | The client’s, unless the call names one. |
idempotencyKey |
no | Your own name for this send, such as an order id. Sent as the Idempotency-Key header, never in the body. |
The promise resolves once the message has been handed over, and deliveryId is the row in Deliveries that records what happened to it.
A retry after a lost reply never sends twice: every attempt of one call carries the same key, and a repeated key answers with the first send’s deliveryId. Without your own idempotencyKey each call gets a fresh one, so running your code again (a queue redelivering a job, say) sends again. Pass a key that names the message, and a second run sends nothing:
await cow.emails.send({ to: { email: order.email }, template: { key: "receipt", props: { orderId: order.id } }, idempotencyKey: `receipt-${order.id}`,});A list in to is one message with one deliveryId, and every address on it sees the others in its To: line. To send each person their own message, call emails.send once per address.
What is checked
Section titled “What is checked”Your credit, then your suppression list, then the from-address. An address that has bounced or complained is dropped from the message; if that leaves nobody, the call still answers with an id and the delivery records that it was suppressed.
Addresses and profiles
Section titled “Addresses and profiles”A message to an address goes to that address and asks nobody’s choice, so only mail nobody can turn off may go this way: an inline body, or a template sent as bare transactional, such as a receipt or a password reset. A template whose purpose its recipient can turn off, any marketing.<name> or transactional.<name>, is refused as a 422 validation_failed, and the message says to address a profile instead.
A message to a profile is how those go out. Name one identifier of the person. Cowliss looks it up in the app the source belongs to, so the same value in another of your apps is another person, and sends to the profile’s email trait, else its newest email identifier. Then:
- Consent is checked. The purpose the template declares, and its root, are read from the profile. If the person turned the purpose off, or a marketing purpose was never granted, the call still answers with an id and the delivery records that it was skipped for consent. A suppressed address is reported before that.
- They can leave. The message carries the
List-Unsubscribeheaders and, unless the template renders its own links, a footer with two links: update email preferences (where leaving everything is) and leave this purpose. See Unsubscribe links. - A new address is a new person.
to: { email }that no profile in the app holds yet creates that profile first, with the address as itsemailidentifier and trait, exactly asidentifywould, then sends to it. Like any new profile it starts with marketing off. - A person Cowliss cannot reach fails the call. Any other identifier no profile in the app holds is a
not_found, and a profile with neither anemailtrait nor anemailidentifier is avalidation_failed. Nothing is sent or recorded either way, so identify the person, with their email, and send again.
Mail that follows a rule or a wait belongs in an automation.
Testing a send
Section titled “Testing a send”Two ways to exercise the real call from your own code or test suite without mailing anyone.
test: true: would this exact send work?
Section titled “test: true: would this exact send work?”const { status, rendered, wouldCreateProfile } = await cow.emails.send({ to: { userId: "shop_1" }, template: { key: "weekly-report", props: { visits: 120 } }, test: true,});// status: "would_send", or why it would not: "would_skip_consent", "would_skip_suppressed"A test runs every check the real send runs and refuses exactly as it would: the same errors for a from-address on a domain you have not verified, an unknown template, props the template refuses, a profile that does not exist. Then, instead of sending:
statusis what the real send would record:would_send, orwould_skip_suppressed/would_skip_consentwhen a check would skip it.renderedis the message it would carry:subject,htmlandtext, unsubscribe footer included.wouldCreateProfileistruewhentonames anemailno profile holds, so the real send would create one. The test creates nobody, so it writes no events and no automation hears of it.- It leaves a row in Deliveries with the same status, so you can read it there too.
A test sends nothing, uses no credit, and ignores idempotencyKey, so the real send with the same key still goes out. The real send answers a status too: sent, or the skip a check recorded.
Test addresses: what happens after?
Section titled “Test addresses: what happens after?”A send to one of these addresses is a real send: every check, a sent delivery, credit used as for any send. What happens next is chosen by the address, and arrives the way it would from a real mailbox.
| Address | Outcome |
|---|---|
[email protected] |
The delivery moves to delivered. |
[email protected] |
The delivery moves to bounced. The address is never added to your suppression list, so the next test bounces again. |
[email protected] |
The delivery moves to complained. On a profile it is a real spam report: marketing mail turns marketing off, mail under a transactional.<name> turns that name off, and each switch turned off writes system.consent_revoked, which automations and segments can listen for. Bare transactional mail turns nothing off. |
[email protected] |
Skipped as an address on your suppression list is: the send answers skipped_suppressed, and a test answers would_skip_suppressed. |
Add a label after a +, such as [email protected], to give each test its own address and so its own profile. A profile whose email trait is a test address works the same, so one send can test the consent check, the footer and what your app does after a bounce. Resend’s [email protected], [email protected] and [email protected] behave like the first three.
With test: true a test address is only a dry run: nothing goes out, so nothing happens after.
A link to their email preferences
Section titled “A link to their email preferences”If your app has a “Manage email preferences” button, send the person to Cowliss’s hosted page instead of building switches of your own. Ask for the link when they click, since it works for an hour, and redirect them to url:
const { url, expiresAt } = await cow.preferences.link({ to: { userId: "shop_1" } });to names one profile exactly as a send to a profile does, with the same sourceId default and the same refusals: a not_found for an identifier no profile in the source’s app holds, a validation_failed for anything but one identifier. A profile with no email trait still gets a link. What the page shows and writes is in Email preferences.
Your own preferences page
Section titled “Your own preferences page”To draw the switches in your own app, in your own language and look, read them with cow.preferences.get and save them with cow.preferences.set. Both name the person as preferences.link does, from your server, with the same refusals.
const { purposes } = await cow.preferences.get({ to: { userId: "shop_1" } });// [// { key: "marketing", label: "Marketing", description: null, enabled: true, answered: true },// { key: "marketing.newsletter", label: "Newsletter", description: "Once a month", enabled: true, answered: true },// { key: "transactional.weeklyReport", label: "Weekly report", description: null, enabled: true, answered: false },// ]There is one row for marketing and one for each purpose your app declares in cow.json, marketing first and the rest by key. Bare transactional is not a row, because nobody can turn off a receipt.
enabledis what a send under that purpose would do now:truemeans it reaches them. A name is enabled only while its root is too.answeredis whether the person (or your app) has given an answer for that key. An unanswered purpose reads as its root decides: a marketing one is off, a transactional one is on.
Save what they chose with set. consent names only the switches that changed; every key you leave out keeps its answer.
const { purposes } = await cow.preferences.set({ to: { userId: "shop_1" }, consent: { "marketing.newsletter": false },});set saves exactly as the hosted page does. Turning a marketing.<name> on turns marketing on too, and turning marketing off turns off every marketing.<name> your app declares. Each switch that changes writes system.consent_granted or system.consent_revoked with reason preferences, and a switch that already held writes nothing. A key your app does not declare is a 422 validation_failed, with nothing saved. It answers with the rows as they now stand, so you can redraw the page from the reply.
identify({ consent }) and set write the same answers, but the timeline says who chose. identify records your app’s choice, such as a signup form’s box, and set records the person’s own, reason preferences, as their click on the hosted page would.
The emails themselves can link to your page instead of Cowliss’s: see Sending in several languages.
Already sending with Resend?
Section titled “Already sending with Resend?”Point the Resend SDK at Cowliss and keep the code you have. See Resend SDK.