rhythmjs

Search documentation

Search guides, the tutorial and every package.

On this page

Start from the template. src/app.service.ts stays as it is.

Install#

Shell
bun add nodemailer
bun add -d @types/nodemailer

The 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.

TypeScript
// 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.

TypeScript
// 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#

TypeScript
// 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:

TypeScript
// 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 from or to from 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.