Dispatch
Outbound message dispatcher for Bun + Elysia — email, SMS, and push behind one interface, with vendor adapter siblings for Resend, Postmark, and Twilio. Built-in OTel spans, audit-event integration, in-memory testing adapters, and a narrow ClientLike interface per adapter so vendor SDKs stay TRUE peer deps.
#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.
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 <[email protected]>' },
10});
11
12// Each channel is called directly — dispatch.email(...), dispatch.sms(...).
13await dispatch.email({
14 to: '[email protected]',
15 subject: 'Welcome',
16 text: 'Hi there!',
17});#Channels
Three channels — email, SMS, push — all optional. Each message shape carries an optional tenant field that propagates to OTel spans and audit events, plus an open metadata record adapters can interpret.
1# Three channels, one dispatcher
2
3 Each channel is OPTIONAL — pass only the adapters you wire. Calling
4 a channel that wasn't configured throws DispatchUnsupportedError so
5 the omission is loud, not silent.
6
7 createDispatcher({
8 email?: EmailAdapter,
9 sms?: SmsAdapter,
10 push?: PushAdapter,
11 audit?: AuditLike, // optional @absolutejs/audit
12 tracerProvider?: TracerProvider, // optional OTel via @absolutejs/telemetry
13 defaultFrom?: { email?: string, sms?: string },
14 onError?: (err, channel, message) => void,
15 });
16
17 The message shape per channel — every channel supports an optional
18 `tenant` field that propagates to spans + audit, and an open
19 `metadata` record adapters can interpret:
20
21 EmailMessage → { to, subject, text?, html?, from?, replyTo?,
22 cc?, bcc?, headers?, tenant?, metadata? }
23 SmsMessage → { to, body, from?, tenant?, metadata? }
24 PushMessage → { to, title?, body, data?, tenant?, metadata? }
25
26 Each call returns DispatchResult { at, id?, provider } so you can
27 correlate the send to the vendor's delivery webhook.#Adapters
Each vendor adapter is its own npm package — install only the ones you wire. In-memory and console adapters ship with the core package for tests + local dev.
1# Vendor adapter siblings
2
3 Each adapter is its own npm package — install only the ones you
4 wire. The narrow ClientLike interfaces keep the vendor SDKs as TRUE
5 peer deps; the adapter doesn't pull in postmark or twilio
6 transitively. Bring your own client.
7
8 Bundled (Apache 2.0 Tier B):
9
10 @absolutejs/dispatch-resend → createResendAdapter (email)
11 @absolutejs/dispatch-postmark → createPostmarkAdapter (email)
12 @absolutejs/dispatch-twilio → createTwilioAdapter (sms)
13
14 In-memory + console adapters ship with the core package — useful
15 for tests + local dev:
16
17 import {
18 memoryEmailAdapter, memorySmsAdapter, memoryPushAdapter,
19 consoleEmailAdapter, consoleSmsAdapter, consolePushAdapter,
20 } from '@absolutejs/dispatch';
21
22 Memory adapters keep an in-process FIFO buffer (default 1000); call
23 .inspect() to read a copy, .clear() to reset between tests. Console
24 adapters print the message as JSON and return immediately.#Observability
Every send fans out an OTel span with ABS_ATTRS-shaped attributes plus cumulative counters via dispatcher.metrics(). Wiring { audit } from @absolutejs/audit appends a dispatch.<channel>.sent / .failed event for every send.
1# Observability — spans + counters + audit
2
3 Each send fans out a single span under the channel's tracer:
4
5 dispatch.email.send
6 dispatch.sms.send
7 dispatch.push.send
8
9 with these attributes:
10
11 abs.tenant → message.tenant when set (ABS_ATTRS.tenant)
12 dispatch.channel → 'email' | 'sms' | 'push'
13 dispatch.provider → adapter.name ('resend', 'postmark', 'twilio', …)
14 dispatch.recipient → message.to (CSV-joined when to is an array)
15 dispatch.message_id → vendor id, set post-send when adapter returns one
16
17 dispatcher.metrics() returns cumulative counters:
18
19 {
20 sent: number,
21 failed: number,
22 byChannel: {
23 email: { sent: number, failed: number },
24 sms: { sent: number, failed: number },
25 push: { sent: number, failed: number },
26 }
27 }
28
29 Pass an { audit } shaped like @absolutejs/audit's writer and every
30 send appends one of:
31
32 dispatch.email.sent / dispatch.email.failed
33 dispatch.sms.sent / dispatch.sms.failed
34 dispatch.push.sent / dispatch.push.failed
35
36 with provider + messageId in metadata, message.tenant as actor (or
37 'system' when no tenant set), and the recipient as target.#Postmark
Transactional + broadcast streams via messageStream. Default metadata mapping routes metadata.tag to Postmark's Tag and string-valued entries to its Metadata map. Custom headers auto-convert to Postmark's [{Name, Value}] shape.
1# Postmark adapter — transactional + metadata routing
2
3 import { createPostmarkAdapter } from '@absolutejs/dispatch-postmark';
4 import { ServerClient } from 'postmark';
5
6 const email = createPostmarkAdapter({
7 client: new ServerClient(process.env.POSTMARK_TOKEN!),
8 defaultFrom: '[email protected]',
9 messageStream: 'outbound', // default
10 });
11
12 Postmark requires a `From` address — pass per-message or via
13 `defaultFrom`, otherwise the adapter throws a clear error.
14
15 By default the adapter extracts a `tag` field from
16 `message.metadata` into Postmark's Tag (used for analytics
17 segmentation) and routes every other string-valued metadata entry
18 into Postmark's Metadata map. Override the mapping via:
19
20 createPostmarkAdapter({
21 client,
22 mapMetadata: (metadata) => ({
23 Tag: metadata.tag as string | undefined,
24 Metadata: {
25 campaign: metadata.campaign as string,
26 tenant: metadata.tenant as string,
27 }
28 }),
29 });
30
31 Custom headers in the EmailMessage convert to Postmark's
32 [{Name, Value}] array shape automatically. SDK errors propagate; the
33 dispatcher's onError + span error capture kicks in.#Twilio
SMS via single-number routing or Messaging Service SID, with sender precedence message.from > defaultFrom > messagingServiceSid. Pass statusCallback to thread delivery webhooks through every send.
1# Twilio adapter — SMS with messaging-service or single-number routing
2
3 import { createTwilioAdapter } from '@absolutejs/dispatch-twilio';
4 import twilio from 'twilio';
5
6 const sms = createTwilioAdapter({
7 client: twilio(process.env.TWILIO_SID, process.env.TWILIO_TOKEN),
8 defaultFrom: '+15555550100', // OR
9 messagingServiceSid: 'MGxxxxxxxx', // OR — at least one required
10 statusCallback: 'https://example.com/twilio/status',
11 });
12
13 Sender precedence: message.from > defaultFrom > messagingServiceSid.
14 If none resolves, the adapter throws.
15
16 statusCallback threads through to every send so Twilio's delivery
17 webhooks fire to your own audit ingest URL. The adapter also
18 normalizes Twilio's documented bulk-send response shape — an
19 errorCode != null in the response body (rare but real) throws so
20 the dispatcher's failed counter + audit failure event fire.#Testing
The in-memory adapters keep an in-process FIFO buffer — call .inspect() to assert what would have shipped, .clear() to reset between tests. No vendor mocks needed.
1# Testing — memory adapters + .inspect() assertions
2
3 No vendor mocks. The in-memory adapters are the fast path for
4 testing notification flows.
5
6 import { createDispatcher, memoryEmailAdapter } from '@absolutejs/dispatch';
7
8 const email = memoryEmailAdapter();
9 const dispatch = createDispatcher({ email });
10
11 await dispatch.email({
12 to: '[email protected]',
13 subject: 'hi',
14 text: 'hi'
15 });
16
17 const sent = email.inspect();
18 expect(sent).toHaveLength(1);
19 expect(sent[0].subject).toBe('hi');
20
21 // Between tests:
22 email.clear();
23 expect(email.inspect()).toHaveLength(0);
24
25 consoleEmailAdapter() is the sibling for local dev — prints the
26 message as JSON to stdout, returns immediately. Useful when you
27 want to eyeball what would have shipped without spinning up a real
28 vendor account.
29
30 For error-path tests, wrap an adapter and have its .send() reject —
31 the dispatcher's failed counter, OTel span error, audit failure
32 event, and onError hook all fire on the same rejection.