@radonsdk/email
v0.1.0
Published
One unified, provider-agnostic API for 16 email providers — Western, African, and raw cloud. Write email.send() once, swap providers via config. Batch sends, template interpolation, and normalized delivery webhooks. Own your data, no vendor lock-in.
Readme
@radonsdk/email
One unified, provider-agnostic API for 16 email providers — Western, African, and raw cloud. Write
email.send()once and swap providers with a config change, never a code change.
Radon Email gives you a single, typed interface (send, sendBatch, templates, webhooks.handle, …) that every provider implements. Provider-specific quirks — auth schemes, wildly different request bodies, signed webhook formats — are absorbed inside each adapter and never leak to your code. Own your data, no vendor lock-in.
- 16 providers, one API — Resend, SendGrid, Postmark, AWS SES, Mailgun, Brevo, Loops, Termii (Africa-first), Mailjet, SparkPost, Elastic Email, MailerSend, SMTP2GO, generic SMTP, Amazon Pinpoint, Zoho ZeptoMail.
- Normalized delivery webhooks — every provider's opens / clicks / bounces / complaints / unsubscribes collapse into one event schema; one handler,
email.webhooks.handle(req). - Batch sending with graceful sequential fallback,
{{variable}}templates, and a bring-your-own-provider interface. - Lazy-loaded adapters — a Resend + SendGrid app never bundles SES's code. The core is ~40 KB.
- Strict TypeScript, ESM + CJS, Node ≥ 18, zero required dependencies (SES/Pinpoint sign with hand-rolled SigV4; only the generic SMTP fallback needs
nodemailer, an optional peer dep).
Install
npm install @radonsdk/email
# generic SMTP fallback only:
npm install nodemailerRadon never stores your secrets. Each adapter reads credentials from RADON_<PROVIDER>_* environment variables (or from the providers config block, which takes precedence). See .env.example for every provider's variable names.
Quickstart (< 5 minutes)
import { RadonEmail } from "@radonsdk/email";
const email = new RadonEmail({
providers: { resend: {} }, // creds come from RADON_RESEND_API_KEY
defaultFrom: "Acme <[email protected]>",
});
const result = await email.send({
to: "[email protected]",
subject: "Welcome, {{name}}!",
html: "<p>Hi {{name}}, thanks for joining.</p>",
variables: { name: "Ada" }, // free {{var}} interpolation
});
console.log(result.status); // "queued" | "sent" | "scheduled"
console.log(result.id); // provider message idSwitch providers without touching your send code — it's a config change:
const email = new RadonEmail({
providers: { resend: {}, termii: {} },
licenseKey: process.env.RADON_LICENSE_KEY, // termii is a Pro provider
defaultFrom: "Acme <[email protected]>",
});
await email.init();
await email.send({ to: "[email protected]", subject: "Hi", text: "…" }, { provider: "resend" });
await email.send({ to: "[email protected]", providerOptions: { templateId: "…" } }, { provider: "termii" });Free vs. Pro
Radon Email gates on two levels — matching @radonsdk/auth and @radonsdk/payments.
1. Provider tier. Three providers are free; the rest require a Radon Pro license key, verified once in await email.init() and cached for the process lifetime.
| Tier | Providers |
| --- | --- |
| Free | resend, sendgrid, smtp |
| Pro (license) | postmark, ses, mailgun, brevo, loops, termii, mailjet, sparkpost, elasticemail, mailersend, smtp2go, pinpoint, zeptomail + any bring-your-own provider |
2. Feature tier. sendBatch(), template management (email.templates.*), and webhook normalization (email.webhooks.handle()) are Pro regardless of provider — they require a verified license even on a free provider like Resend. A single send() (including free {{var}} interpolation) never does.
const email = new RadonEmail({
providers: { resend: {} }, // a FREE provider…
licenseKey: process.env.RADON_LICENSE_KEY, // …but Pro features still need a license
});
await email.init(); // verifies the license; unlocks Pro
await email.sendBatch([ /* … */ ]); // Pro feature- Free provider + single
send()works with no license and noinit(). - Using a Pro provider or a Pro feature without a valid license throws
LicenseRequiredError; a bad/unreachable key throwsLicenseInvalidError(fail-closed). - Get a license at https://radonsdk.xyz/pricing. Introspect tiers with the exported
FREE_PROVIDERSset andisProProvider(slug).
The unified API
// Send one (returns a normalized SendResult).
const r = await email.send({ to: "[email protected]", subject: "Hi", html: "<p>Hi</p>" });
// Batch (Pro). Uses a native bulk endpoint where the provider has one, else
// falls back to sequential sends — `result.batched` tells you which.
const batch = await email.sendBatch([
{ to: "[email protected]", subject: "1", text: "…" },
{ to: "[email protected]", subject: "2", text: "…" },
]);
// Managed templates (Pro to manage; interpolated at send time).
await email.templates.register({ id: "welcome", subject: "Hi {{name}}", html: "<p>{{name}}</p>" });
await email.send({ to: "[email protected]", template: { id: "welcome", variables: { name: "Ada" } } });Every message supports to/cc/bcc (one or many), replyTo, html/text, attachments, tags, headers, metadata, and sendAt. Operations a provider can't do throw a typed UnsupportedOperationError — never a silent failure — and provider.capabilities tells you up front what's available.
Attachments
await email.send({
to: "[email protected]",
subject: "Your invoice",
text: "Attached.",
attachments: [
{ filename: "invoice.pdf", content: pdfBuffer }, // Buffer
{ filename: "logo.png", content: base64Png, disposition: "inline", contentId: "logo" },
],
});Templates
- Free — inline interpolation. Any
subject/html/textmay contain{{variable}}placeholders; passvariablesand Radon interpolates 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, Mailgun/Postmark stored templates, …) are exposed via the escape hatch, not reinvented:
// SendGrid dynamic template — passed straight through to the provider.
await email.send({
to: "[email protected]",
providerOptions: { templateId: "d-abc123", dynamicTemplateData: { name: "Ada" } },
}, { provider: "sendgrid" });Webhooks — one handler for every provider (Pro)
Each provider signs (or doesn't sign) delivery webhooks differently — Svix, ECDSA, SNS-RSA, HMAC-SHA256/512. Radon verifies the signature inside the adapter and hands you one normalized event array:
// Express / Fastify / Next — pass the RAW body (signatures are computed over exact bytes).
app.post("/webhooks/email", async (req, res) => {
try {
const events = await email.webhooks.handle(
{ body: req.rawBody, headers: req.headers },
{ provider: "resend" }, // omit when only one provider is configured
);
for (const e of events) {
switch (e.type) {
case "delivered": /* … */ break;
case "bounced": /* e.bounce?.type === "hard" | "soft" */ break;
case "complained":
case "unsubscribed":
case "opened":
case "clicked": break;
}
}
res.sendStatus(200);
} catch {
res.sendStatus(400); // signature failed → reject
}
});Normalized event types: delivered, opened, clicked, bounced, complained, unsubscribed, dropped, deferred, failed, unknown. You can also react with hooks: email.on("onBounced", …), email.on("onComplained", …), email.on("onEmailEvent", …).
| Signed & verified | Normalized, unsigned* | No delivery webhook | | --- | --- | --- | | Resend, SendGrid, SES, Mailgun, MailerSend | Postmark, Brevo, Mailjet, SparkPost | Termii, Loops, Elastic Email, SMTP2GO, Pinpoint, ZeptoMail, SMTP |
*These providers don't sign their webhook bodies — Radon still normalizes them, but secure the endpoint yourself (secret path / basic auth). Introspect with provider.capabilities.webhookSignature.
Termii — first-class, not an afterthought
Termii is a leading African (Nigeria-first) messaging platform, and a core reason Radon isn't just another Western-only SDK. It's built to Termii's real API, which is transactional-template / OTP driven (no arbitrary-body send), and keyed by an email_configuration_id from your Termii dashboard:
// Template email
await email.send({
to: "[email protected]",
subject: "Your balance",
variables: { name: "Ada", balance: "₦5,000" },
providerOptions: { templateId: "your-termii-template-id" },
}, { provider: "termii" });
// OTP-code email
await email.send({
to: "[email protected]",
providerOptions: { code: "123456" },
}, { provider: "termii" });A Termii send with neither a template id nor an OTP code throws a clear SendError rather than silently doing nothing — the same "no silent stubs" honesty the whole suite follows.
Escape hatch
Anything the unified API doesn't cover:
message.providerOptions— merged into the outgoing request by the adapter (e.g. 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.
Bring your own provider
Implement the EmailProvider interface (or extend BaseProvider) and register it:
import { registerProvider, BaseProvider } from "@radonsdk/email";
class MyEspProvider extends BaseProvider {
readonly name = "my-esp";
readonly capabilities = { send: true, batch: false, templates: false, attachments: true,
webhooks: false, webhookSignature: false, tags: false, scheduling: false, cc: true, bcc: true, replyTo: true };
async send(message) { /* … call your API via this.http(...) … */ }
}
registerProvider("my-esp", async () => MyEspProvider);Custom providers are Pro-gated (like any non-free slug).
Install size & lazy loading
The core (@radonsdk/email) contains zero provider code — only the interface, the registry, the template/webhook/hook engines, the license gate, and shared types. Adapters are loaded on demand via dynamic import() and are also importable directly:
import { ResendProvider } from "@radonsdk/email/providers/resend";A splitting bundler only includes the adapters you actually use.
License
MIT © Radon SDK. The Radon Pro tier is governed by the license key you configure; see https://radonsdk.xyz/pricing. "# radonsdk-email"
