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:
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 loggedJS 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.
Delivery
Section titled “Delivery”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.
Validation
Section titled “Validation”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.