@alimzen/relayo
v0.4.0
Published
Provider-agnostic messaging for Node: one API to send email and SMS, and normalized delivery events.
Maintainers
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-sesNode.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 withshouldFailover(error, driver). - Every attempt uses the same
messageId.result.providertells 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.
