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.

The examples on this page use the tables of a sample invoicing app (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 app
export 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 alias billing (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).

FieldNotes
toAn address or a list. With cc and bcc, at most 50 recipients. Required.
cc, bccOptional.
subjectRequired. Placeholders allowed.
fromOptional. Must be one of the connection's senders; omitted, the connection's default sender.
fromNameOptional. Omitted, the branding's display name, else the app's name.
replyToOptional. Omitted, the branding's reply-to, else the connection's.
dataValues for the placeholders.
Structured
headingThe big line at the top. Defaults to the subject.
bodyA paragraph, or a list of paragraphs, of plain text (escaped for you).
actionA button: { label, url }. The URL must be http(s) or mailto:.
codeA one-time code, shown large instead of a button.
footnotesSmall print under a rule.
preheaderThe inbox preview line. Defaults to the heading.
Raw
html, textSent 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 writeIn the emailIn the log
{name}The valueThe 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's redacted is true.
  • A key may be a path into data: {order.number}.
  • A placeholder naming a key that data does 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

CodeStatusWhen
EMAIL_MESSAGE_INVALID400No 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_MISSING400A {key} or {{key}} names nothing in data. Nothing was sent.
EMAIL_NOT_GRANTED403No grant by that alias, or no default. The app's owner grants one under App integrations › Email.
EMAIL_FROM_NOT_ALLOWED403from is not one of the connection's senders.
EMAIL_CONNECTION_UNVERIFIED409The connection was edited and not tested since.
EMAIL_SEND_FAILED502The mail server did not accept the message. Logged as failed.
SDK_CAP_NOT_IN_MANIFEST403The published version was not frozen with sdk:external_email. Publish again.

Next

On this page