Email

sdk.email.send sends an email from your app's server code: an order confirmation, a form notification, an invitation link. Delivery goes through the SMTP server configured for your app, and one call sends one message.

For newsletters and sends to a contact list, use the campaigns described in Email & MJML instead.

API

import { createSdk } from "@bext-stack/framework/sdk";

const sdk = createSdk("<app-id>");

await sdk.email.send({
  to: ["customer@example.com"],
  subject: "Your order is confirmed",
  html: "<p>Thank you for your order.</p>",
  text: "Thank you for your order.",
});

Call it server-side (loader, action, route.ts or task), never from an island.

Field Type Description
to string[] Recipients, at least one. A bare address (a@b.com) or one with a name (Jane Martin <a@b.com>).
subject string Message subject.
html string? HTML body.
text string? Plain-text body.

You need at least html or text. With both, the message is sent as HTML with a text alternative, and the mail client picks the one it displays.

send returns true once the SMTP server has accepted the message. On failure, the call throws an Error carrying the error message returned by the platform.

Sender and reply-to address

The sender address (From) is not part of the message: send has no from field. It comes from your app's sending configuration, which holds the SMTP server, its credentials, the sender address and name, and a default reply-to address. The framework client has no call to read or change this configuration. An app with no sending configuration cannot send: every call throws.

If the configuration includes a reply-to address, it applies to every message. To override it on one message (a contact form, where replies should go to the visitor), add the reply_to field described below.

Extra fields: reply_to and attachments

The client forwards the message object as is, and the platform accepts two fields that the BextSdk type does not declare:

Field Type Description
reply_to string Reply-to address for this message. Overrides the one from the configuration.
attachments array Attachments, each shaped { filename, content_type, content_base64 }.

For each attachment:

  • content_base64 holds the base64-encoded file. An attachment without this field is skipped silently; content that is not valid base64 makes the send fail.
  • filename defaults to attachment.
  • content_type defaults to application/octet-stream. A malformed MIME type makes the send fail.

Since these fields are not in the type, build the message in a variable before passing it to send: TypeScript would reject an unknown field written directly in the call.

const msg = {
  to: ["accounts@example.com"],
  subject: "Invoice F-2026-0142",
  text: "Please find the invoice attached.",
  reply_to: "billing@example.com",
  attachments: [
    {
      filename: "F-2026-0142.pdf",
      content_type: "application/pdf",
      content_base64: pdfBase64,
    },
  ],
};
await sdk.email.send(msg);

Base64 makes a file about a third larger, and the whole message travels in a single request: keep attachments light, and send a link for a large file instead.

Example: contact form

// src/app/api/contact/route.ts
import { json } from "@bext-stack/framework";
import { createSdk } from "@bext-stack/framework/sdk";

const sdk = createSdk("<app-id>");

const esc = (s: string) =>
  s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");

export async function POST(request: Request) {
  const { name, email, message } = await request.json();
  const msg = {
    to: ["contact@example.com"],
    subject: `Contact request: ${name}`,
    text: `${name} <${email}>\n\n${message}`,
    html: `<p><strong>${esc(name)}</strong> (${esc(email)})</p><p>${esc(message)}</p>`,
    reply_to: email,
  };
  try {
    await sdk.email.send(msg);
    return json({ ok: true });
  } catch (e) {
    return json({ ok: false, error: String(e) }, 502);
  }
}

reply_to lets you answer the visitor straight from the inbox, while the message still comes from the app's sender address.

Errors

Cause Kind
to empty or missing permanent
Invalid recipient or reply_to address permanent
Neither html nor text permanent
Attachment with invalid base64 or a malformed MIME type permanent
No sending configuration for the app permanent
Permanent refusal from the SMTP server (5xx code, rejected credentials for example) permanent
SMTP server unreachable, or temporary refusal (4xx code) temporary

A permanent error will happen again unchanged: fix the message before retrying. A temporary error may clear on the next attempt. The Error message contains the detail returned by the platform or the SMTP server.

The call is also bounded by the client's timeoutMs (30 seconds by default). A timeout throws, but does not prove the message was not sent.

Good to know

  • One attempt: send tries once, with no queue and no retry. To retry automatically, send from a Queue handler that responds 500 on failure. A permanent error would then be retried for nothing until it is dead-lettered: keep the try/catch and respond 200 when the message itself is at fault.
  • All recipients are visible: every address in to goes into the To header, and each recipient sees the others. There is no cc or bcc. For individual sends, call send once per recipient.
  • HTML is sent as is: nothing is escaped or sanitized. Escape values typed by your visitors before inserting them into html, as in the example.
  • No tracking: send only returns true. The client exposes no message ID, and there is no delivery, open or bounce status.

Where to next

Page What it covers
Queue Sending from a handler, with retries and dead-letter
Email & MJML Newsletters, campaigns and sending statistics
Secrets Values and configuration specific to your app
SDK Overview The createSdk client and the BextSdk interface