Dispatch
Provider-agnostic outbound dispatcher for Bun + Elysia — send email, SMS, and push through one typed interface. Swap Resend, Postmark, or Twilio without touching call sites, test with the bundled in-memory adapters, and get OpenTelemetry spans and audit events on every send.
#Quick Start
createDispatcher() takes one optional adapter per channel. Each channel becomes a top-level callable — dispatch.email(message), dispatch.sms(message), dispatch.push(message). Calling a channel you didn't configure throws DispatchUnsupportedError, so the omission is loud, not silent.
1import { createDispatcher } from '@absolutejs/dispatch';
2import { createResendAdapter } from '@absolutejs/dispatch-resend';
3import { Resend } from 'resend';
4
5const dispatch = createDispatcher({
6 email: createResendAdapter({
7 client: new Resend(process.env.RESEND_API_KEY!),
8 }),
9 defaultFrom: { email: 'Example <noreply@example.com>' },
10});
11
12// Each channel is called directly — dispatch.email(...), dispatch.sms(...).
13await dispatch.email({
14 to: 'user@example.com',
15 subject: 'Welcome',
16 text: 'Hi there!',
17});#Channels
Three channels, all optional — configure only the ones you use. Every message shape carries an optional tenant field that propagates to spans and audit events, plus an open metadata record adapters can interpret.
dispatch.email(message)
to, subject, text?, html?, from?, replyTo?, cc?, bcc?, headers?, tenant?, metadata?
SMS
dispatch.sms(message)
to, body, from?, tenant?, metadata?
Push
dispatch.push(message)
to, title?, body, data?, tenant?, metadata?
Besides the per-channel adapters, createDispatcher() accepts:
| Option | Description |
|---|---|
email / sms / push | One optional adapter per channel. Only the channels you configure become callable. |
defaultFrom | Fallback sender per channel ({ email?, sms? }) when a message omits from. |
audit | Audit writer from @absolutejs/audit — appends a sent/failed event for every send. |
tracerProvider | OpenTelemetry TracerProvider (via @absolutejs/telemetry) — one span per send. |
onError | (err, channel, message) => void hook that fires on every failed send. |
Every channel call resolves to a DispatchResult { at, id?, provider } so you can correlate the send with the vendor's delivery webhook.
1// Every channel call returns a DispatchResult you can correlate
2// with the vendor's delivery webhook later.
3const result = await dispatch.sms({
4 to: '+15555550123',
5 body: 'Your code is 424242',
6 tenant: 'acme', // propagates to spans + audit
7 metadata: { campaign: 'signup' }, // open record adapters interpret
8});
9
10console.log(result); // { at: Date, id: 'SM…', provider: 'twilio' }#Adapters
Each vendor adapter is its own npm package — install only the ones you wire.
| Package | Version | Channel | Description |
|---|---|---|---|
@absolutejs/dispatch-resend | 0.0.1 | createResendAdapter — takes your Resend client; the Resend message id becomes the result id. | |
@absolutejs/dispatch-postmark | 0.0.1 | createPostmarkAdapter — transactional + broadcast streams; the MessageID becomes the result id. | |
@absolutejs/dispatch-twilio | 0.0.1 | SMS | createTwilioAdapter — single-number or Messaging Service routing; the Message SID becomes the result id. |
ClientLike interface instead of importing the vendor SDK, so installing an adapter never pulls in postmark or twilio transitively. You construct the client and hand it to the adapter.In-memory and console adapters ship with the core package for tests and local dev:
| Adapters | Behavior |
|---|---|
memoryEmailAdapter / memorySmsAdapter / memoryPushAdapter | In-process FIFO buffer (default 1000 messages). Call .inspect() to read a copy, .clear() to reset between tests. |
consoleEmailAdapter / consoleSmsAdapter / consolePushAdapter | Prints the message as JSON to stdout and returns immediately. Handy for local dev without a vendor account. |
#Observability
Every send is wrapped in a single OpenTelemetry span — dispatch.email.send, dispatch.sms.send, or dispatch.push.send — carrying these attributes:
| Attribute | Value |
|---|---|
dispatch.channel | 'email' | 'sms' | 'push' |
dispatch.provider | Adapter name — 'resend', 'postmark', 'twilio', … |
dispatch.recipient | message.to (CSV-joined when to is an array) |
dispatch.message_id | Vendor id, set after the adapter returns one |
abs.tenant | message.tenant when set |
dispatcher.metrics() returns cumulative counters since the dispatcher was created:
| Counter | Meaning |
|---|---|
sent | Cumulative successful sends across every channel. |
failed | Cumulative failed sends across every channel. |
byChannel | Per-channel { sent, failed } breakdown for email, sms, and push. |
Pass an { audit } writer shaped like @absolutejs/audit's and every send appends a dispatch.<channel>.sent or dispatch.<channel>.failed event — with the provider and message id in metadata, message.tenant as the actor ('system' when no tenant is set), and the recipient as the target.
#Postmark
Transactional and broadcast streams via messageStream. By default the adapter extracts a tag field from message.metadata into Postmark's Tag (used for analytics segmentation) and routes every other string-valued metadata entry into Postmark's Metadata map — override the mapping with mapMetadata.
1import { createPostmarkAdapter } from '@absolutejs/dispatch-postmark';
2import { ServerClient } from 'postmark';
3
4const email = createPostmarkAdapter({
5 client: new ServerClient(process.env.POSTMARK_TOKEN!),
6 defaultFrom: 'noreply@example.com',
7 messageStream: 'outbound', // default
8});
9
10// Override the default metadata mapping when you need full control
11// over Postmark's Tag + Metadata fields:
12const emailCustom = createPostmarkAdapter({
13 client: new ServerClient(process.env.POSTMARK_TOKEN!),
14 defaultFrom: 'noreply@example.com',
15 mapMetadata: (metadata) => ({
16 Tag: typeof metadata.tag === 'string' ? metadata.tag : undefined,
17 Metadata: {
18 campaign: String(metadata.campaign),
19 tenant: String(metadata.tenant),
20 },
21 }),
22});defaultFrom — otherwise the adapter throws a clear error before the send.Custom headers on the EmailMessage auto-convert to Postmark's [{Name, Value}] array shape. SDK errors propagate, so the dispatcher's onError hook and span error capture kick in.
#Twilio
SMS via single-number routing or a Messaging Service SID, with sender precedence message.from > defaultFrom > messagingServiceSid — if none resolves, the adapter throws. Pass statusCallback to thread Twilio's delivery webhooks through every send to your own ingest URL.
1import { createTwilioAdapter } from '@absolutejs/dispatch-twilio';
2import twilio from 'twilio';
3
4const sms = createTwilioAdapter({
5 client: twilio(process.env.TWILIO_SID, process.env.TWILIO_TOKEN),
6 defaultFrom: '+15555550100', // OR
7 messagingServiceSid: 'MGxxxxxxxx', // OR — at least one required
8 statusCallback: 'https://example.com/twilio/status',
9});errorCode != null in an otherwise-successful response body (rare but real). The adapter throws in that case, so the dispatcher's failed counter and audit failure event still fire.#Testing
No vendor mocks needed. The in-memory adapters keep an in-process FIFO buffer — call .inspect() to assert what would have shipped, .clear() to reset between tests.
1import { createDispatcher, memoryEmailAdapter } from '@absolutejs/dispatch';
2
3const email = memoryEmailAdapter();
4const dispatch = createDispatcher({ email });
5
6await dispatch.email({
7 to: 'a@b.c',
8 subject: 'hi',
9 text: 'hi',
10});
11
12const sent = email.inspect();
13expect(sent).toHaveLength(1);
14expect(sent[0].subject).toBe('hi');
15
16// Between tests:
17email.clear();
18expect(email.inspect()).toHaveLength(0);For error-path tests, wrap an adapter and have its .send() reject — the dispatcher's failed counter, span error, audit failure event, and onError hook all fire on the same rejection.