Skip to content

Sending

Three equivalent ways to send, all going through the same pipeline (log to _mailLog, then deliver — see Mail log and Delivery):

REST — POST /api/mails/send (see Reference → REST API → Mails for the full body/response shape). A superuser or API key may always call it with anything:

Terminal window
curl -X POST https://your-app/api/mails/send \
-H "Authorization: $SUPERUSER_TOKEN" -H "Content-Type: application/json" \
-d '{"to":"user@example.com","template":"welcome","data":{"user":{"name":"Ada"}}}'

Any other caller — an authenticated app user, or even an anonymous one — may call it too, but only to + template (+ data/locale), and only when that template’s sendRule allows it. This is what lets a frontend send transactional mail with no backend of its own:

// No superuser session, no API key — just a signed-in (or anonymous,
// if the template's sendRule allows it) app user calling Cratebase
// directly.
await cb.mails.send({
to: recipientEmail,
template: "invite",
data: { invitedBy: currentUserName, teamName: "Acme" },
});

TypeScript SDK — cb.mails.send (subject to sendRule for a non-superuser caller, as above) and cb.mails.preview (superuser/API-key only — a dashboard/tooling affordance, sign in via cb.auth.as("_superusers") or attach an API key as a header):

const result = await cb.mails.send({
to: "user@example.com",
template: "welcome",
data: { user: { name: "Ada" } },
});
// { id: "...", status: "sent" | "failed" | "queued", error?: string }
const preview = await cb.mails.preview({ template: "welcome", data: { user: { name: "Ada" } } });
// { subject, html, text? } — never sent, never logged

JS hooks — $mails.send(...), the same pipeline as the REST/SDK calls above (distinct from $app.newMailClient().send(...), which only ever sends a bare subject/html with no template resolution, log row, or queue delivery):

onRecordAfterCreateSuccess((e) => {
$mails.send({
to: e.record.get("email"),
template: "welcome",
data: { user: { name: e.record.get("name") } },
});
e.next();
}, "users");

Called from a record-write hook like this — onRecordAfterCreateSuccess/ onRecordAfterUpdateSuccess/onRecordValidate/etc., where e.app’s writes still join the triggering write’s own transaction — $mails.send detects that and defers its _mailLog write and the actual delivery until that transaction commits: the call still validates its input and returns { id, status: "queued" } immediately, but the mail only ever goes out (and only ever gets logged) once the record write it was called from actually succeeds. A hook that throws afterwards and rolls back the write also cancels the send — nothing is delivered for a record that was never really created. Outside of a hook (a route, a cronAdd job, POST /api/mails/send) there is no open transaction to defer past, so the call behaves exactly like the REST/SDK examples above: it logs and delivers (or enqueues) inline and returns the real outcome.

If settings.queue.enabled, a send enqueues onto the durable job queue (mail:send, with the queue’s own retry/backoff) instead of sending inline — the _mailLog row starts (and, until a worker attempt resolves it, stays) "queued". Otherwise, delivery is inline: one attempt, then one immediate retry on failure, ending at "sent" or "failed". Either way, POST /api/mails/send itself always answers 200 once validation passes — a delivery failure is reported in the response body (status: "failed", error), not as an HTTP error, same as request-otp/request-magic-link always answering success regardless of what happens next.

Either template or subject plus html/text must be given; to/cc/bcc together may not exceed 50 recipients (5 for a non-superuser/non-API-key caller — see Templates → sendRule), and every address must be well-formed — both are 400s, checked before anything is logged or sent. A sendRule denial is a 403, checked before this validation.