Messaging
Named channels for email and Telegram, with capture when credentials are absent
messaging is a named record of channels. Each channel names one provider, and
each provider carries its own message type. Send through
app.messaging.<channel>.send() on the server, or ctx.messaging.<channel>
inside a procedure or a job.
Declaration
import { bunderstack, resend, telegram } from 'bunderstack'
import { libsql } from 'bunderstack/libsql'
import * as v from 'valibot'
import * as schema from './schema'
export const backend = bunderstack({
schema,
env: {
server: {
RESEND_API_KEY: v.optional(v.string()),
TELEGRAM_BOT_TOKEN: v.optional(v.string()),
},
},
database: { adapter: libsql() },
// `messaging` holds credentials, so it also accepts a function of the
// validated environment. Which channels exist must not depend on a value.
messaging: (env) => ({
email: resend({ apiKey: env.RESEND_API_KEY, from: '[email protected]' }),
billing: resend({
apiKey: env.RESEND_API_KEY,
from: '[email protected]',
}),
ops: telegram({ botToken: env.TELEGRAM_BOT_TOKEN }),
}),
})
export const app = await backend.start()Two channels can name the same provider with a different sender. Only the channel names and provider kinds reach the manifest and the blueprint; a key, a token, and a sender address never do.
Sending
An email channel takes an email message:
await app.messaging.email.send({
to: '[email protected]',
subject: 'Welcome',
html: '<h1>Hello</h1>',
text: 'Hello',
})A message needs html or text. Everything else except to and subject is
optional, and a per-message from overrides the channel's own sender.
await app.messaging.billing.send({
to: ['[email protected]', '[email protected]'],
subject: 'Invoice 41',
text: 'Attached.',
replyTo: '[email protected]',
cc: '[email protected]',
bcc: '[email protected]',
})A Telegram channel takes a different message, and TypeScript enforces it:
await app.messaging.ops.send({
to: '@release_channel',
text: '*Deploy finished*',
parseMode: 'MarkdownV2',
})send() returns { id, providerId? }. id is the journal row; providerId
is the provider's own identifier when the provider returns one.
Capture
A channel whose required configuration is absent or empty captures instead of sending. Capture is not an error state and not a separate provider: every channel has it.
- Locally, a captured message is written to the journal and printed to the console.
- On a host (
BUNDERHOST_ENVIRONMENT_IDis set), it is written to the journal only. The body never reaches the production logs.
A key that is present but wrong is not capture. So is a provider that rejects
the request: both raise, and the journal row records failed.
For Resend that means apiKey and from, for Telegram botToken, and for a
custom adapter the adapter itself plus from.
Providers
Resend
messaging: (env) => ({
email: resend({ apiKey: env.RESEND_API_KEY, from: '[email protected]' }),
})SMTP
import { smtp } from 'bunderstack/email-smtp'
messaging: (env) => ({
email: smtp({ url: env.SMTP_URL, from: '[email protected]' }),
})SMTP uses nodemailer through its own subpath, so an application that does not declare it never loads it:
bun add nodemailerTelegram
messaging: (env) => ({
ops: telegram({ botToken: env.TELEGRAM_BOT_TOKEN }),
})to accepts a chat ID or an @channel name.
Custom email
Pass a full adapter or one send function:
import { customEmail } from 'bunderstack'
messaging: {
email: customEmail({
from: '[email protected]',
adapter: async (message) => {
// `from` is already resolved on the message.
await mySendService(message)
return { id: 'msg_123' }
},
}),
}Managed credentials
A host can supply credentials through the reserved
BUNDERSTACK_MESSAGING_CONFIG variable, a JSON object keyed by provider:
{ "resend": { "apiKey": "re_managed" }, "telegram": { "botToken": "123:abc" } }Every channel of one provider shares that connection. A field the channel
declares itself wins over the managed value, field by field, so a channel can
take the managed key and keep its own from. Each journal row records where
the credentials came from: explicit, managed, or capture.
Auth emails
Better Auth verification and password-reset mail goes through the channel named
email, and only that one, when its provider is an email provider. A channel
named anything else is never selected for it.
The journal
Every channel writes to _bunderstack_messages, with delivery attempts in
_bunderstack_message_events. A row carries the channel, the provider kind, the
credential source, the status (captured, sending, sent, or failed), the
recipients, the content, and the provider's message ID once it exists.
Testing
A test fixture substitutes an isolated capture for every declared channel, so nothing leaves the process and no fixture sees another fixture's messages:
await using t = await backend.test()
await t.app.messaging.email.send({
to: '[email protected]',
subject: 'Welcome',
text: 'Hello',
})
expect(t.messaging.email.sent).toHaveLength(1)
expect(t.messaging.ops.sent).toEqual([])Each sent array is typed by its own channel, so a Telegram capture never
type-checks against an email message.
Sending outside the app
Code that runs before or beside the started app — a Better Auth builder sending
an invitation, a script, a one-off worker — builds the same facades with
createMessaging:
import { createMessaging, defineAuth, resend } from 'bunderstack'
export const auth = defineAuth(schema, ({ db, env }) => {
const messaging = createMessaging(
{ email: resend({ apiKey: env.RESEND_API_KEY, from: env.EMAIL_FROM }) },
{ env, db },
)
return {
/* … */
}
})Pass db so those messages reach the same journal the application writes.
Capture, managed credentials, and provider errors behave exactly as they do on
app.messaging.
Exact channel types in a separate module
An inline jobs or api builder receives an open messaging type, because
TypeScript cannot infer the channel record and type a sibling callback in the
same pass. Declare the builder in its own module to get the exact type:
import type { BunderstackJobsBuilder } from 'bunderstack'
import type { ResendDescriptor } from 'bunderstack/messaging'
export const defineJobs = (
jobs: BunderstackJobsBuilder<typeof schema, AppEnv, { email: ResendDescriptor }>,
) => jobs.define({ ... })defineApi({ schema, env, messaging }) does the same for procedures.