sdxc
SearchCtrlK

[ The sdxc command ]

Make an email

Write an email template with sdxc make email, its subject and body typed by the copy it takes, and the test that renders it.

Last updated 2026-10-19

An email template is the message the app sends: a welcome, a receipt, a password reset. sdxc make email writes one in app/emails/, built with @sdxc/mail components, with a test that renders it:

sdxc make email welcome
Wrote app/emails/welcome.tsx
Wrote app/emails/welcome.test.tsx

Flags

FlagDefaultWhat it does
--test, --no-testmake.testswrite the test beside it, or skip it
--dry-runprint the files and change nothing

What it writes

An email class named after the file's last segment in PascalCase, and the copy it takes, already translated for its reader:

app/emails/welcome.tsx
import { defineEmail, Email } from "@sdxc/mail";

/** What the Welcome email says, already translated for its reader. */
export interface WelcomeCopy {
	heading: string;
	body: string;
}

/** The Welcome email, sent with `new WelcomeEmail(recipient, copy)`. */
export const WelcomeEmail = defineEmail<WelcomeCopy>({
	subject: (copy) => copy.heading,
	body: (handle) => () => (
		<Email.Layout preview={handle.props.heading} title={handle.props.heading}>
			<Email.Heading>{handle.props.heading}</Email.Heading>
			<Email.Text>{handle.props.body}</Email.Text>
			{/* TODO: lay out the rest of the email */}
		</Email.Layout>
	),
});

receipts/order-paid writes app/emails/receipts/order-paid.tsx exporting OrderPaidEmail and OrderPaidCopy. Nothing is declared or mapped: whatever sends it imports the class.

The test

app/emails/welcome.test.tsx
import { render } from "@sdxc/mail";
import { describe, expect, test } from "vitest";

import { WelcomeEmail } from "~/app/emails/welcome";

describe("WelcomeEmail", () => {
	test("is addressed to its recipient under its heading", async () => {
		let email = new WelcomeEmail("ada@example.com", {
			heading: "Hello",
			body: "Welcome.",
		});

		expect(email.to).toEqual({ email: "ada@example.com" });
		expect(email.subject).toBe("Hello");
		expect((await render(email.body())).text).toContain("Welcome.");
	});
});
bun run test app/emails/welcome.test.tsx

Fill it in

  1. Add the fields the message needs to its copy interface, such as a link or an amount, and lay them out with the Email components.

  2. Take the strings from your translations where you build the copy, so the email reads in the recipient's language.

  3. Send it through the mailer the router's mail() middleware publishes as ctx.email. later() sends after the response, so a slow provider never holds the request; send() waits and returns a Result:

ctx.email.later(new WelcomeEmail(user.email, { heading, body }));

Send email covers transports, previews and testing what was sent, and @sdxc/mail the components and the whole API.

Where to go next

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.