Radondocs

Core concepts

The mental model behind Radon Email — providers, the from requirement, capabilities, templates, webhooks, and how swapping providers actually works.

Radon Email has a small number of ideas. Once they click, every method and every provider behaves predictably.

Providers

A provider is an email service — Resend, SendGrid, Amazon SES, Termii. You configure the ones you use; Radon lazy-loads each adapter only when it's first touched, so a Resend-plus-SendGrid app never bundles SES's code.

const email = new RadonEmail({
  providers: {
    resend: {},                         // credentials from RADON_RESEND_API_KEY
    sendgrid: { apiKey: "SG.xxx" },     // or inline
  },
  defaultProvider: "resend",
  defaultFrom: "Acme <hello@acme.com>",
});

Every call uses defaultProvider unless you override it per call:

await email.send({ to: "ada@example.com", subject: "Hi", text: "…" }, { provider: "sendgrid" });

With a single provider configured, it's the default automatically. With several and no defaultProvider, calls that don't name one throw InvalidConfigError.

Why swapping is a config change

Every provider implements the same interfacesend, sendBatch, parseWebhook. The shapes you pass in (EmailMessage) and get back (SendResult, EmailEvent) are identical across providers, so changing service is a config edit, not a rewrite.

A from address is required

Every provider needs a sender. Set it once as defaultFrom on the client, or per message with from — the message value overrides the default.

// Configured once, applied to every message:
new RadonEmail({ providers: { resend: {} }, defaultFrom: "Acme <hi@acme.com>" });

// Or per message:
await email.send({ from: "Support <support@acme.com>", to: "ada@example.com", subject: "…", text: "…" });

No `from` throws

A send() with neither a message from nor a configured defaultFrom throws a SendError before any network call — never a silent failure. An address may be a bare string ("ada@example.com") or a named object ({ email: "ada@example.com", name: "Ada" }), which renders as Ada <ada@example.com>.

Normalized results

Every send() returns the same SendResult shape, whatever the provider.

status"sent" | "queued" | "scheduled"

The normalized outcome. sent = handed off for immediate delivery, queued = accepted for async delivery, scheduled = accepted for a future sendAt.

idstring | undefined

The provider's message id, when it returns one. Correlates to webhook events via event.messageId.

providerstring

The slug that handled the send, e.g. "resend".

acceptedstring[] | undefined

Recipients the provider accepted, when it reports them.

rawunknown

The untouched provider response — reach here for fields Radon doesn't normalize.

Capabilities

Providers don't all do the same things. Rather than fail silently, each adapter declares a static capability descriptor you can read before you call:

const resend = await email.provider("resend");
resend.capabilities.batch;      // true  — has a native bulk endpoint
resend.capabilities.scheduling; // true  — supports sendAt
resend.capabilities.attachments;// true
sendboolean

Can send a single message. True for every provider.

batchboolean

Has a native bulk-send endpoint. When false, sendBatch() falls back to sequential sends.

templatesboolean

Has a native stored-template system, exposed via providerOptions (the escape hatch), not reimplemented.

attachmentsboolean

Supports file attachments.

webhooksboolean

Radon can parse and normalize this provider's delivery webhooks.

webhookSignatureboolean

Radon cryptographically verifies the webhook signature before normalizing. When false but webhooks is true, the provider ships no body signature — secure the endpoint yourself.

tagsboolean

Supports tags / categories on a message.

schedulingboolean

Supports scheduling a send for a future time via sendAt.

ccboolean

Supports CC recipients.

bccboolean

Supports BCC recipients.

replyToboolean

Supports a Reply-To address.

Asking a provider to do something it genuinely can't (e.g. attachments on Termii) throws a typed UnsupportedOperationError — never a silent no-op. See Providers for the full matrix.

Templates

Radon has two template layers, plus a passthrough to each provider's own:

  • Free — inline interpolation. Any subject, html, or text may contain {{variable}} placeholders. Pass variables and Radon substitutes them provider-agnostically, before the adapter runs. Dotted paths ({{user.name}}) work; unknown variables render empty.
  • Pro — managed templates. Register named templates in the email.templates store and send with template: { id, variables }.
  • Native provider templates (SendGrid dynamic templates, Postmark/Mailgun stored templates) are exposed via the escape hatch (providerOptions), not reinvented.

Full detail in Templates.

Webhooks, normalized

Providers notify you of delivery events (opens, clicks, bounces, complaints) via webhooks. Radon verifies the signature (where the provider signs) and collapses every provider's payload into one EmailEvent array, so you write one handler:

const events = await email.webhooks.handle({ body: req.rawBody, headers: req.headers });
for (const e of events) {
  if (e.type === "bounced") { /* suppress the address */ }
}

Signatures are computed over the exact bytes received — always pass the raw body, never a re-serialized object. Webhook normalization is a Pro feature. Full details in Webhooks.

Escape hatch

The unified API covers the common cases. For provider-only features, you have three doors:

  • message.providerOptions — merged into the outgoing request by the adapter (a native template id, a provider-specific tracking flag).
  • result.raw — the untouched provider response.
  • email.native(slug) — the provider's own configured HTTP client / context, for calling endpoints Radon doesn't model.

Using any of these ties that call to one provider — documented, but discouraged. You can also register your own adapter by extending BaseProvider; any custom slug is Pro-gated but built exactly like the built-ins.

On this page