Skip to content

mail

Package @theoven/mail
Adds to context ctx.mail
Endpoints /_oven/mail — the preview inbox, development only
Creates files none
Creates tables none
Status shipped
Terminal window
bun add @theoven/mail
src/app.ts
import { consoleMail, mail, resendMail } from '@theoven/mail'
export const app = createApp().use(
mail(
env.has('RESEND_API_KEY')
? resendMail({ apiKey: env.string('RESEND_API_KEY'), from: env.string('MAIL_FROM') })
: consoleMail(),
),
)

Gives you ctx.mail.send() and ctx.mail.sendTemplate(), with four drivers behind one interface: console, Resend, SES and SMTP. In development it mounts a preview inbox so you can read what your app sent without configuring a provider or sending anything real.

If the queue brick is registered, sends go through it automatically — with retries and backoff, and without your handler waiting on a third party.

Method Path Purpose Auth
GET /_oven/mail the preview inbox none
GET /_oven/mail/:id one captured message none

Mounted only when preview is on, which by default means development only. Set preview: '/some/path' to move it, or preview: false to turn it off.

await ctx.mail.send({
to: user.email,
subject: 'Welcome',
text: 'Thanks for signing up.',
html: '<p>Thanks for signing up.</p>',
})

A template is a function, not a file format:

src/emails/welcome.ts
import { defineTemplate, escapeHtml } from '@theoven/mail'
export const welcome = defineTemplate<{ name: string; link: string }>((props) => ({
subject: `Welcome, ${props.name}`,
html: `<h1>Hi ${escapeHtml(props.name)}</h1>
<a href="${escapeHtml(props.link)}">Confirm your address</a>`,
}))
await ctx.mail.sendTemplate({
to: user.email,
template: welcome,
props: { name: user.name, link },
})

Miss a prop and it is a compile error, not an email that says Welcome, undefined. That is the whole feature. A template engine here would be a second templating system to learn, a build step to configure, and a class of runtime error that a function signature already prevents.

sendTemplate is separate from send rather than an overload, because a templated message must not carry its own subject — a union would let one through and then quietly ignore it.

Use escapeHtml for anything a user typed. If you want JSX, render it yourself: React Email and Bun.renderToString both produce a string, and a template takes whatever you return.

Give only html and the text alternative is generated from it — links kept as text (url), scripts and styles dropped. A message with no text part scores worse with spam filters and is unreadable in a client that refuses HTML, so deriving one is strictly better than sending none. Pass your own text and it is left alone.

await ctx.mail.send({
to: customer.email,
subject: 'Your invoice',
text: 'Attached.',
attachments: [
{ filename: 'invoice.pdf', content: pdfBlob },
{ filename: 'logo.png', content: logoBlob, cid: 'logo' }, // <img src="cid:logo">
],
})

content is a Blob, bytes, or a string — not base64. An attachment usually starts life as an upload or a generated PDF, both of which are already one of those, and asking every caller to encode is asking them to hold the whole thing in memory as a string. Encoding happens at send time, so an attachment built from Bun.file() is not read until the message goes out.

http://localhost:3000/_oven/mail

Every message the app sends is held in memory and rendered there — headers, both bodies, and any attachments. It is how you check what a reset email actually looks like without sending one to yourself.

Mounted in development only. It serves message bodies, and those contain working password-reset links; an unauthenticated page serving them in production would be a way to take over accounts. Set preview: '/somewhere' to move it, preview: false to turn it off, or preview: true to force it on — which logs a warning outside development, because you should see that decision in your logs.

The HTML body renders inside a sandboxed iframe, and everything else is escaped. A preview shows whatever an email contains, and some of that comes from users.

Driver Sends Use for
consoleMail() prints to the terminal development, the default
memoryMail() collects in .sent tests
resendMail({ apiKey, from }) Resend’s HTTP API production
sesMail({ from, region }) Amazon SES v2 HTTP API production on AWS
smtpMail({ host, port, user, pass, from }) SMTP over a socket anything else
mail(sesMail({ from: env.string('MAIL_FROM'), region: env.string('AWS_REGION', 'us-east-1') }))

Credentials fall back to AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, which an instance role or a secrets mount already sets.

No AWS SDK. Requests are signed with SigV4 in about eighty lines — verified against AWS’s own published test vectors, not against itself. Acquiring the SDK’s dependency tree in order to send an email is a poor trade.

Attachments switch the request from SES’s Simple content to raw MIME, because Simple has nowhere to put a file. Without them, SES builds the MIME itself and does it better than we would.

mail(smtpMail({
host: env.string('SMTP_HOST'),
port: env.port('SMTP_PORT', 587),
user: env.string('SMTP_USER'),
pass: env.string('SMTP_PASS'),
from: env.string('MAIL_FROM'),
}))

Spoken directly over Bun.connect — EHLO, STARTTLS, AUTH, MAIL FROM, RCPT TO, DATA. No mailbox management, no pipelining, no connection pool.

Prefer an HTTP driver where you have one. SMTP means a long-lived TCP connection, a port that hosting providers block, and errors that arrive as three-digit codes.

The console driver is the default so that a freshly created app has a working password-reset flow: the link appears in your terminal, with no provider account and no API key. Laravel and Rails both ship the equivalent.

Files: none. Tables: none.

With the queue brick registered, sends become jobs named oven:mail:send in whatever store your queue uses — so a pending message survives a restart when the queue driver is durable.

src/routes/signup.post.ts
import { z } from 'zod'
export const body = z.object({ email: z.email(), name: z.string() })
export default async ({ body, mail, db }) => {
const user = await db.insert(users).values(body).returning()
await mail.send({
to: body.email,
subject: 'Welcome to Acme',
text: `Thanks for signing up, ${body.name}.`,
html: `<p>Thanks for signing up, ${body.name}.</p>`,
})
return user[0]
}

Wiring it into password reset, which is the most common reason to want mail at all:

src/app.ts
auth(
basicAuth({
db: client,
secret: env.string('AUTH_SECRET'),
sendResetEmail: (to, token) =>
mailer.send({
to,
subject: 'Reset your password',
text: `Reset here: ${env.string('APP_URL')}/reset?token=${token}`,
}),
}),
)

Reading what the inbox captured, from your own code:

ctx.mail.inbox?.all() // every captured message; `inbox` is undefined when preview is off
ctx.mail.inbox?.find(id) // one of them
ctx.mail.inbox?.clear()
ctx.mail.driver // 'resend', 'ses', 'smtp' or 'console'
const driver = memoryMail()
const app = createApp().use(mail(driver))
await request('/signup')
expect(driver.sent[0]?.to).toBe('[email protected]')
Option Default Purpose
from — default sender, used when a message sets none
allowConsoleInProduction false permit the console driver outside development
preview on in development mount the inbox; a string moves it, false turns it off
previewLimit 50 how many messages the inbox keeps
send, sendTemplate ✓ all drivers
Attachments, inline cid images ✓ all drivers
Automatic plain-text part from HTML ✓
Preview inbox ✓ development by default
Retries and backoff only with queue registered
Bounce / complaint webhooks ✗ — yours to receive
SMTP connection pooling ✗ — one connection per message
Bulk / marketing sends ✗ — this is transactional mail

What fails at boot: the console driver in production without allowConsoleInProduction; a Resend driver with no apiKey; an SES driver with no credentials it can resolve.

A send logs the driver and subject. A failure logs the same plus the cause — never the body. A failed password-reset email would otherwise put a working reset token into your log aggregator.

  • No retries without the queue brick. On its own, a send is a single attempt. Register queue and sends go through it automatically, with retries and backoff.
  • No bounce or complaint handling. Webhooks from Resend and SES are yours to receive.
  • No JSX renderer bundled. Templates return strings; render JSX yourself if you want it.
  • SMTP has no connection pool. One connection per message, which is fine for transactional volume and wrong for bulk.
  • The preview inbox is per process and in memory. It empties on restart and shows nothing from other instances.

SigV4 is checked against AWS’s published test vectors rather than against itself — a signature implementation that only agrees with its own tests agrees with nothing.

The SMTP driver is tested against a real SMTP server running in the test process, so the actual conversation is exercised: EHLO parsing, envelope addresses, dot-stuffing, and the refusal to authenticate over an unencrypted connection.

MIME assembly is checked for encoded-word subjects, 76-character base64 wrapping, inline Content-ID attachments, and that bcc never appears in the message — the whole meaning of blind.