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