Sending email
sdk.sendEmail — structured or raw messages, senders, placeholders that keep secrets out of the log, and duplicates.
A handler sends email with sdk.sendEmail. The message goes out as the app, through a connection
the app was granted, in the app's
branding, and is recorded in the app's email log.
invoices, vendors, payments). Your app has only the tables you create, so put your own table and column names in their place. About the example appexport default async function sendOverdueNotice(data, ctx) {
const { sdk } = ctx;
const invoice = await sdk.read("invoices", data.invoiceUid);
const sent = await sdk.sendEmail("billing", {
to: invoice.billing_email,
subject: "Invoice {number} is overdue",
heading: "Your invoice is overdue",
body: ["Invoice {number} for {amount} was due on {due}."],
action: { label: "Pay now", url: invoice.pay_url },
data: { number: invoice.invoice_number, amount: invoice.amount, due: invoice.due_date },
});
return { emailUid: sent.uid, status: sent.status };
}sdk.sendEmail("billing", message)sends through the connection granted under the aliasbilling(aliases are not case-sensitive), or a connection uid.sdk.sendEmail(message)sends through the app's default connection.
It needs the sdk:external_email capability. That is deny by default, so whoever writes, dry-runs
and approves the handler must hold it, and the published version must be frozen with it. See
Capabilities & governance.
The message
Give the content either as structured fields, which are laid out in the app's branding, or
as raw html / text, sent as written. Not both (EMAIL_MESSAGE_INVALID).
| Field | Notes |
|---|---|
to | An address or a list. With cc and bcc, at most 50 recipients. Required. |
cc, bcc | Optional. |
subject | Required. Placeholders allowed. |
from | Optional. Must be one of the connection's senders; omitted, the connection's default sender. |
fromName | Optional. Omitted, the branding's display name, else the app's name. |
replyTo | Optional. Omitted, the branding's reply-to, else the connection's. |
data | Values for the placeholders. |
| Structured | |
heading | The big line at the top. Defaults to the subject. |
body | A paragraph, or a list of paragraphs, of plain text (escaped for you). |
action | A button: { label, url }. The URL must be http(s) or mailto:. |
code | A one-time code, shown large instead of a button. |
footnotes | Small print under a rule. |
preheader | The inbox preview line. Defaults to the heading. |
| Raw | |
html, text | Sent as written. Placeholder values are HTML-escaped in html. |
Prefer structured content. It renders in the app's branding, in light and dark mode, with a plain-text part, and every value you put in it is escaped.
Senders
from is checked against the connection's senders: an exact address, or any address at a
domain the connection lists (@example.com). Anything else is refused with
EMAIL_FROM_NOT_ALLOWED, and nothing is sent.
await sdk.sendEmail("billing", {
from: "invoices@example.com", // allowed if the connection lists it, or @example.com
fromName: "Example Billing",
to: customer.email,
subject: "Your receipt",
body: ["Thanks for your payment."],
});Placeholders
Any text field, the subject included, can name values from data. There are two kinds, and the
difference is what the email log keeps:
| You write | In the email | In the log |
|---|---|---|
{name} | The value | The value |
{{code}} | The value | ••• |
\{ | A literal { | A literal { |
await sdk.sendEmail({
to: user.email,
subject: "Your sign-in code",
body: ["Hi {name},", "Your code is {{code}}. It expires in 10 minutes."],
data: { name: user.fname, code },
});
// Sent: "Your code is 482913. …"
// Logged: "Your code is •••. …"- Use
{{…}}for anything secret: codes, tokens, one-time links. The log masks it and the result'sredactedistrue. - A key may be a path into
data:{order.number}. - A placeholder naming a key that
datadoes not have refuses the send (EMAIL_PLACEHOLDER_MISSING), rather than mailing a literal{key}to a customer. - A plain placeholder is written tight,
{key}, so prose and CSS with spaced braces stay text. The secret form allows spaces:{{ code }}.
Pass secrets through data
A secret written straight into the text (body: [`Your code is ${code}`]) cannot be told apart
from the rest of the message, and is logged as sent. Put it in data and name it with {{…}}.
If you are used to Handlebars, note that the convention runs the other way: there {{x}} is the
plain form. That fails safe: writing {{x}} by habit hides more from the log, never less, and the
email itself is the same.
What it returns
{
uid: "0199b7d4-…", // the message in the app's email log
status: "sent", // "sent", "sending" or "failed"
duplicate: false, // true when a retried job row had already sent this
messageId: "<…@mail.example.com>", // the mail server's id, when it gives one
from: "billing@example.com",
to: ["ada@example.org"],
alias: "billing",
redacted: false, // true when {{…}} values were masked in the log
}If the mail server refuses the message, the call throws EMAIL_SEND_FAILED (502) with the
server's reason, and the log keeps the message as failed.
Duplicates and retries
In a scheduled job's ctx.job.each, every email is keyed to the row being handled. If a turn
fails and is retried, a row that already sent its email finds the logged message, sends nothing,
and gets duplicate: true. "The same email" is the same connection, recipients, sender and
subject; a different email for the same row is sent. A send that failed is not counted, so the
retry sends it. See Jobs that loop.
Anywhere else, each call sends. A client that retries a request runs the handler again, and
the handler sends again. If that matters, wrap the work in sdk.withIdempotency with a key the
client supplies, so a repeated request gets the first result back instead of running twice.
Email is not transactional. A message is sent the moment you call sendEmail, and rolling the
handler's transaction back does not unsend it. Send after the writes that justify it have
succeeded, not before.
In a dry run
A dry run checks everything a real send would (the
message, the app's grant, the sender, every placeholder) and then sends nothing and logs nothing.
The dry-run report lists the email it would have sent, and the call returns
{ testMode: true, delivered: false, to, from, subject, alias }.
Errors
| Code | Status | When |
|---|---|---|
EMAIL_MESSAGE_INVALID | 400 | No recipient, no subject, no content, both structured and raw content, a malformed address, or a bad action.url. The message names the field. |
EMAIL_PLACEHOLDER_MISSING | 400 | A {key} or {{key}} names nothing in data. Nothing was sent. |
EMAIL_NOT_GRANTED | 403 | No grant by that alias, or no default. The app's owner grants one under App integrations › Email. |
EMAIL_FROM_NOT_ALLOWED | 403 | from is not one of the connection's senders. |
EMAIL_CONNECTION_UNVERIFIED | 409 | The connection was edited and not tested since. |
EMAIL_SEND_FAILED | 502 | The mail server did not accept the message. Logged as failed. |
SDK_CAP_NOT_IN_MANIFEST | 403 | The published version was not frozen with sdk:external_email. Publish again. |