bunderstackbunderstack/ docs

Messaging

Named channels for email and Telegram, receiving mail, and 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?, messageId? }. id is the journal row; providerId is the provider's own identifier when the provider returns one; messageId is the Message-ID header of the sent message when the provider makes it knowable (SES does, Resend does not).

Headers

headers adds headers to an email, for example to answer inside a thread:

const sent = await app.messaging.inbox.send({
  from: '[email protected]',
  to: '[email protected]',
  subject: 'Re: Interview',
  text: 'Monday works.',
  headers: {
    'In-Reply-To': '<[email protected]>',
    References: '<[email protected]> <[email protected]>',
  },
})
// Store sent.messageId: the recruiter's reply will name it in In-Reply-To.

A header that has its own field (From, To, Cc, Bcc, Subject, Reply-To) is refused, and so is a value that spans lines.

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 SES region, accessKeyId and secretAccessKey, 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]' }),
})

Amazon SES

import { ses } from 'bunderstack'

messaging: {
  email: ses({ from: 'App <[email protected]>' }),
}

On Bunderhost the region, credentials, configuration set and tenant come from the platform, and the key it supplies can only send from the project's own domains. Elsewhere, declare them:

messaging: (env) => ({
  email: ses({
    from: '[email protected]',
    region: 'eu-west-1',
    accessKeyId: env.AWS_SES_ACCESS_KEY_ID,
    secretAccessKey: env.AWS_SES_SECRET_ACCESS_KEY,
  }),
})

Sending is one signed SES v2 SendEmail request; the AWS SDK is not loaded. A channel without credentials captures. An SES channel may leave out from and give one with each message, which suits a channel that answers from the address that was written to.

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" } }

For SES the platform also supplies the receiving side under inbound: the secret it signs deliveries with and the domains routed to each channel.

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.

Receiving

An email channel declared with receive: true accepts the mail the host routes to it, and one jobs.inbound job handles each message:

export const backend = bunderstack({
  schema,
  database: { adapter: libsql() },
  messaging: {
    email: ses({ from: 'App <[email protected]>' }),
    inbox: ses({ receive: true }),
  },
  jobs: (jobs) =>
    jobs.define({
      fileMail: jobs.inbound('inbox', {
        concurrency: 3,
        handler: async (message, ctx) => {
          // message.from, .subject, .text, .html, .inReplyTo, .references,
          // .attachments, .verdicts, message.header('List-Id'), await message.raw()
        },
      }),
    }),
})

A receiving channel without exactly one handler, or a handler for a channel that does not receive, fails at start and in bunderstack blueprint. The handler is an ordinary job: retries, backoff, concurrency and onFailed work as for any other, and a retry reads the same message.

On Bunderhost, connecting a domain in the project's Messaging tab is what starts the flow: the platform receives the mail, parses it, finds the environment by the recipient's domain and delivers it to POST /api/_bunderstack/messaging/inbound, signed with a secret it supplies in BUNDERSTACK_MESSAGING_CONFIG. The framework checks the signature, writes the message to the journal with status received, and enqueues the handler. A repeated delivery of the same message is a no-op. Without the secret the endpoint accepts nothing.

app.messaging.inbox.receiving is true once at least one verified domain is routed to the channel, and app.messaging.inbox.domains lists those domains. Use them to decide whether to hand out addresses at all.

message.raw() downloads the original MIME message; the host's link expires seven days after the message arrived. text and html arrive capped at 1 MB each, with truncated set when either was cut.

Delivery events

On Bunderhost every message a hosted application sends is tagged with its journal id and environment, and the platform records delivery, bounce and complaint events against that row in _bunderstack_message_events.

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, failed, or received for incoming mail), 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.

A receiving channel takes mail with receive; run its handler with t.jobs:

await t.messaging.inbox.receive({
  from: 'Rita <[email protected]>',
  to: '[email protected]',
  subject: 'Interview',
  text: 'Monday?',
})
await t.jobs.runUntilIdle()

Only from and to are required. Pass providerId twice to test a repeated delivery, and raw to control what message.raw() returns.

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