Skip to content

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.

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:

Terminal window
bun run email:preview # http://localhost:3100
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.

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 would
const link = await t.outbox.linkTo('ada@example.com', 'Your report');