Skip to content

Email

An app sends mail through one SMTP server: Protobase’s own mail (password reset, emailed sign-in codes, invitations, staff sign-in notices and mentions in comments) and the emails the app defines itself. Both are React components, rendered to HTML with a plain-text part beside it.

Variable
PROTOBASE_SMTP_URL The SMTP server, credential included: smtp://user:password@host:587 (STARTTLS when the server offers it) or smtps://user:password@host:465 (TLS from the start).
PROTOBASE_MAIL_FROM The sender, a plain address such as [email protected]. The client greets the server with its domain; a ?name= on the URL overrides that.
PROTOBASE_APP_NAME Optional: the app’s name, such as Shop, which mail clients show as the sender’s name (Shop <[email protected]>). createAuth’s appName, when set, takes its place. Without either the sender has no name.

Set both or neither. One without the other, a URL that is not smtp:// or smtps://, or a sender that is not a plain address stops the app at startup. Without them mail is off: password reset and emailed codes are not offered, a job’s mail is undefined, and sending an app’s email rejects.

protobase dev and protobase serve read them from the environment or the nearest .env; a host passes them like DATABASE_URL. In development, a mail catcher such as Mailpit shows what the app sends without delivering it:

.env
docker run -d -p 8025:8025 -p 1025:1025 axllent/mailpit # its inbox is http://localhost:8025
PROTOBASE_SMTP_URL=smtp://localhost:1025
PROTOBASE_MAIL_FROM=[email protected]

A project that delivers mail another way passes a mailer, { send({ to, subject, text, html }) }, to createAuth and startJobs, or mailer: false to turn mail off whatever the environment says. A template’s send takes one too, see Templates.

The auth emails (sign-in, two-factor and sign-up codes, the note to an address that asked to sign up while it has an account, password reset, invitations and staff sign-in notices) and the email to someone a comment mentions are built with React Email’s components: a heading, the message, one code or button, and a footer naming the app.

  • They name the app by appName, and link it on the origin the request came to: the footer links its home page, and the note to an address that has an account links its sign-in page.
  • The plain-text part has each code on a line of its own and each link in full.
  • React Email and the templates are loaded when the first auth email is sent, so an app that sends none never loads them and the server starts as fast without them.

An email of the app’s own is defineEmail: a name, a subject, and a React component whose props are the email’s data. The components of React Email render the tables and inline styles email clients need. @protobase/server brings React’s renderer; the project adds the components, and React for its JSX, which a project with a UI has already:

Terminal window
bun add react-email react
bun add -d @types/react
config/emails/repair-ready.tsx
import { defineEmail } from '@protobase/server'
import { Body, Button, Container, Head, Heading, Html, Preview, Text } from 'react-email'
export type RepairReady = { customer: string; bike: string; number: string; url: string }
// The email's data is the component's props: `send` and `render` take exactly these.
const RepairReadyEmail = ({ customer, bike, number, url }: RepairReady) => (
<Html lang="en">
<Head />
<Preview>Your {bike} is ready for pickup</Preview>
<Body style={{ backgroundColor: '#f4f4f5', fontFamily: 'Helvetica, Arial, sans-serif' }}>
<Container style={{ backgroundColor: '#ffffff', padding: '24px', maxWidth: '480px' }}>
<Heading as="h1">Ready for pickup</Heading>
<Text>Hi {customer}, your {bike} is ready. Bring repair number {number} when you come by.</Text>
<Button href={url} style={{ backgroundColor: '#0f766e', color: '#ffffff', padding: '12px 20px', borderRadius: '6px' }}>
See the repair
</Button>
</Container>
</Body>
</Html>
)
export const repairReady = defineEmail({
name: 'repair-ready',
subject: ({ bike }: RepairReady) => `Your ${bike} is ready for pickup`,
component: RepairReadyEmail,
})
defineEmail option
name Names the email in errors.
subject The subject line, or a function of the data that makes it.
component The body, a React component of the data. It is rendered with React Email’s render to the HTML part; the plain-text part is made from that HTML, leaving out what React Email marks to skip, such as <Preview>.

The template that comes back has two methods, both typed by the component’s props:

  • render(data) gives { subject, html, text } without sending anything: for a test, or to look at the result.
  • send(to, data, mailer?) renders and sends it, through mailer when given (a job’s mail), otherwise through PROTOBASE_SMTP_URL. It rejects when mail is off or the server refuses the message.

React and React Email’s renderer are loaded on the first email rendered, not when the app starts.

A job is the usual place: it runs after the write that asked for it has committed, and a message the server refuses fails the try, which is retried. Pass the job’s mail, so a job runtime started with a mailer of its own sends through it:

config/jobs.ts
import { defineJob, FatalJobError } from '@protobase/server'
import { z } from 'zod'
import { repairReady } from './repair-ready'
// Enqueued by the write hook of a repair that became ready (see protobase.config.ts), so the email goes out only once
// the write has committed, and a refused message is retried.
export const emailRepairReady = defineJob({
name: 'email-repair-ready',
payload: z.object({ repairId: z.number() }),
run: async ({ repairId }, { db, mail }) => {
if (!mail) throw new FatalJobError('Mail is off: set PROTOBASE_SMTP_URL and PROTOBASE_MAIL_FROM')
const repair = await db
.selectFrom('repairs')
.innerJoin('customers', 'customers.id', 'repairs.customer_id')
.select(['repairs.number', 'repairs.bike', 'customers.name', 'customers.email'])
.where('repairs.id', '=', repairId)
.executeTakeFirst()
if (!repair?.email) return
const url = `https://bikeshop.example.com/repairs/${repair.number}`
await repairReady.send(repair.email, { customer: repair.name, bike: repair.bike, number: repair.number, url }, mail)
},
})

A write hook runs in the write’s transaction, which may still roll back. It enqueues the job rather than sending, so the email goes out only for a write that committed:

protobase.config.ts
import { defineConfig } from '@protobase/server'
import * as jobs from './jobs'
export default defineConfig({
config: {},
jobs,
options: {
writeHooks: [
// The job commits with the write, or not at all: no email about a change that was rolled back.
async (event, trx) => {
if (event.resource.name !== 'repairs' || event.after?.status !== 'ready' || event.before?.status === 'ready') return
await jobs.emailRepairReady.enqueue(trx, { repairId: Number(event.after.id) })
},
],
},
})

An API function may send directly, as the answer to a request:

functions/resend-repair-ready.ts
import { z } from 'zod'
import { defineFunction, readJson } from '@protobase/server'
import { repairReady } from './repair-ready'
const Input = z.object({ repairId: z.number().int().positive() })
// POST /api/functions/resend-repair-ready { "repairId": 7 }: the desk sends the email again, as the answer to the request.
export default defineFunction({ roles: ['desk'] }, async (request, { records }) => {
const { repairId } = await readJson(request, Input)
const { record: repair } = await records.get('repairs', repairId)
const { record: customer } = await records.get('customers', repair.customer_id as number)
if (!customer.email) return Response.json({ sent: false, reason: 'The customer has no email address' })
const number = repair.number as string
await repairReady.send(customer.email as string, { customer: customer.name as string, bike: repair.bike as string, number, url: `https://bikeshop.example.com/repairs/${number}` })
return Response.json({ sent: true })
})

A message is sent once per call, so a job try that sends and then fails sends again: record what was sent, and check it first.

A bundle gets React and React Email’s renderer from the serve runtime, as it gets @protobase/server, so an email’s component and the renderer share one React and hooks such as useContext work. React Email’s other components are inlined, though @protobase/server uses them too: it loads them only to send its own mail, so the runtime does not supply them. Its <Tailwind> component needs tailwindcss, which the runtime does not supply, so a served config cannot use it: style with style props instead.