npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

mailgrid

v0.1.0

Published

Unified email SDK that routes sends across SendPulse, Brevo, Mailjet, Mailtrap, Resend and Maileroo — maximizing free quota with transparent failover.

Downloads

213

Readme

mailgrid

One email API. Six providers. All of your free quota.

mailgrid routes every send across Resend, Brevo, Mailjet, Mailtrap, Maileroo and SendPulse — tracking each provider's usage, spending the free tiers you already have, and switching providers automatically when one runs out of quota, gets rate limited or breaks.

import { createEmailClient } from "mailgrid";

const email = createEmailClient(); // reads RESEND_API_KEY, BREVO_API_KEY, …

const result = await email.send({
  from: "[email protected]",
  to: "[email protected]",
  subject: "Welcome",
  html: "<h1>Hello!</h1>",
});

console.log(result.provider, result.id); // "brevo", "brevo-42"

No provider SDKs, no runtime dependencies, no lock-in. If resend is out of quota for the day, the same call silently lands on brevo. Your application code never learns about it.


Why

Free email tiers are generous but fragmented. Six providers give you thousands of sends per month for free — but each one has its own API, its own limits and its own failure modes. mailgrid treats them as one pool of capacity:

  • Automatic failover. A 500, a timeout, an expired API key or a rate limit never surfaces as a user-facing error while another provider can still deliver.
  • Quota awareness. Usage is tracked per provider, per window (second → month). When a provider is spent, routing moves on — before the provider has to reject you.
  • Shared state. Point every instance at the same store and your whole fleet shares one budget.
  • Extensible. Providers are ~100-line adapters behind one interface. Bring your own in minutes.

Install

npm install mailgrid

Requires Node 18+ (or any runtime with fetch). Zero runtime dependencies.


Quick start

Providers from environment variables

RESEND_API_KEY=re_xxx
BREVO_API_KEY=xkeysib-xxx
MAILJET_API_KEY=xxx
MAILJET_API_SECRET=xxx
MAILTRAP_API_TOKEN=xxx
MAILEROO_API_KEY=xxx
SENDPULSE_CLIENT_ID=xxx
SENDPULSE_CLIENT_SECRET=xxx
import { createEmailClient } from "mailgrid";

const email = createEmailClient(); // every provider with credentials becomes a route

You do not need all six — one provider works exactly like six, you just get less headroom.

Providers configured in code

import { createEmailClient } from "mailgrid";

const email = createEmailClient({
  providers: [
    { type: "resend", apiKey: process.env.RESEND_API_KEY!, priority: 1 },
    { type: "brevo", apiKey: process.env.BREVO_API_KEY!, priority: 2 },
    // Plan limits are configurable; defaults match the documented free tiers.
    { type: "mailjet", apiKey: "…", apiSecret: "…", priority: 3, limits: { perDay: 200, perMonth: 6000 } },
  ],
  strategy: "least-used",
  defaultFrom: "[email protected]",
});

Sending

const result = await email.send({
  from: "Jane <[email protected]>",
  to: ["[email protected]", "[email protected]"],
  cc: "[email protected]",
  bcc: "[email protected]",
  replyTo: "[email protected]",
  subject: "Welcome aboard",
  html: "<h1>Hello!</h1>",
  text: "Hello!",
  headers: { "X-Campaign": "welcome" },
  tags: { campaign: "welcome" },           // or ["welcome"]
  attachments: [
    { filename: "invoice.pdf", content: pdfBytes, contentType: "application/pdf" },
    { filename: "logo.png", content: base64Png, encoding: "base64", contentId: "logo" },
  ],
  idempotencyKey: "welcome-42",            // honoured by Resend and Maileroo
  provider: "brevo",                       // optional: pin this send
});

result.provider;   // which provider actually delivered it
result.id;         // provider message id
result.attempts;   // every attempt, including the ones that failed
result.usedFallback; // true when a fallback provider was needed
result.quota;      // quota left on the provider after this send

send() resolves as long as any provider could accept the message. It only throws when every option is exhausted.


How routing works

For every send the client:

  1. Filters to providers with credentials, past cooldowns and remaining quota.
  2. Orders them by priority, then by the configured strategy.
  3. Reserves the quota atomically in the shared store (so two instances can never overspend a free tier).
  4. Sends. On a transient failure it refunds the reservation, records provider health, and tries the next provider — up to maxAttempts.

Errors that fail over

| Situation | Behaviour | | --- | --- | | Network error, timeout, 5xx | Retry another provider | | 429 / rate limit | Cooldown (honours Retry-After), retry another provider | | 401 / 403 / bad credentials / unverified sender domain | Bench the provider, retry another | | 400 / 422 / invalid recipient | Throw immediately — the payload is wrong, another provider would agree | | Every provider exhausted | AllProvidersFailedError with a per-attempt summary |

Set failoverOnInvalidRequest: true if you would rather try the next provider on 4xx too.

Strategies

| Strategy | When to use it | | --- | --- | | priority (default) | You have a favourite provider; order is respected, nearly spent providers are demoted. | | least-used | Maximise free quota — always send through whichever provider has the most headroom. | | round-robin | Spread volume evenly across providers in the same priority group. | | random | Cheap jitter, useful for load spreading across identical providers. |

priority is always the primary key; the strategy only reorders providers within the same priority group.


Quota tracking

Limits are counted per window and all windows are enforced:

{ type: "resend", apiKey: "…", limits: { perDay: 100, perMonth: 3000 } }

| Provider | Default limits (free tier) | | --- | --- | | Resend | 100/day, 3 000/month | | Brevo | 300/day | | Mailjet | 200/day, 6 000/month | | Mailtrap | 1 000/month | | Maileroo | 3 000/month | | SendPulse | 15 000/month |

These are a starting point, not a contract — plans change. Override them with limits, per-provider environment variables (EMAIL_RESEND_PER_DAY=100) or limits: false to opt out of tracking for a provider.

Inspect the state at any time:

await email.quota();   // { resend: [{ window: "day", used: 12, limit: 100, remaining: 88, resetAt }] }
await email.status();  // availability, reasons, health, cooldowns, last error
email.health();        // in-memory health snapshot
await email.verify();  // check credentials against each provider
await email.resetState("resend");

Sharing quota between instances

Deploy more than one instance and they will each spend the same free tier independently. Pick a store so they coordinate:

  • state: "memory" (default) — one budget per process.
  • state: "file" — one budget per host or shared volume (available from mailgrid/node).
  • state: "redis" — one budget for the whole fleet.

File store — several processes on one host or a shared volume:

import { createEmailClient } from "mailgrid/node";

const email = createEmailClient({
  state: { store: "file", file: ".mailgrid/usage.json" },
});
// or: EMAIL_STATE_STORE=file EMAIL_STATE_FILE=.mailgrid/usage.json

Mutual exclusion uses an exclusive-create lock file plus atomic writes, so concurrent processes cannot oversubscribe a provider. Stale locks (crashed process) are reclaimed automatically.

Redis store — any number of instances, any host:

import { Redis } from "ioredis";
import { createEmailClient } from "mailgrid";

const redis = new Redis(process.env.REDIS_URL!);

const email = createEmailClient({
  state: { store: "redis", client: redis },
  // or pass a store instance yourself:
  // store: new RedisUsageStore({ client: redis, keyPrefix: "mailgrid" }),
});

Every reservation runs inside a single Lua script, so quota checks and increments are atomic. ioredis and node-redis v4 are both supported (no hard dependency on either).

Custom stores just implement the UsageStore interface (consume, refund, read, plus optional getProviderState / setProviderState / reset).


Events, logging, retries

email.on("sent", ({ result }) => metrics.increment("email.sent", { provider: result.provider }));
email.on("failover", ({ from, to, error }) => logger.warn({ from, to }, error.message));
email.on("provider:state", ({ provider, current }) => logger.info(provider, current.status));
email.on("failed", ({ error, attempts }) => logger.error(error.message, attempts));
const email = createEmailClient({
  logger: "info",           // "silent" | "error" | "warn" | "info" | "debug", or a custom Logger
  retry: {
    maxAttempts: 3,         // total provider attempts per email (default: one per provider, min 2, max 5)
    backoffMs: 250,         // exponential backoff between attempts
    maxBackoffMs: 2000,
    jitter: 0.5,
    retriesPerProvider: 0,  // retry the same provider before switching away
  },
  health: { failureThreshold: 3, cooldownMs: 30_000, maxCooldownMs: 600_000 },
  respectQuota: true,       // skip spent providers instead of letting them 429
  minRemainingRatio: 0.15,  // demote providers below 15% remaining quota
  maxCooldownWaitMs: 2000,  // wait out a short cooldown instead of failing
});

Health is shared through the store, so a provider that breaks for one instance is benched for all of them, and a successful send closes the circuit immediately.

Batch and bulk sending

// Many different messages, sent concurrently, one result each.
const outcomes = await email.sendBatch(messages, { concurrency: 5, stopOnError: false });
// [{ input, result }, { input, error }]

// One message to many recipients — one email each, per-recipient data,
// duplicates collapsed.
await email.sendBulk(
  { from, subject: "Hi {{ name }}", html: "<p>{{ name }}</p>", to: contacts },
  { concurrency: 10, data: (recipient) => ({ name: nameOf(recipient) }) },
);

sendBulk({ …, single: true }) keeps everyone on one message (one To header, one send) instead.

Preview before sending

const plan = await email.preview(input);
// { valid, issues, candidates: [{ provider, priority, remaining, blockedBy }],
//   providers, suppressed, deferred }

preview() never sends, never reserves quota, and works with the CLI too: mailgrid send --to [email protected] --subject Hi --dry-run.


Templates

Write the email once, send it with data. Templates are rendered locally — no provider template editor required, and nothing leaves your process:

const email = createEmailClient({
  templates: [
    {
      name: "welcome",
      subject: "Welcome, {{ name | title }}!",
      html: '<h1>Hi {{ name }}</h1>{{> footer }}',
      text: "Hi {{ name }}",           // optional — derived from the HTML when omitted
    },
  ],
  templatePartials: { footer: "<p>— The team</p>" },
  templateFilters: { shout: (value) => String(value).toUpperCase() },
});

await email.send({ to, template: "welcome", data: { name: "jane" } });
email.renderEmail("welcome", { name: "jane" }); // { subject, html, text }, no send

Explicit subject / html / text always win over the template. Templates from a directory for the CLI (mailgrid render welcome --templates ./emails, files named welcome.html, welcome.subject, welcome.text) load with loadTemplatesFromDirectory(). If you already maintain templates in the provider dashboard, pass providerTemplate: { id, variables } instead and mailgrid forwards it to whichever provider supports provider-side templates.

Scheduling

await email.send({ …, scheduledAt: "in 2 hours" });   // ISO strings and Dates work too
await email.send({ …, scheduledAt: tomorrow, scheduleMode: "queue" });   // force the outbox
await email.send({ …, scheduledAt: tomorrow, scheduleMode: "native" });  // force the provider

With the default scheduleMode: "auto" a provider that supports native scheduling gets the job (it keeps running if your process restarts); otherwise the message goes to the durable outbox and is delivered when its time comes.


Suppressions

Bounces, spam complaints and unsubscribes are remembered and skipped automatically:

await email.suppressions.add("[email protected]", "bounce", { note: "550 mailbox unavailable" });
await email.suppressions.has("[email protected]");  // true
await email.suppressions.list();
await email.suppressions.remove("[email protected]");
await email.suppressions.count();

Pass suppressions: false to turn the filtering off. When every recipient of a message is suppressed, send() throws SuppressedError rather than silently doing nothing.

Webhooks

Normalize any provider's webhook, optionally verify it, and let it update suppressions and metrics:

const result = await email.handleWebhook("mailtrap", payload, {
  headers: request.headers,      // plain object; lookup is case-insensitive
  rawBody: await readRawBody(),  // required for HMAC — never re-serialize
});
// { events: [{ type: "bounced", email, messageId, reason, … }], suppressed, signature }

Signature verification understands the providers' own schemes, so you configure a secret instead of an algorithm:

| Provider | Scheme | Header | | --- | --- | --- | | Resend | Svix (whsec_…, base64 HMAC over id.timestamp.body) | svix-signature, svix-id, svix-timestamp | | Mailtrap | HMAC-SHA256, hex, over the raw body | Mailtrap-Signature | | Mailjet | HTTP basic auth | Authorization | | Others | hmac-sha256 / bearer / basic, configured explicitly | --header / header option |

const email = createEmailClient({
  webhooks: { secret: "shared", secrets: { resend: "whsec_…" }, toleranceMs: 300_000 },
  // or: EMAIL_WEBHOOK_SECRET=shared EMAIL_WEBHOOK_SECRET_RESEND=whsec_…
});

await email.verifyWebhookSignature("resend", { headers, rawBody }); // { valid, scheme, reason }

A bad signature throws WebhookSignatureError (webhook_signature_invalid, or unsupported_webhook_signature when a provider has no signature format), and nothing is applied to your suppression list.

A ready-made endpoint. createWebhookReceiver gives you a Request → Response handler for Bun, Deno, Workers, Next.js route handlers or Node with a small adapter — raw body, verification, parsing, suppressions and events, in order:

import { createWebhookReceiver } from "mailgrid";

export const POST = createWebhookReceiver({
  client: email,
  provider: (request) => new URL(request.url).pathname.split("/").pop()!,
  onEvents: (events) => queue.push(...events),   // return a Response to override
});

From the CLI: mailgrid webhook mailtrap --file payload.json --secret $SECRET verifies and applies a payload, and --sign --secret $SECRET mints the signature header so you can replay a captured request locally.

One-click unsubscribe

const email = createEmailClient({
  unsubscribe: { secret: process.env.UNSUBSCRIBE_SECRET!, url: "https://app.example.com/unsubscribe", mailto: "[email protected]" },
});

// Marketing mail gets List-Unsubscribe + one-click headers per recipient.
await email.send({ …, to: audience, unsubscribe: true });

// Your endpoint turns the click back into an address — no session, no lookup.
const record = await email.unsubscribe.verify(new URL(request.url).searchParams.get("token")!);
if (record) await email.suppressions.add(record.email, "unsubscribe");

Tokens are base64url(email).base64url(expiry).hmacSha256(secret, email.expiry) — tamper proof, so an attacker cannot unsubscribe somebody else, and they expire after 30 days by default. Build one directly with email.unsubscribe.create(address) or unsubscribeLink(address, options). Transactional mail should leave unsubscribe off.

Durable outbox

When no provider can take a message right now, queue it instead of failing:

const email = createEmailClient({ outboxOnFailure: true, outbox: { autoStart: true, maxAttempts: 6 } });

await email.outbox.enqueue(input, { scheduledAt: "in 3 days" });
await email.outbox.list("failed");
await email.outbox.flush({ limit: 20 });   // deliver everything that is due
await email.outbox.retry("id");
await email.outbox.retryFailed();
await email.outbox.stats();                // { total, pending, failed, sent, scheduled, due }
const stop = email.outbox.start({ intervalMs: 30_000 });

Items carry their attachments (binary content is serialized) and survive process restarts whenever the store is shared. flush() emits flushed events per delivery, and the CLI can drive it from cron: mailgrid outbox flush.

Idempotency

await email.send({ …, idempotencyKey: `invoice-${invoice.id}` });

The key is remembered in the shared store (24h by default), so a retry from your side — or a job that ran twice — returns the original result with deduplicated: true and never mails twice. Providers with native idempotency support receive the key as well.

Metrics and forecasting

type MetricsSnapshot = { sent, delivered, bounced, complained, opened, clicked,
                         unsubscribed, suppressed, failed, deduplicated, byProvider, events };

await email.metrics();    // counters, merged across the fleet when the store is shared
await email.forecast();   // [{ provider, window, limit, used, remaining, resetAt,
                          //    burnRatePerHour, projectedExhaustionAt }]

Webhook events feed the counters, so delivered / bounced / opened reflect reality once providers report back. quota:low is emitted below quotaWarnThreshold (10% by default).

Waiting for quota

await email.send({ …, waitForQuota: 5_000 });   // wait out a short window instead of failing over

Node helpers (mailgrid/node)

import { createEmailClient } from "mailgrid/node";
import { attachmentFromFile, attachmentsFromFiles, attachmentFromUrl, loadTemplatesFromDirectory } from "mailgrid/node";

const email = createEmailClient({
  state: { store: "file", file: ".mailgrid/usage.json" },
  templates: await loadTemplatesFromDirectory("./emails"),
});

await email.send({
  …,
  attachments: [
    await attachmentFromFile("./invoice.pdf"),
    await attachmentFromUrl("https://cdn.example.com/logo.png", { contentId: "logo" }),
  ],
});

Testing your integration (mailgrid/testing)

import { createMockEmailClient } from "mailgrid/testing";

const mail = createMockEmailClient({ limits: { perDay: 1 } });
await mail.client.send(message);

expect(mail.sent).toHaveLength(1);
mail.primary.failNext("boom", "auth_error");   // script failures and watch the failover

No network, no credentials, no timers.

CLI

mailgrid doctor                 # credentials, quota, health
mailgrid status --json          # quota, health and forecast
mailgrid send --to [email protected] --subject "Hi" --html "<p>Hi</p>" [--dry-run] [--scheduled-at "in 2h"]
mailgrid render welcome --templates ./emails --data '{"name":"Jane"}'
mailgrid webhook mailtrap --file payload.json --secret "$SECRET"
mailgrid outbox list | stats | flush | retry <id> | retry-all | remove <id>
mailgrid providers              # built-in limits and capabilities

Environment variables

| Variable | Purpose | | --- | --- | | RESEND_API_KEY, BREVO_API_KEY, MAILJET_API_KEY + MAILJET_API_SECRET, MAILTRAP_API_TOKEN, MAILEROO_API_KEY, SENDPULSE_CLIENT_ID + SENDPULSE_CLIENT_SECRET | Provider credentials | | EMAIL_PROVIDER_ORDER | Comma separated routing preference, e.g. brevo,resend | | EMAIL_DISABLED_PROVIDERS | Providers to ignore even when keys are present | | EMAIL_STRATEGY | priority | least-used | round-robin | random | | EMAIL_DEFAULT_FROM | Sender used when a send omits from | | EMAIL_LOG_LEVEL | silent | error | warn | info | debug | | EMAIL_STATE_STORE / EMAIL_STATE_FILE | Persist usage in a shared file (Node entrypoint) | | EMAIL_MAX_ATTEMPTS | Total provider attempts per email | | EMAIL_<PROVIDER>_ENABLED / _PRIORITY / _PER_DAY / _PER_MONTH / _BASE_URL | Per-provider overrides | | EMAIL_WEBHOOK_SECRET / EMAIL_WEBHOOK_SECRET_RESEND | Default (and per-provider) webhook signing secrets | | MAILTRAP_INBOX_ID | Route Mailtrap sends to a sandbox inbox | | EMAIL_REDIS_URL | Redis connection for the shared store | | EMAIL_RESPECT_QUOTA, EMAIL_MIN_REMAINING_RATIO, EMAIL_FAILOVER_ON_INVALID_REQUEST, EMAIL_MAX_COOLDOWN_WAIT_MS | Routing behaviour |

See .env.example for the full list.


Adding a provider

Providers are the only thing you need to write — quota, routing, retries and logging come for free.

import { BaseProvider, registerProviderType, type ProviderContext, type NormalizedMessage } from "mailgrid";

class PostmarkProvider extends BaseProvider {
  constructor(private readonly token: string, options = {}) {
    super({
      kind: "postmark",
      limits: { perMonth: 100 },              // your quota accounting
      capabilities: { tags: true /* … */ },
      ...options,
    });
  }

  protected override defaultBaseUrl() {
    return "https://api.postmarkapp.com";
  }

  override isConfigured() {
    return this.token.length > 0;
  }

  override async send(message: NormalizedMessage, ctx: ProviderContext) {
    const response = await this.request({
      url: this.url("/email"),
      headers: { "x-postmark-server-token": this.token, "content-type": "application/json" },
      body: JSON.stringify({ From: message.from.email, To: message.to[0]!.email, Subject: message.subject, HtmlBody: message.html, TextBody: message.text }),
      ctx,
    });
    this.ensureOk(response);                   // maps status → retryable SDK error
    const data = response.json<{ MessageID: string }>()!;
    return { id: data.MessageID };
  }
}

registerProviderType("postmark", (config: { apiKey: string }) => new PostmarkProvider(config.apiKey));

const email = createEmailClient({ providers: [{ custom: "postmark", options: { apiKey: "…" } }] });

You can also pass instances directly: providers: [{ provider: myProvider, priority: 1 }], or extend the built-ins (ResendProvider, BrevoProvider, …). Override protected fail() / throw ProviderError to customise how a provider's errors are classified.


TypeScript

Everything is typed and exported: EmailClient, EmailClientOptions, SendEmailInput, SendResult, EmailProvider, UsageStore, ProviderStatus, QuotaState, event payloads, all config types and every error class.

import type { EmailProvider, ProviderEntry, SendResult, UsageStore } from "mailgrid";
import { AllProvidersFailedError, QuotaExhaustedError, ProviderError } from "mailgrid";

try {
  await email.send(input);
} catch (error) {
  if (error instanceof QuotaExhaustedError) {
    // every provider is spent — queue it for tomorrow
  } else if (error instanceof AllProvidersFailedError) {
    console.error(error.summary); // "resend (HTTP 503): provider_error; brevo: auth_error"
  } else if (error instanceof ProviderError && error.retryable) {
    // …
  }
}

Notes & non-goals

  • Idempotency. A provider that accepted a message but failed to answer can theoretically receive a duplicate when the send fails over. Pass idempotencyKey where supported (Resend, Maileroo) if this matters for your workload.
  • Free-tier numbers are defaults, not facts. Providers change plans; override limits to match your account.
  • Deliverability is per provider. Each provider requires its own domain verification. An unverified sender surfaces as auth_error and simply routes to a provider that can send.
  • Missing text parts are generated. When a message has HTML but no text, a plain-text alternative is derived from it (set autoText: false to keep bodies exactly as given). HTML-only mail is a deliverability own goal.
  • Unsubscribe links are opt-in per message. unsubscribe: true on a send adds the headers; the SDK never adds them to transactional mail on its own.
  • Not included: contact lists, marketing automation, inbound email parsing. mailgrid sends mail, reliably, through whatever capacity you have.

License

MIT