bunderstackbunderstack/ docs

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_ID is 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 nodemailer

Telegram

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.

On this page