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

@alimzen/relayo

v0.4.0

Published

Provider-agnostic messaging for Node: one API to send email and SMS, and normalized delivery events.

Readme

@alimzen/relayo

Provider-agnostic email and SMS for Node.js: one API to send, one normalized event model for what comes back. This package holds the API, the contracts every driver implements, failover() and an in-memory driver for tests. Install a driver too: @alimzen/relayo-smtp, @alimzen/relayo-ses or @alimzen/relayo-sendgrid.

npm install @alimzen/relayo @alimzen/relayo-ses

Node.js 20+. Ships ESM and CommonJS.

Sending

import { createRelayo } from '@alimzen/relayo';
import { ses } from '@alimzen/relayo-ses';

const relayo = createRelayo({ email: ses({ region: 'us-east-1' }) });

const result = await relayo.email.send({
  from: { address: '[email protected]', name: 'Billing' },
  to: [{ address: '[email protected]' }],
  subject: 'Your October invoice',
  html: '<p>...</p>',
  text: '...',
  metadata: { invoiceId: '123' }, // comes back on every event
  tags: ['invoices'],
});
// { messageId: 'b1e…', providerMessageId: '0100…', provider: 'ses', status: 'queued' }

messageId is generated by Relayo and stays the same whatever provider sends the message. Store it next to your record if you want to match events by id; metadata usually does the job too.

| Message field | | | --- | --- | | to, cc, bcc, from, replyTo | { address, name? }. SMS uses E.164 numbers (+34600000000) | | subject, html, text | Email needs a subject and html or text. Templates are rendered before sending | | attachments | { filename, content: string \| Uint8Array, contentType?, cid? } | | headers | Extra email headers | | unsubscribe | { url?, mailto?, oneClick? }: adds List-Unsubscribe headers, one-click (RFC 8058) for https urls | | metadata | Record<string, string> carried to every event | | tags | Provider tags/categories, where supported | | providerOptions | Escape hatch: merged into the provider request as-is |

SMS works the same way through relayo.sms.send({ to, body }).

Many messages

sendMany sends a list, a few at a time, and tells you what happened to each one. It never rejects because one message failed: a bad address or a provider error affects only that message.

const outcomes = await relayo.email.sendMany(
  customers.map((c) => ({ from, to: { address: c.email }, subject: 'Your October invoice', html: invoiceHtml(c), metadata: { customerId: c.id } })),
  { concurrency: 5 }, // default 10
);
for (const [i, outcome] of outcomes.entries()) {
  if (outcome.ok) await saveMessageId(customers[i], outcome.messageId);
  else console.error(customers[i].email, outcome.error.message); // a RelayoError: check error.retryable
}

One email per recipient is what you usually want for newsletters and notifications: to: [a, b, c] sends one email that every recipient can see. sendBatch(messages) is the all-or-nothing variant: it validates everything first, uses the provider's batch API when the driver has one, and rejects if any send fails.

Several accounts

One instance can hold several sending accounts: another provider, or the same one with other credentials (transactional mail through SES, newsletters through a different SMTP, SMS through another account...).

const relayo = createRelayo({
  email: ses({ region: 'eu-west-1' }), // the default account
  accounts: {
    marketing: { email: smtp('smtps://news%40example.com:[email protected]') },
    alerts: { sms: twilio({ accountSid, authToken }) },
  },
});

await relayo.email.send(receipt); // default account
await relayo.account('marketing').email.sendMany(newsletter);
relayo.accounts; // ['marketing', 'alerts']

Webhooks of a named account go through its driver: relayo.handleWebhook('email', { ...req, account: 'marketing' }) (with the NestJS module, POST /webhooks/relayo/email?account=marketing). Their events carry event.account, so one handler can tell them apart; events of the default account have no account.

Events

relayo.on('bounced', (event) => {
  event.bounceType; // 'hard' | 'soft' | 'block'
  event.reason;     // 'user_unknown' | 'mailbox_full' | 'reputation_block' | ...
  event.metadata;   // { invoiceId: '123' }
});
relayo.on('*', (event) => log(event.type, event.recipient));

| Event | Meaning | Typical action | | --- | --- | --- | | accepted | The provider took the message | — | | delivered | The receiving server accepted it | — | | deferred | Temporary failure, the provider will retry | Wait | | bounced · hard | Permanent recipient failure | Mark the contact invalid | | bounced · soft | Retries exhausted on a temporary failure | Retry later or alert | | bounced · block | Refused for policy, reputation or authentication | Fix your sending setup, keep the contact | | rejected | Not even attempted (reason: 'suppressed', 'invalid_recipient'...) | Don't retry | | complained | Marked as spam | Stop sending to them | | opened, clicked | Tracking (clicked has url) | — | | unsubscribed | Opted out | Stop sending to them | | failed | The provider couldn't process the message | Retry or fail over |

Every event has id (stable, use it to deduplicate), type, channel, provider, messageId, providerMessageId, recipient, timestamp, metadata and raw (the provider's original payload). Bounces add smtpCode, enhancedCode and diagnostic.

Handlers run in order and are awaited. If one throws, handleWebhook throws too, so your endpoint can answer with an error and the provider will deliver the webhook again: make handlers idempotent.

Receiving webhooks

Give each provider's webhook request to handleWebhook. It verifies the signature, normalizes the payload and emits the events. Signed webhooks need the raw body, exactly as received:

import express from 'express';
import { ValidationError, WebhookVerificationError } from '@alimzen/relayo';

app.post('/webhooks/:provider', express.text({ type: '*/*' }), async (req, res) => {
  try {
    const events = await relayo.handleWebhook('email', {
      headers: req.headers,
      body: req.body,
      rawBody: req.body,
      provider: req.params.provider, // needed with failover()
    });
    res.json({ received: events.length });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return res.status(401).end();
    if (error instanceof ValidationError) return res.status(400).end();
    res.status(500).end(); // the provider will retry
  }
});

Using NestJS? @alimzen/relayo-nestjs ships this controller.

Errors

Every error is a RelayoError with a code, a retryable flag, the provider and the provider's details in raw:

| Error | code | retryable | | --- | --- | --- | | ValidationError | validation_error | no | | AuthenticationError | authentication_error | no | | RejectedError | rejected | no | | RateLimitError (with retryAfter seconds when known) | rate_limited | yes | | ProviderUnavailableError | provider_unavailable | yes | | WebhookVerificationError | webhook_verification_failed | no |

try {
  await relayo.email.send(message);
} catch (error) {
  if (isRelayoError(error) && error.retryable) await queue.retry(message, error);
  else throw error;
}

Relayo doesn't retry unless you ask it to. In production retries usually belong to your queue; if you don't have one, enable them in process:

createRelayo({
  email: ses({ region: 'us-east-1' }),
  retry: { attempts: 3, minDelayMs: 200, maxDelayMs: 5000, onRetry: ({ attempt, error }) => log(attempt, error.code) },
});

Only retryable errors are retried, with exponential backoff and jitter and the same messageId. A RateLimitError waits its retryAfter, or is thrown right away when that is longer than maxDelayMs. With failover(), each attempt goes through every provider again.

Unsubscribe

Gmail and Yahoo require one-click unsubscribe from bulk senders:

await relayo.email.send({
  ...message,
  unsubscribe: { url: `https://example.com/unsubscribe?t=${token}`, mailto: '[email protected]' },
});

The mailbox provider then POSTs List-Unsubscribe=One-Click to that url when the recipient clicks "Unsubscribe": handle it in your app and stop sending to them.

Failover

import { failover } from '@alimzen/relayo';

const relayo = createRelayo({
  email: failover([ses({ region: 'us-east-1' }), smtp(process.env.SMTP_URL!)], {
    onFailover: ({ from, to, error }) => logger.warn({ from, to, code: error.code }),
  }),
});
  • Tries drivers in order and moves on only after a retryable error (outage, rate limit). Validation, authentication and rejection errors stop right away.
  • Errors that aren't RelayoErrors (a bug, an unexpected exception) don't fail over either: they may have happened after the provider accepted the message, and switching would send it twice. Override with shouldFailover(error, driver).
  • Every attempt uses the same messageId. result.provider tells you who sent it.
  • Skips drivers that lack something the message needs (e.g. attachments).
  • If every driver fails, the last error is thrown.
  • Webhooks are routed by req.provider (one endpoint per provider is simplest); without it, to the first driver whose signature check accepts the request. Driver names must be unique.

Testing

memory() stores what you send and builds events for it, so you can test your handlers without a provider:

import { createRelayo, memory, RateLimitError } from '@alimzen/relayo';

const driver = memory();
const relayo = createRelayo({ email: driver });
relayo.on('bounced', markInvalid);

const { messageId } = await relayo.email.send(message);
expect(driver.sent).toHaveLength(1);

await relayo.emit(driver.createEvent(messageId, 'bounced', { bounceType: 'hard', reason: 'user_unknown' }));
expect(markInvalid).toHaveBeenCalled();

// Simulate provider errors:
memory({ fail: () => new RateLimitError('slow down', { retryAfter: 5 }) });

Writing a driver

A driver implements Driver<M>: name, channel, capabilities, and send(message, { messageId }). Optional: sendBatch, verifyWebhook, parseWebhook. Attach the messageId to the provider message (tag, header, custom argument) so webhooks can be mapped back, map provider errors to RelayoError subclasses, and use classifyBounce({ smtpCode, enhancedCode, diagnostic }) to classify bounces, refined with whatever the provider adds. Declare @alimzen/relayo as a peer dependency.