Send email
Every email is a React component in apps/api/src/mail/templates, sent through the
job queue so a mail server that is down delays email instead of losing
it. To connect a real provider, see Email provider.
Write it
Section titled “Write it”Create apps/api/src/mail/templates/report-ready.tsx:
import { appConfig } from '@saas-stack/contracts';import { Layout, Paragraph } from './layout.js';
interface Props { name: string; project: string; url: string;}
export const subject = ({ project }: Props) => `Your report for ${project} is ready`;
export default function ReportReady({ name, project, url }: Props) { return ( <Layout preview={`The report for ${project} is ready to read.`} heading="Your report is ready" action={{ label: 'Read the report', url }} footnote={`You are getting this because you asked ${appConfig.name} for a report.`} > <Paragraph>Hello {name},</Paragraph> <Paragraph>The report for {project} is ready.</Paragraph> </Layout> );}
ReportReady.PreviewProps = { name: 'Ada', project: 'Engine', url: 'http://localhost:5173',} satisfies Props;Use inline styles, as Layout does: mail apps ignore most CSS. Use appConfig.name rather than
writing your product’s name.
Register it in apps/api/src/mail/emails.tsx. The key is the name you send it by:
import ReportReady, { subject as reportReadySubject } from './templates/report-ready.js';
export const emails = { // ... reportReady: { template: ReportReady, subject: reportReadySubject },};See every template in the browser, reloading as you edit:
bun run email:preview # http://localhost:3100Send it
Section titled “Send it”await this.mail.queue(user.email, 'reportReady', { name: user.name, project: project.name, url: `${this.env.WEB_URL}${workspacePath(this.app, workspace.slug, '/projects')}`,});Inject MailService from ../mail/mail.service.js; nothing sends email any other way. Build
links into the app with workspacePath from the contracts: it gives the right address with
personal accounts and with teams.
Pass { key: 'report-ready:<id>' } to send only once. Queued inside a
transaction, it is sent only if the transaction
commits:
return this.database.transaction(async () => { const [report] = await this.db.client.insert(reports).values(values).returning(); await this.mail.queue(user.email, 'reportReady', props, { key: `report-ready:${report!.id}` }); return report!;});Locally every email lands in Mailpit at http://localhost:8025. Email is tried 10 times over about 2 hours before it gives up.
Test it
Section titled “Test it”Tests send nothing; every email lands in an outbox. Always name the email you want by its subject, because other emails, such as the welcome email, can arrive at any moment:
const email = await t.outbox.latestTo('ada@example.com', 'Your report for Engine');expect(email.text).toContain('The report for Engine is ready.');
// The link in it, to follow as the person wouldconst link = await t.outbox.linkTo('ada@example.com', 'Your report');