Integrations
Nodemailer
Send mail over SMTP: build the transport once as a provider, close it on teardown, and call it from a controller.
On this page
Start from the template. src/app.service.ts stays as it is.
Install#
bun add nodemailer
bun add -d @types/nodemailerThe transport is a provider#
A transport holds connection settings, so it is built once as a provider and closed on teardown. Without SMTP_HOST, the JSON transport builds each message and returns it instead of sending, so the app runs and tests with no mail server. The #transport key stays off the request context, but closeMailer still receives it.
// src/mail/mail.ts
import nodemailer from "nodemailer";
export function createMailer() {
const transport = process.env.SMTP_HOST
? nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT ?? 1025),
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } : undefined,
})
: nodemailer.createTransport({ jsonTransport: true });
return {
mailer: {
sendWelcome: (to: string, name: string) =>
transport.sendMail({
from: "RhythmJS <hello@example.com>",
to,
subject: `Welcome, ${name}`,
text: `Hi ${name}, thanks for signing up.`,
}),
},
"#transport": transport,
};
}
export function closeMailer(value: ReturnType<typeof createMailer>) {
value["#transport"].close();
}
export type Mailer = ReturnType<typeof createMailer>["mailer"];The controller sends mail#
The template's controller gets mailer in its context type and a POST /signup route.
// src/app.controller.ts
import { RhythmRouter } from "@rhythmjs/router";
import type { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
import type { appService } from "./app.service";
import type { Mailer } from "./mail/mail";
export type AppContext = RhythmHttpContext & {
appService: typeof appService;
mailer: Mailer;
};
export const appController = new RhythmRouter<AppContext>()
.get("/", (ctx) => {
ctx.response.headers.set("content-type", "text/plain");
ctx.response.body = ctx.appService.getHello();
})
.post("/signup", async (ctx) => {
const { name, email } = (await ctx.request.json()) as { name?: string; email?: string };
if (!name || !email?.includes("@")) {
ctx.error(400, "name and a valid email are required");
return;
}
const info = await ctx.mailer.sendWelcome(email, name);
ctx.json({ messageId: info.messageId }, 201);
});Provide it in the module#
// src/app.module.ts
import { Rhythm } from "@rhythmjs/rhythm";
import type { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
import { appController } from "./app.controller";
import { appService } from "./app.service";
import { closeMailer, createMailer } from "./mail/mail";
export const appModule = new Rhythm<RhythmHttpContext>({ name: "app", type: "module" })
.provide(() => ({ appService }))
.provide(createMailer, closeMailer)
.use(appController.middleware())
.use((ctx) => {
ctx.response.status = 404;
ctx.response.headers.set("content-type", "application/json");
ctx.response.body = JSON.stringify({ success: false, status: 404, message: "Not Found" });
});main.ts closes the app on SIGINT, which closes the transport:
// src/main.ts
import { toFetchHandler } from "@rhythmjs/router/fetch";
import { appModule } from "./app.module";
const port = Number(process.env.PORT ?? 3009);
const server = Bun.serve({ port, fetch: toFetchHandler(appModule) });
console.log(`listening on ${server.url}`);
process.on("SIGINT", async () => {
await server.stop();
await appModule.teardown();
process.exit(0);
});Things to check#
- A slow SMTP server slows the response. Hand mail that is not part of the answer to a queue.
- Never build
fromortofrom unchecked input. - For a real inbox locally, run Mailpit and set
SMTP_HOST=localhost.
The full app, with tests, is examples/nodemailer, built from the template.