Email Notifications
Development: console adapter
Section titled “Development: console adapter”Use the built-in console adapter during development. It logs every email to your terminal — no API key, no SMTP server, no external service needed.
import { consoleEmailAdapter } from "@porulle/core";
export default defineConfig({ email: consoleEmailAdapter(),});When an email is triggered, you see output like:
============================================================ EMAIL: order-confirmation============================================================ To: customer@example.com Template: order-confirmation Data: orderId: "ord_abc123" total: 13997 currency: "USD"============================================================Production: Resend
Section titled “Production: Resend”bun add @porulle/adapter-resendimport { resendAdapter } from "@porulle/adapter-resend";
export default defineConfig({ email: resendAdapter({ apiKey: process.env.RESEND_API_KEY!, from: "Acme Store <orders@acme.com>", }),});Production: AWS SES
Section titled “Production: AWS SES”bun add @porulle/adapter-sesimport { sesAdapter } from "@porulle/adapter-ses";
export default defineConfig({ email: sesAdapter({ region: "us-east-1", from: "Acme Store <orders@acme.com>", credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, }, }),});The sender address must be verified in SES. If your account is in the SES sandbox, recipient addresses must also be verified.
Both adapters ship with default HTML templates for all built-in email types. No additional configuration is needed for basic use.
Customize templates
Section titled “Customize templates”Override subjects and HTML bodies per template key:
email: resendAdapter({ apiKey: process.env.RESEND_API_KEY!, from: "Acme Store <orders@acme.com>",
subjects: { "order-confirmation": (data) => `Thanks for your order #${String(data.orderId).slice(0, 8)}!`, },
templates: { "order-confirmation": (data) => ` <h1>Order Confirmed</h1> <p>Hi! Your order <strong>#${data.orderId}</strong> is confirmed.</p> <p>Total: ${data.currency} ${(Number(data.total) / 100).toFixed(2)}</p> <a href="https://mystore.com/orders/${data.orderId}">View Order</a> `, },}),If you use Resend’s dashboard templates, pass template IDs:
email: resendAdapter({ apiKey: process.env.RESEND_API_KEY!, from: "Acme Store <orders@acme.com>", resendTemplateIds: { "order-confirmation": "tmpl_abc123", "password-reset": "tmpl_def456", },}),Built-in template keys
Section titled “Built-in template keys”| Template | Triggered by | Data fields |
|---|---|---|
order-confirmation | Checkout completion | orderId, total, currency |
order-status-change | Order status hook | orderId, newStatus, previousStatus |
password-reset | Better Auth password reset | url |
email-verification | Better Auth email verification | url |
appointment:reminder | Appointment plugin (24h + 1h before) | bookingId, reminderType |
appointment:confirmation-notice | Appointment booking confirmed | bookingId, providerId |
appointment:cancellation-notice | Appointment booking cancelled | bookingId |
appointment:no-show-notice | Appointment marked no-show | bookingId |
Write a custom email adapter
Section titled “Write a custom email adapter”The email interface requires a single send method:
interface EmailAdapter { send(input: { template: string; to: string; data?: Record<string, unknown>; }): Promise<void>;}Any object matching this signature works. Example SendGrid adapter:
import sgMail from "@sendgrid/mail";
export function sendgridAdapter(options: { apiKey: string; from: string }) { sgMail.setApiKey(options.apiKey);
return { async send(input: { template: string; to: string; data?: Record<string, unknown> }) { await sgMail.send({ to: input.to, from: options.from, subject: input.template, html: `<pre>${JSON.stringify(input.data, null, 2)}</pre>`, }); }, };}Related
Section titled “Related”- Adapter Reference — full
EmailAdapterinterface definition - Hook System guide — register hooks that trigger emails
- Deployment guide — environment variables for API keys