How to Send Emails in Bun (2026 Guide)

Bun is the fastest JavaScript runtime for a reason. Native TypeScript support, built-in fetch, automatic .env loading, and a package manager that makes npm look slow. For sending emails, that means zero config overhead: bun add a provider SDK, create a route, and you're done.
But "sending one email" is the easy part. The hard part is building an email system that handles welcome flows, password resets, payment receipts, failed payment alerts, and onboarding sequences. That's what this guide actually covers. If you're using a framework on top of Bun, check out our Hono or Elysia guides instead.
All code examples use TypeScript, Bun.serve, and the Elysia framework. Every section includes a provider switcher so you can compare Sequenzy, Resend, and SendGrid side by side.
Pick an Email Provider
You have three solid options. Each code example below lets you switch between them.
- Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one SDK. If you're building a SaaS product, this is the simplest path because you won't need to glue together three different tools later. Built-in retries and native Stripe integration.
- Resend is a developer-friendly transactional email API. Clean DX, good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
- SendGrid is the enterprise standard. Feature-rich, sometimes complex. Good if you need high volume and don't mind a bigger API surface.
Install Your SDK
bun add sequenzybun add resendbun add @sendgrid/mailAdd your API key to .env. Bun loads it automatically, no dotenv needed:
SEQUENZY_API_KEY=sq_your_api_key_hereRESEND_API_KEY=re_your_api_key_hereSENDGRID_API_KEY=SG.your_api_key_hereInitialize the Client
Create a shared email client. Bun's module caching means this runs once regardless of how many files import it.
import Sequenzy from "sequenzy";
// Reads SEQUENZY_API_KEY from Bun.env automatically
export const sequenzy = new Sequenzy();import { Resend } from "resend";
export const resend = new Resend(Bun.env.RESEND_API_KEY);import sgMail from "@sendgrid/mail";
sgMail.setApiKey(Bun.env.SENDGRID_API_KEY!);
export { sgMail };Send Your First Email
The simplest possible email with Bun.serve. No framework, just the built-in HTTP server.
import { sequenzy } from "./email";
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/api/send") {
const result = await sequenzy.transactional.send({
to: "user@example.com",
subject: "Hello from Bun",
body: "<p>Your app is sending emails. Nice.</p>",
});
return Response.json({ jobId: result.jobId });
}
return new Response("Not found", { status: 404 });
},
});
console.log("Server running on http://localhost:3000");import { resend } from "./email";
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/api/send") {
const { data, error } = await resend.emails.send({
from: "Your App <noreply@yourdomain.com>",
to: "user@example.com",
subject: "Hello from Bun",
html: "<p>Your app is sending emails. Nice.</p>",
});
if (error) {
return Response.json({ error: error.message }, { status: 500 });
}
return Response.json({ id: data?.id });
}
return new Response("Not found", { status: 404 });
},
});
console.log("Server running on http://localhost:3000");import { sgMail } from "./email";
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/api/send") {
try {
await sgMail.send({
to: "user@example.com",
from: "noreply@yourdomain.com",
subject: "Hello from Bun",
html: "<p>Your app is sending emails. Nice.</p>",
});
return Response.json({ success: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to send";
return Response.json({ error: message }, { status: 500 });
}
}
return new Response("Not found", { status: 404 });
},
});
console.log("Server running on http://localhost:3000");Run and test:
bun run src/index.ts
curl -X POST http://localhost:3000/api/sendBuild Email Templates with React Email
Raw HTML strings get messy fast. React Email lets you build email templates as React components that compile to email-safe HTML. Bun has first-class JSX support, so React Email works out of the box.
bun add @react-email/componentsHere's a welcome email template:
// emails/welcome.tsx
import {
Body,
Container,
Head,
Heading,
Html,
Link,
Preview,
Text,
} from "@react-email/components";
interface WelcomeEmailProps {
name: string;
loginUrl: string;
}
export default function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Welcome to the team, {name}</Preview>
<Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
<Container
style={{
backgroundColor: "#ffffff",
padding: "40px",
borderRadius: "8px",
margin: "40px auto",
maxWidth: "480px",
}}
>
<Heading style={{ fontSize: "24px", marginBottom: "16px" }}>
Welcome, {name}
</Heading>
<Text style={{ fontSize: "16px", lineHeight: "1.6", color: "#374151" }}>
Your account is ready. You can log in and start exploring.
</Text>
<Link
href={loginUrl}
style={{
display: "inline-block",
backgroundColor: "#f97316",
color: "#ffffff",
padding: "12px 24px",
borderRadius: "6px",
textDecoration: "none",
fontSize: "14px",
fontWeight: "600",
marginTop: "16px",
}}
>
Go to Dashboard
</Link>
</Container>
</Body>
</Html>
);
}Render and send:
import { render } from "@react-email/components";
import { sequenzy } from "./email";
import WelcomeEmail from "../emails/welcome";
export async function handleWelcome(req: Request): Promise<Response> {
const { name, email } = await req.json();
const html = await render(WelcomeEmail({ name, loginUrl: "https://app.yoursite.com" }));
const result = await sequenzy.transactional.send({
to: email,
subject: `Welcome, ${name}`,
body: html,
});
return Response.json({ jobId: result.jobId });
}import { render } from "@react-email/components";
import { resend } from "./email";
import WelcomeEmail from "../emails/welcome";
export async function handleWelcome(req: Request): Promise<Response> {
const { name, email } = await req.json();
const html = await render(WelcomeEmail({ name, loginUrl: "https://app.yoursite.com" }));
const { data, error } = await resend.emails.send({
from: "Your App <noreply@yourdomain.com>",
to: email,
subject: `Welcome, ${name}`,
html,
});
if (error) {
return Response.json({ error: error.message }, { status: 500 });
}
return Response.json({ id: data?.id });
}import { render } from "@react-email/components";
import { sgMail } from "./email";
import WelcomeEmail from "../emails/welcome";
export async function handleWelcome(req: Request): Promise<Response> {
const { name, email } = await req.json();
const html = await render(WelcomeEmail({ name, loginUrl: "https://app.yoursite.com" }));
try {
await sgMail.send({
to: email,
from: "noreply@yourdomain.com",
subject: `Welcome, ${name}`,
html,
});
return Response.json({ sent: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Send failed";
return Response.json({ error: message }, { status: 500 });
}
}More React Email Components
React Email includes components for all common email patterns:
import {
Button, // CTA buttons with proper email padding tricks
Img, // Images with alt text and sizing
Section, // Layout sections
Row, // Horizontal rows
Column, // Column layout
Hr, // Horizontal rules
CodeBlock, // Formatted code (for developer-facing emails)
Tailwind, // Use Tailwind classes instead of inline styles
} from "@react-email/components";The Tailwind wrapper compiles Tailwind classes to inline styles for email clients:
import { Html, Tailwind, Text, Button } from "@react-email/components";
export function ReceiptEmail({ amount }: { amount: string }) {
return (
<Html>
<Tailwind>
<Text className="text-2xl font-bold text-gray-900">
Payment received
</Text>
<Text className="text-gray-600">
We received your payment of {amount}.
</Text>
<Button
href="https://app.yoursite.com/billing"
className="bg-orange-500 text-white px-6 py-3 rounded-md"
>
View Invoice
</Button>
</Tailwind>
</Html>
);
}Preview your templates locally with bunx react-email dev.
Send with Elysia
For anything beyond a toy project, you want a framework. Elysia is the go-to for Bun: end-to-end type safety, built-in validation with TypeBox, and great performance.
bun add elysiaimport { Elysia, t } from "elysia";
import { sequenzy } from "./email";
const app = new Elysia()
.post("/api/send-welcome", async ({ body }) => {
const result = await sequenzy.transactional.send({
to: body.email,
subject: `Welcome, ${body.name}`,
body: `<h1>Welcome, ${body.name}</h1><p>Your account is ready.</p>`,
});
return { jobId: result.jobId };
}, {
body: t.Object({
email: t.String({ format: "email" }),
name: t.String({ minLength: 1 }),
}),
})
.post("/api/contact", async ({ body }) => {
await sequenzy.transactional.send({
to: "you@yourcompany.com",
subject: `Contact from ${body.email}`,
body: `<p><strong>From:</strong> ${body.email}</p><p>${body.message}</p>`,
});
return { sent: true };
}, {
body: t.Object({
email: t.String({ format: "email" }),
message: t.String({ minLength: 1 }),
}),
})
.listen(3000);
console.log(`Server running on http://${app.server?.hostname}:${app.server?.port}`);import { Elysia, t } from "elysia";
import { resend } from "./email";
const FROM = "Your App <noreply@yourdomain.com>";
const app = new Elysia()
.post("/api/send-welcome", async ({ body }) => {
const { data, error } = await resend.emails.send({
from: FROM,
to: body.email,
subject: `Welcome, ${body.name}`,
html: `<h1>Welcome, ${body.name}</h1><p>Your account is ready.</p>`,
});
if (error) throw new Error(error.message);
return { id: data?.id };
}, {
body: t.Object({
email: t.String({ format: "email" }),
name: t.String({ minLength: 1 }),
}),
})
.post("/api/contact", async ({ body }) => {
const { error } = await resend.emails.send({
from: FROM,
to: "you@yourcompany.com",
subject: `Contact from ${body.email}`,
html: `<p><strong>From:</strong> ${body.email}</p><p>${body.message}</p>`,
});
if (error) throw new Error(error.message);
return { sent: true };
}, {
body: t.Object({
email: t.String({ format: "email" }),
message: t.String({ minLength: 1 }),
}),
})
.listen(3000);
console.log(`Server running on http://${app.server?.hostname}:${app.server?.port}`);import { Elysia, t } from "elysia";
import { sgMail } from "./email";
const app = new Elysia()
.post("/api/send-welcome", async ({ body }) => {
await sgMail.send({
to: body.email,
from: "noreply@yourdomain.com",
subject: `Welcome, ${body.name}`,
html: `<h1>Welcome, ${body.name}</h1><p>Your account is ready.</p>`,
});
return { sent: true };
}, {
body: t.Object({
email: t.String({ format: "email" }),
name: t.String({ minLength: 1 }),
}),
})
.post("/api/contact", async ({ body }) => {
await sgMail.send({
to: "you@yourcompany.com",
from: "noreply@yourdomain.com",
subject: `Contact from ${body.email}`,
html: `<p><strong>From:</strong> ${body.email}</p><p>${body.message}</p>`,
});
return { sent: true };
}, {
body: t.Object({
email: t.String({ format: "email" }),
message: t.String({ minLength: 1 }),
}),
})
.listen(3000);
console.log(`Server running on http://${app.server?.hostname}:${app.server?.port}`);Elysia validates the request body automatically. If someone sends { email: "not-an-email" }, they get a 400 error before your handler even runs.
Common Email Patterns for SaaS
Here are the emails almost every SaaS app needs. All production-ready, all with provider switchers.
Password Reset
import { sequenzy } from "../email";
export async function sendPasswordResetEmail(email: string, resetToken: string) {
const resetUrl = `${Bun.env.APP_URL}/reset-password?token=${resetToken}`;
await sequenzy.transactional.send({
to: email,
subject: "Reset your password",
body: `
<h2>Password Reset</h2>
<p>Click the link below to reset your password. This link expires in 1 hour.</p>
<a href="${resetUrl}"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Reset Password
</a>
<p style="color:#6b7280;font-size:14px;margin-top:24px;">
If you didn't request this, ignore this email.
</p>
`,
});
}import { resend } from "../email";
export async function sendPasswordResetEmail(email: string, resetToken: string) {
const resetUrl = `${Bun.env.APP_URL}/reset-password?token=${resetToken}`;
await resend.emails.send({
from: "Your App <noreply@yourdomain.com>",
to: email,
subject: "Reset your password",
html: `
<h2>Password Reset</h2>
<p>Click the link below to reset your password. This link expires in 1 hour.</p>
<a href="${resetUrl}"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Reset Password
</a>
<p style="color:#6b7280;font-size:14px;margin-top:24px;">
If you didn't request this, ignore this email.
</p>
`,
});
}import { sgMail } from "../email";
export async function sendPasswordResetEmail(email: string, resetToken: string) {
const resetUrl = `${Bun.env.APP_URL}/reset-password?token=${resetToken}`;
await sgMail.send({
to: email,
from: "noreply@yourdomain.com",
subject: "Reset your password",
html: `
<h2>Password Reset</h2>
<p>Click the link below to reset your password. This link expires in 1 hour.</p>
<a href="${resetUrl}"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Reset Password
</a>
<p style="color:#6b7280;font-size:14px;margin-top:24px;">
If you didn't request this, ignore this email.
</p>
`,
});
}Payment Receipt
import { sequenzy } from "../email";
interface ReceiptParams {
email: string;
amount: number; // in cents
plan: string;
invoiceUrl: string;
}
export async function sendReceiptEmail({ email, amount, plan, invoiceUrl }: ReceiptParams) {
const formatted = `$${(amount / 100).toFixed(2)}`;
await sequenzy.transactional.send({
to: email,
subject: `Payment receipt - ${formatted}`,
body: `
<h2>Payment Received</h2>
<p>Thanks for your payment. Here's your receipt:</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;">${plan}</td>
</tr>
<tr>
<td style="padding:8px;font-weight:600;">Total</td>
<td style="padding:8px;text-align:right;font-weight:600;">${formatted}</td>
</tr>
</table>
<a href="${invoiceUrl}" style="color:#f97316;">View full invoice</a>
`,
});
}import { resend } from "../email";
interface ReceiptParams {
email: string;
amount: number;
plan: string;
invoiceUrl: string;
}
export async function sendReceiptEmail({ email, amount, plan, invoiceUrl }: ReceiptParams) {
const formatted = `$${(amount / 100).toFixed(2)}`;
await resend.emails.send({
from: "Your App <billing@yourdomain.com>",
to: email,
subject: `Payment receipt - ${formatted}`,
html: `
<h2>Payment Received</h2>
<p>Thanks for your payment. Here's your receipt:</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;">${plan}</td>
</tr>
<tr>
<td style="padding:8px;font-weight:600;">Total</td>
<td style="padding:8px;text-align:right;font-weight:600;">${formatted}</td>
</tr>
</table>
<a href="${invoiceUrl}" style="color:#f97316;">View full invoice</a>
`,
});
}import { sgMail } from "../email";
interface ReceiptParams {
email: string;
amount: number;
plan: string;
invoiceUrl: string;
}
export async function sendReceiptEmail({ email, amount, plan, invoiceUrl }: ReceiptParams) {
const formatted = `$${(amount / 100).toFixed(2)}`;
await sgMail.send({
to: email,
from: "billing@yourdomain.com",
subject: `Payment receipt - ${formatted}`,
html: `
<h2>Payment Received</h2>
<p>Thanks for your payment. Here's your receipt:</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;">${plan}</td>
</tr>
<tr>
<td style="padding:8px;font-weight:600;">Total</td>
<td style="padding:8px;text-align:right;font-weight:600;">${formatted}</td>
</tr>
</table>
<a href="${invoiceUrl}" style="color:#f97316;">View full invoice</a>
`,
});
}Stripe Webhook Handler
Bun handles Stripe webhooks naturally since Bun.serve gives you the raw request body. With Elysia, you need to access the raw body for signature verification:
import Stripe from "stripe";
import { sequenzy } from "../email";
const stripe = new Stripe(Bun.env.STRIPE_SECRET_KEY!);
export async function handleStripeWebhook(req: Request): Promise<Response> {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
const event = stripe.webhooks.constructEvent(
body,
signature,
Bun.env.STRIPE_WEBHOOK_SECRET!
);
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await sequenzy.transactional.send({
to: session.customer_email!,
subject: "Payment confirmed",
body: "<h1>Thanks for your purchase!</h1><p>Your subscription is now active.</p>",
});
// Auto-add as subscriber for marketing
await sequenzy.subscribers.create({
email: session.customer_email!,
tags: ["customer", "stripe"],
});
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
await sequenzy.transactional.send({
to: invoice.customer_email!,
subject: "Payment failed - action required",
body: `
<h2>Payment Failed</h2>
<p>We couldn't process your payment. Please update your payment method to keep your account active.</p>
<a href="${Bun.env.APP_URL}/billing"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Update Payment Method
</a>
`,
});
break;
}
}
return Response.json({ received: true });
}import Stripe from "stripe";
import { resend } from "../email";
const stripe = new Stripe(Bun.env.STRIPE_SECRET_KEY!);
export async function handleStripeWebhook(req: Request): Promise<Response> {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
const event = stripe.webhooks.constructEvent(
body,
signature,
Bun.env.STRIPE_WEBHOOK_SECRET!
);
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await resend.emails.send({
from: "Your App <noreply@yourdomain.com>",
to: session.customer_email!,
subject: "Payment confirmed",
html: "<h1>Thanks for your purchase!</h1><p>Your subscription is now active.</p>",
});
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
await resend.emails.send({
from: "Your App <noreply@yourdomain.com>",
to: invoice.customer_email!,
subject: "Payment failed - action required",
html: `
<h2>Payment Failed</h2>
<p>We couldn't process your payment. Please update your payment method.</p>
<a href="${Bun.env.APP_URL}/billing"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Update Payment Method
</a>
`,
});
break;
}
}
return Response.json({ received: true });
}import Stripe from "stripe";
import { sgMail } from "../email";
const stripe = new Stripe(Bun.env.STRIPE_SECRET_KEY!);
export async function handleStripeWebhook(req: Request): Promise<Response> {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
const event = stripe.webhooks.constructEvent(
body,
signature,
Bun.env.STRIPE_WEBHOOK_SECRET!
);
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await sgMail.send({
to: session.customer_email!,
from: "noreply@yourdomain.com",
subject: "Payment confirmed",
html: "<h1>Thanks for your purchase!</h1><p>Your subscription is now active.</p>",
});
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
await sgMail.send({
to: invoice.customer_email!,
from: "noreply@yourdomain.com",
subject: "Payment failed - action required",
html: `
<h2>Payment Failed</h2>
<p>We couldn't process your payment. Please update your payment method.</p>
<a href="${Bun.env.APP_URL}/billing"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Update Payment Method
</a>
`,
});
break;
}
}
return Response.json({ received: true });
}Wire up the webhook route in your main server. Note: with Bun.serve, you don't need special raw body parsing like Express. The request body is already raw:
// src/index.ts
import { handleStripeWebhook } from "./routes/stripe-webhook";
import { handleWelcome } from "./routes/welcome";
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/api/webhooks/stripe") {
return handleStripeWebhook(req);
}
if (req.method === "POST" && url.pathname === "/api/send-welcome") {
return handleWelcome(req);
}
return new Response("Not found", { status: 404 });
},
});Error Handling
Emails fail. Networks time out. Rate limits hit. Here's how to handle it properly.
The Sequenzy SDK has built-in retries (2 retries with exponential backoff by default), so you mostly just need to catch errors. For Resend and SendGrid, add retry logic yourself.
import Sequenzy from "sequenzy";
import { sequenzy } from "../email";
export async function sendEmailSafe(params: {
to: string;
subject: string;
body: string;
}) {
try {
return await sequenzy.transactional.send(params);
} catch (err) {
if (err instanceof Sequenzy.RateLimitError) {
console.error("Rate limited, try again later");
} else if (err instanceof Sequenzy.AuthenticationError) {
console.error("Bad API key - check SEQUENZY_API_KEY in .env");
} else {
console.error("Email send failed:", err);
}
throw err;
}
}import { resend } from "../email";
export async function sendEmailSafe(params: {
to: string;
subject: string;
html: string;
}) {
const { data, error } = await resend.emails.send({
from: "Your App <noreply@yourdomain.com>",
...params,
});
if (error) {
console.error("Email send failed:", { to: params.to, error: error.message });
throw new Error(error.message);
}
return data;
}import { sgMail } from "../email";
export async function sendEmailSafe(params: {
to: string;
subject: string;
html: string;
}) {
try {
const [response] = await sgMail.send({
from: "noreply@yourdomain.com",
...params,
});
return { id: response.headers["x-message-id"] };
} catch (err: unknown) {
const error = err as { response?: { body?: unknown }; message?: string };
console.error("Email send failed:", {
to: params.to,
error: error.response?.body ?? error.message,
});
throw new Error("Email send failed");
}
}For critical emails (password resets, receipts), add a retry wrapper:
// src/utils/retry.ts
export async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries) throw error;
const delay = Math.pow(2, attempt) * 1000;
await Bun.sleep(delay); // Bun has a built-in sleep!
}
}
throw new Error("Unreachable");
}
// Sequenzy already retries, so this is mainly for Resend/SendGrid
await withRetry(() => sendEmailSafe({ to, subject, html }));Going to Production
Before you start sending real emails, you need to handle a few things.
1. Verify Your Domain
Every email provider requires domain verification. This means adding DNS records (SPF, DKIM, and usually DMARC) to prove you own the domain you're sending from. Our email authentication guide covers this in detail.
Without this, your emails go straight to spam. No exceptions.
2. Use a Dedicated Sending Domain
Send from something like mail.yourapp.com instead of your root domain. If your email reputation takes a hit, your main domain stays clean.
3. Set a Consistent "From" Address
Pick a from address and stick with it. Changing it frequently confuses spam filters.
// Good: one consistent sender
const FROM = "YourApp <notifications@mail.yourapp.com>";
// Bad: different sender for every email type4. Rate Limit Your Endpoints
Bun doesn't have an express-rate-limit equivalent built in, but a simple in-memory rate limiter works:
// src/utils/rate-limit.ts
const requests = new Map<string, number[]>();
export function rateLimit(key: string, maxPerWindow = 10, windowMs = 60_000): boolean {
const now = Date.now();
const cutoff = now - windowMs;
const timestamps = (requests.get(key) ?? []).filter((t) => t > cutoff);
if (timestamps.length >= maxPerWindow) return false;
timestamps.push(now);
requests.set(key, timestamps);
return true;
}
// Usage in Bun.serve
if (!rateLimit(clientIp, 10, 15 * 60 * 1000)) {
return Response.json({ error: "Too many requests" }, { status: 429 });
}5. Build and Deploy
# Build a standalone executable
bun build --compile src/index.ts --outfile=server
# Or just run directly in production
bun run src/index.tsBun's startup time is fast enough that you can skip the compile step for most deployments. The compile step is useful for Docker containers where you don't want to install Bun.
Beyond Transactional: Marketing Emails and Sequences
At some point, you'll want more than one-off transactional emails. You'll want to:
- Send onboarding sequences that guide new users through your product over several days
- Run marketing campaigns to announce features or share updates
- Automate lifecycle emails based on what users do (or don't do) in your app
- Track engagement to see which emails get opened and clicked
Most teams stitch together a transactional provider (Resend, SendGrid) with a separate marketing tool (Mailchimp, ConvertKit). That means two dashboards, two billing systems, and keeping subscriber lists in sync.
Sequenzy handles both from one platform. Same SDK, same dashboard. You get transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Stripe integration for SaaS-specific automations like trial conversion and churn prevention.
Here's what subscriber management looks like:
import { sequenzy } from "./email";
// Add a subscriber when they sign up
await sequenzy.subscribers.create({
email: "user@example.com",
firstName: "Jane",
tags: ["signed-up"],
customAttributes: { plan: "free", source: "organic" },
});
// Tag them when they upgrade
await sequenzy.subscribers.tags.add({
email: "user@example.com",
tag: "customer",
});
// Track events to trigger automated sequences
await sequenzy.subscribers.events.trigger({
email: "user@example.com",
event: "onboarding.completed",
properties: { completedSteps: 5 },
});You set up sequences in the Sequenzy dashboard (onboarding drip, trial conversion, churn prevention), and the SDK triggers them based on what happens in your app.
FAQ
Can I use Nodemailer with Bun?
Yes. Bun has full npm compatibility, so bun add nodemailer works. But Nodemailer is for SMTP, which is slower and more error-prone than API-based providers. Unless you need to connect to a specific SMTP server, use an API-based provider instead.
Does Bun.serve support HTTPS?
Yes. Pass tls options to Bun.serve with your cert and key files. In production, you'd typically terminate TLS at a reverse proxy (nginx, Caddy, Cloudflare) and run Bun on plain HTTP behind it.
How do I send emails from Bun without a web server?
You can send emails from any Bun script. Just import the SDK and call send:
// scripts/send-digest.ts
import { sequenzy } from "./src/email";
await sequenzy.transactional.send({
to: "user@example.com",
subject: "Your weekly digest",
body: "<h1>Here's what happened this week</h1>",
});Run it with bun run scripts/send-digest.ts. Great for cron jobs and one-off scripts.
How does Bun handle .env files compared to Node.js?
Bun loads .env automatically, no dotenv package needed. It reads .env, .env.local, .env.production, and .env.development based on NODE_ENV. Access variables through Bun.env.VARIABLE_NAME or process.env.VARIABLE_NAME (both work).
Should I use Bun.serve or Elysia for a production email API?
Use Bun.serve if you have a handful of routes and want zero dependencies. Use Elysia when you need validation, middleware, structured error handling, or more than 5-6 routes. Elysia's TypeBox validation alone is worth it for any endpoint that takes user input.
Can I use React Email with Bun?
Absolutely. Bun has native JSX/TSX support. Install @react-email/components, create your templates as .tsx files, and render them with the render() function. No special configuration needed.
How do I test email sending in Bun?
Use Bun's built-in test runner. Mock the email client and assert the right parameters were passed:
import { describe, it, expect, mock } from "bun:test";
const mockSend = mock(() => Promise.resolve({ jobId: "test-123" }));
// ... test your routes with the mockFor integration tests, most providers have sandbox modes or test API keys that accept emails without actually delivering them.
Wrapping Up
Here's what we covered:
- Set up an email client with Sequenzy, Resend, or SendGrid using Bun's auto
.envloading - Send from Bun.serve for zero-dependency HTTP servers
- Send from Elysia for typed routes with automatic validation
- Build templates with React Email for maintainable HTML emails
- Handle Stripe webhooks for payment-triggered emails (no raw body workarounds needed)
- Common SaaS patterns: password reset, payment receipts, failed payment alerts
- Error handling with provider-specific error types and retry logic
- Production checklist: domain verification, dedicated sending domain, rate limiting
The code in this guide is production-ready. Copy the patterns that fit your app, swap in your provider of choice, and start shipping emails.
Frequently Asked Questions
Do email SDKs work with Bun out of the box?
Most modern email SDKs work with Bun since they use standard fetch internally, which Bun supports natively. Some older packages that depend on Node-specific APIs (like http or net) may need Bun's Node compatibility layer. Test your SDK of choice before committing.
Is Bun faster than Node.js for sending emails?
Bun's startup time is faster, which matters for serverless/CLI scripts. For the actual email send (an HTTP API call), the difference is negligible since you're waiting on network I/O. The main Bun advantage is faster dependency installation and a cleaner developer experience.
Can I use Nodemailer with Bun?
Nodemailer works with Bun through its Node.js compatibility layer, but it's not the recommended approach. Bun's native fetch is faster and cleaner for calling email API endpoints. Use a provider SDK that uses fetch internally for the best Bun experience.
How do I run email-sending scripts as Bun cron jobs?
Create a standalone Bun script that sends your emails and run it with bun run script.ts from your system's cron scheduler. For Bun-native scheduling, use Bun.cron in Bun's built-in cron API if available in your version.
How do I handle environment variables for email API keys in Bun?
Bun reads .env files automatically without any extra packages. Just create a .env file with your API key and access it via process.env.YOUR_KEY or Bun.env.YOUR_KEY. No dotenv package needed.
Can I send emails from Bun's built-in HTTP server?
Yes. Use Bun.serve() to create an HTTP server and call your email provider's SDK in the route handler. Bun's server is fast and handles concurrent requests well. For background processing, use queueMicrotask() or a job queue.
How do I test email sending in Bun?
Use Bun's built-in test runner (bun test) with mock.module() to mock the email SDK. This lets you verify that your code calls the send function with correct parameters without making real API calls. Bun's mocking API is similar to Jest.
Does Bun support TypeScript for email templates without a build step?
Yes, that's one of Bun's best features. You can write email templates in TypeScript and import them directly—no tsc compilation step needed. Bun runs .ts files natively, so your templates work the same as JavaScript files.
How do I send bulk emails efficiently with Bun?
Use Promise.all() with controlled concurrency to send batches in parallel. Bun's fast runtime handles concurrent promises well. For large volumes, use your provider's batch API endpoint instead of individual sends to reduce HTTP overhead.
Can I use React Email with Bun?
Yes. Install @react-email/components and @react-email/render with bun add. Bun supports JSX/TSX natively, so you can build and render React Email templates without any additional configuration. The render() function works the same as in Node.js.