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_base64holds the base64-encoded file. An attachment without this field is skipped silently; content that is not valid base64 makes the send fail.filenamedefaults toattachment.content_typedefaults toapplication/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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
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:
sendtries once, with no queue and no retry. To retry automatically, send from a Queue handler that responds500on failure. A permanent error would then be retried for nothing until it is dead-lettered: keep thetry/catchand respond200when the message itself is at fault. - All recipients are visible: every address in
togoes into theToheader, and each recipient sees the others. There is noccorbcc. For individual sends, callsendonce 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:
sendonly returnstrue. 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 |