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 interface — send, 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 | undefinedThe provider's message id, when it returns one. Correlates to webhook events
via event.messageId.
providerstringThe slug that handled the send, e.g. "resend".
acceptedstring[] | undefinedRecipients the provider accepted, when it reports them.
rawunknownThe 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;// truesendbooleanCan send a single message. True for every provider.
batchbooleanHas a native bulk-send endpoint. When false, sendBatch() falls back to
sequential sends.
templatesbooleanHas a native stored-template system, exposed via providerOptions (the
escape hatch), not reimplemented.
attachmentsbooleanSupports file attachments.
webhooksbooleanRadon can parse and normalize this provider's delivery webhooks.
webhookSignaturebooleanRadon cryptographically verifies the webhook signature before normalizing.
When false but webhooks is true, the provider ships no body signature —
secure the endpoint yourself.
tagsbooleanSupports tags / categories on a message.
schedulingbooleanSupports scheduling a send for a future time via sendAt.
ccbooleanSupports CC recipients.
bccbooleanSupports BCC recipients.
replyTobooleanSupports 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, ortextmay contain{{variable}}placeholders. Passvariablesand 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.templatesstore and send withtemplate: { 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.