QUESTPIE
Build Your BackendBusiness Logic

Emails

Define email templates with Zod-validated input and send them from any handler.

Email templates are defined in emails/ and discovered by codegen. Each template has a Zod input schema and a handler that returns { subject, html }.

Defining an Email Template

emails/appointment-confirmation.ts
import { email } from "questpie";
import { z } from "zod";

export default email({
	name: "appointment-confirmation",
	schema: z.object({
		customerName: z.string(),
		appointmentId: z.string(),
		barberName: z.string(),
		serviceName: z.string(),
		scheduledAt: z.string(),
	}),
	handler: ({ input }) => ({
		subject: "Appointment Confirmed",
		html: `
      <h1>Appointment Confirmed</h1>
      <p>Hi ${input.customerName}, your appointment is confirmed!</p>
      <p><strong>Service:</strong> ${input.serviceName}</p>
      <p><strong>Barber:</strong> ${input.barberName}</p>
      <p><strong>Date:</strong> ${input.scheduledAt}</p>
      <p>Booking ID: #${input.appointmentId.slice(0, 8).toUpperCase()}</p>
    `,
	}),
});

The filename becomes the template key: appointment-confirmation.tsemail.sendTemplate({ template: "appointmentConfirmation", ... }).

Sending Emails

Use email.sendTemplate() from any handler:

routes/confirm-appointment.ts
import { route } from "questpie";
import { z } from "zod";

export default route()
	.post()
	.schema(z.object({ appointmentId: z.string() }))
	.handler(async ({ input, collections, email }) => {
		const appointment = await collections.appointments.findOne({
			where: { id: input.appointmentId },
			with: { barber: true, service: true },
		});

		await email.sendTemplate({
			template: "appointmentConfirmation",
			to: appointment.customerEmail,
			input: {
				customerName: appointment.customerName,
				appointmentId: appointment.id,
				barberName: appointment.barber.name,
				serviceName: appointment.service.name,
				scheduledAt: appointment.scheduledAt.toISOString(),
			},
		});

		return { success: true };
	});

Handler Context

Email handlers receive the full AppContext plus validated input:

export default email({
	name: "weekly-digest",
	schema: z.object({ userId: z.string() }),
	handler: async ({ input, collections, db }) => {
		// Full AppContext available — fetch data, call services
		const user = await collections.users.findOne({
			where: { id: input.userId },
		});
		const recentPosts = await collections.posts.find({
			where: { createdAt: { gte: oneWeekAgo() } },
			limit: 5,
		});

		return {
			subject: `Weekly digest for ${user.name}`,
			html: renderDigestHtml(user, recentPosts.docs),
			text: renderDigestText(user, recentPosts.docs), // optional plain text
		};
	},
});

Email Result

The handler must return:

PropertyTypeRequiredDescription
subjectstringYesEmail subject line
htmlstringYesHTML body
textstringNoPlain text fallback

Sending from Hooks and Jobs

In a collection hook
.hooks({
  afterChange: async ({ data, operation, email }) => {
    if (operation === "create") {
      await email.sendTemplate({
        template: "appointmentConfirmation",
        to: data.customerEmail,
        input: { ... },
      });
    }
  },
})
In a background job
export default job({
	handler: async ({ input, email }) => {
		await email.sendTemplate({
			template: "weeklyDigest",
			to: input.email,
			input: { userId: input.userId },
		});
	},
});

On this page