rhythmjs

Search documentation

Search guides, the tutorial and every package.

On this page

Start from the template. src/app.service.ts and src/main.ts stay as they are.

Install#

Shell
bun add resend

A service wraps the client#

The client holds only an API key, so there is nothing to close. A plain service creates it lazily and sends; the app module puts the service on its startup context, and tests stub it. Until a domain is verified, send from Resend's onboarding@resend.dev, the default here.

TypeScript
// src/mail/mail.service.ts
import { Resend } from "resend";

const FROM = process.env.MAIL_FROM ?? "RhythmJS <onboarding@resend.dev>";

let client: Resend | undefined;

export const mailService = {
  send(to: string, subject: string, html: string) {
    client ??= new Resend(process.env.RESEND_API_KEY);
    return client.emails.send({ from: FROM, to, subject, html });
  },
};

export function escapeHtml(value: string): string {
  return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
}

The controller checks the result#

emails.send does not throw for API errors. It resolves to { data, error }, so the POST /contact route checks error and answers 502 instead of reporting success. User input is escaped before it goes into the HTML.

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 { escapeHtml, type mailService } from "./mail/mail.service";

export type AppContext = RhythmHttpContext & {
  appService: typeof appService;
  mailService: typeof mailService;
};

export const appController = new RhythmRouter<AppContext>()
  .get("/", (ctx) => {
    ctx.response.headers.set("content-type", "text/plain");
    ctx.response.body = ctx.appService.getHello();
  })
  .post("/contact", async (ctx) => {
    const { email, message } = (await ctx.request.json()) as { email?: string; message?: string };
    if (!email?.includes("@") || !message) {
      ctx.error(400, "a valid email and a message are required");
      return;
    }

    const { data, error } = await ctx.mailService.send(email, "We got your message", `<p>${escapeHtml(message)}</p>`);
    if (error) {
      ctx.error(502, "Could not send the email");
      return;
    }

    ctx.json({ id: data.id }, 202);
  });

Put the service on the context#

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 { mailService } from "./mail/mail.service";

export const appModule = new Rhythm<
  RhythmHttpContext,
  { appService: typeof appService; mailService: typeof mailService }
>({ name: "app", type: "module" })
  .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" });
  });

appModule.context.appService = appService;
appModule.context.mailService = mailService;

Things to check#

  • Tests stub mailService.send (for example with spyOn), so nothing touches the network.
  • RESEND_API_KEY is read when the first email is sent.

The full app, with tests, is examples/resend, built from the template.