Your First Email
Build a working email setup with channels, routes, sub-catalogs, and a typed client
This guide walks you through the moving parts of a Better-Notify project: channels, routes, sub-catalogs, and the typed client.
By the end, you will understand why mail.transactional.welcome.send(...) only compiles when the route exists and the input matches its schema.
If you have not installed yet, start with Installation.
The big picture
A Better-Notify project has four layers: a channel defines message shape, routes declare individual notifications, a catalog organizes routes into a tree, and a client ties the catalog to transports for delivery.
The file structure for this walkthrough:
Set up Gmail SMTP
Before writing any code, you need a Gmail app password. Gmail requires a dedicated app password for SMTP.
Enable 2-Step Verification
App passwords require 2-Step Verification on your Google account. If you have not enabled it yet, go to your Google Account security settings and turn it on.
Generate an app password
Go to App Passwords and create a new app password. Give it a name like "Better-Notify" and copy the generated 16-character password.
Add credentials to your environment
Create a .env file at the root of your project:
GMAIL_USER=you@gmail.com
GMAIL_APP_PASSWORD=xxxx xxxx xxxx xxxxReplace you@gmail.com with your Gmail address and paste the app password you just generated.
Never commit .env files to version control. Add .env to your .gitignore.
Define the email channel
The channel sets shared defaults for every email route.
import { emailChannel } from '@betternotify/email';
export const email = emailChannel({
defaults: {
from: { name: 'My App', email: process.env.GMAIL_USER! },
},
});Every route built with rpc.email() inherits this from address. Individual routes can override it.
Define routes and sub-catalogs
Routes are contracts. Each one declares the input it accepts, the subject line, and how it renders. Group related routes into sub-catalogs, then compose them into a root catalog.
import { createNotify } from '@betternotify/core';
import { z } from 'zod';
import { email } from './channel';
export const rpc = createNotify({ channels: { email } });
const transactional = rpc.catalog({
welcome: rpc
.email()
.input(
z.object({
name: z.string(),
verifyUrl: z.string().url(),
}),
)
.subject(({ input }) => `Welcome, ${input.name}!`)
.template({
render: async ({ input }) => ({
text: `Welcome, ${input.name}! Verify here: ${input.verifyUrl}`,
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #1a1a1a;">Welcome, ${input.name}!</h1>
<p>Thanks for signing up. Verify your account to get started:</p>
<a href="${input.verifyUrl}"
style="display: inline-block; padding: 12px 24px; background: #2563eb; color: #fff; text-decoration: none; border-radius: 6px;">
Verify your account
</a>
</div>
`,
}),
}),
passwordReset: rpc
.email()
.input(
z.object({
name: z.string(),
resetUrl: z.string().url(),
}),
)
.subject(() => 'Reset your password')
.template({
render: async ({ input }) => ({
text: `Hi ${input.name}, reset your password: ${input.resetUrl}`,
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #1a1a1a;">Password reset</h1>
<p>Hi ${input.name}, someone requested a password reset for your account.</p>
<a href="${input.resetUrl}"
style="display: inline-block; padding: 12px 24px; background: #2563eb; color: #fff; text-decoration: none; border-radius: 6px;">
Reset your password
</a>
<p style="color: #666; font-size: 14px; margin-top: 24px;">If you did not request this, ignore this email.</p>
</div>
`,
}),
}),
});
const marketing = rpc.catalog({
newsletter: rpc
.email()
.input(
z.object({
headline: z.string(),
bodyUrl: z.string().url(),
}),
)
.subject(({ input }) => input.headline)
.template({
render: async ({ input }) => ({
text: `${input.headline} — read more: ${input.bodyUrl}`,
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #1a1a1a;">${input.headline}</h1>
<p><a href="${input.bodyUrl}" style="color: #2563eb;">Read more</a></p>
</div>
`,
}),
}),
});
export const catalog = rpc.catalog({ transactional, marketing });rpc.catalog() accepts both email routes and other catalogs at the same level. Sub-catalogs flatten into dot-path IDs — transactional.welcome, transactional.passwordReset, marketing.newsletter — which the typed client exposes as nested properties.
This guide uses inline HTML templates to keep things simple. If you prefer writing templates as React components, see React Email.
Create the client
The client connects the catalog to a transport. Wire it to Gmail's SMTP server:
import { createClient } from '@betternotify/core';
import { smtpTransport } from '@betternotify/smtp';
import { catalog } from './routes';
import { email } from './channel';
export const mail = createClient({
catalog,
transportsByChannel: {
email: smtpTransport({
host: 'smtp.gmail.com',
port: 587,
auth: {
user: process.env.GMAIL_USER!,
pass: process.env.GMAIL_APP_PASSWORD!,
},
}),
},
});mail is now a typed client. Autocomplete shows mail.transactional.welcome, mail.transactional.passwordReset, and mail.marketing.newsletter — nothing more, nothing less.
Send your first email
Send a welcome email to yourself:
import { mail } from './notifications/client';
const result = await mail.transactional.welcome.send({
to: process.env.GMAIL_USER!,
input: {
name: 'Ada Lovelace',
verifyUrl: 'https://example.com/verify?token=abc123',
},
});
console.log('sent! messageId:', result.messageId);Run it and check your inbox. You should see a styled welcome email from your own Gmail address.
Send across sub-catalogs
Now send from both sub-catalogs:
import { mail } from './notifications/client';
const welcome = await mail.transactional.welcome.send({
to: process.env.GMAIL_USER!,
input: {
name: 'Ada Lovelace',
verifyUrl: 'https://example.com/verify?token=abc123',
},
});
console.log(`[transactional.welcome] messageId: ${welcome.messageId}`);
const reset = await mail.transactional.passwordReset.send({
to: process.env.GMAIL_USER!,
input: {
name: 'Ada Lovelace',
resetUrl: 'https://example.com/reset?token=xyz789',
},
});
console.log(`[transactional.passwordReset] messageId: ${reset.messageId}`);
const newsletter = await mail.marketing.newsletter.send({
to: process.env.GMAIL_USER!,
input: {
headline: 'This week in notifications',
bodyUrl: 'https://example.com/blog/weekly',
},
});
console.log(`[marketing.newsletter] messageId: ${newsletter.messageId}`);Three emails, three routes, two sub-catalogs — all type-checked and delivered through the same SMTP transport. Check your inbox to see all three.
Type safety in action
Try passing the wrong input to a route:
// Type error: Property 'verifyUrl' is missing
await mail.transactional.welcome.send({
to: 'ada@example.com',
input: { name: 'Ada' },
});
// Type error: 'signup' does not exist on 'transactional'
await mail.transactional.signup.send({ to: 'ada@example.com', input: {} });
// Runtime error: invalid input (Zod validation)
await mail.transactional.welcome.send({
to: 'ada@example.com',
input: { name: 'Ada', verifyUrl: 'not-a-url' },
});TypeScript catches the first two at compile time. Zod catches the third at runtime. The catalog enforces both layers.
Use a mock transport for tests
To skip real sends in tests or CI, swap the transport without touching routes or the catalog:
import { createClient } from '@betternotify/core';
import { createMockTransport } from '@betternotify/core/transports';
import { catalog } from './routes';
import { email } from './channel';
const transport = createMockTransport();
const mail = createClient({
catalog,
transportsByChannel: { email: transport },
});
await mail.transactional.welcome.send({
to: 'test@example.com',
input: {
name: 'Test User',
verifyUrl: 'https://example.com/verify?token=test',
},
});
console.log(transport.sent.length);
// → 1
console.log(transport.sent[0].ctx.route);
// → 'transactional.welcome'Same catalog, same routes, same client shape. Only the transport changed. Read more about the mock transport.
Where to go next
You now have a working setup with typed procedures, merged catalogs, and real email delivery. From here:
- Read Channels to understand how email, SMS, push, and custom channels work.
- Read Catalog & Procedures for deeper coverage of procedure composition and dot-path route IDs.
- Read Transports Overview to learn about multi-transport composition and failover.
- Read Middleware when you want rate limits, tracing, or event logging across procedures.