Integrations
Resend
Send email through Resend's HTTP API: a small service wraps the client, and a failed send is a 502, not a false success.
On this page
Start from the template. src/app.service.ts and src/main.ts stay as they are.
Install#
bun add resendA 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.
// 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.
// 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#
// 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 withspyOn), so nothing touches the network. RESEND_API_KEYis read when the first email is sent.
The full app, with tests, is examples/resend, built from the template.