Back to Blog

How to Send Emails in Express.js (2026 Guide)

16 min read

Most Express email tutorials show you nodemailer.sendMail() and call it a day. That gets you a contact form. It doesn't get you welcome emails, password resets, payment receipts, and onboarding sequences for a real product.

This guide covers the full picture: picking a provider, building HTML email templates with React Email, sending from route handlers, common SaaS email patterns, error handling, and production hardening. If you need a broader overview, check our Node.js email guide. All examples use Express with TypeScript.

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. It also has 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

Terminal
npm install express sequenzy
Terminal
npm install express resend
Terminal
npm install express @sendgrid/mail

Add your API key to .env:

.env
SEQUENZY_API_KEY=sq_your_api_key_here
.env
RESEND_API_KEY=re_your_api_key_here
.env
SENDGRID_API_KEY=SG.your_api_key_here

Load your .env file:

npm install dotenv
// src/app.ts - at the very top
import "dotenv/config";

Initialize the Client

Create a shared email client so you're not re-instantiating on every request.

src/email.ts
import Sequenzy from "sequenzy";

// Reads SEQUENZY_API_KEY from env automatically
export const sequenzy = new Sequenzy();
src/email.ts
import { Resend } from "resend";

export const resend = new Resend(process.env.RESEND_API_KEY);
src/email.ts
import sgMail from "@sendgrid/mail";

sgMail.setApiKey(process.env.SENDGRID_API_KEY!);

export { sgMail };

Send Your First Email

The simplest possible email. Plain text, no template, just getting something out the door.

src/routes/email.ts
import { Router } from "express";
import { sequenzy } from "../email";

const router = Router();

router.post("/api/send", async (req, res) => {
const result = await sequenzy.transactional.send({
  to: "user@example.com",
  subject: "Hello from Express",
  body: "<p>Your app is sending emails. Nice.</p>",
});

res.json({ jobId: result.jobId });
});

export default router;
src/routes/email.ts
import { Router } from "express";
import { resend } from "../email";

const router = Router();

router.post("/api/send", async (req, res) => {
const { data, error } = await resend.emails.send({
  from: "Your App <noreply@yourdomain.com>",
  to: "user@example.com",
  subject: "Hello from Express",
  html: "<p>Your app is sending emails. Nice.</p>",
});

if (error) {
  return res.status(500).json({ error: error.message });
}

res.json({ id: data?.id });
});

export default router;
src/routes/email.ts
import { Router } from "express";
import { sgMail } from "../email";

const router = Router();

router.post("/api/send", async (req, res) => {
try {
  await sgMail.send({
    to: "user@example.com",
    from: "noreply@yourdomain.com",
    subject: "Hello from Express",
    html: "<p>Your app is sending emails. Nice.</p>",
  });

  res.json({ success: true });
} catch (err) {
  const message = err instanceof Error ? err.message : "Failed to send";
  res.status(500).json({ error: message });
}
});

export default router;

Wire it up and test:

// src/app.ts
import "dotenv/config";
import express from "express";
import emailRoutes from "./routes/email";
 
const app = express();
app.use(express.json());
app.use(emailRoutes);
 
app.listen(3000, () => {
  console.log("Server running on port 3000");
});
curl -X POST http://localhost:3000/api/send

Build 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 with inline styles. Yes, you can use it in Express even though Express isn't a React framework. You just render the components to HTML strings on the server.

npm install @react-email/components

Here'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>
  );
}

Then render and send it from Express:

src/routes/welcome.ts
import { Router } from "express";
import { render } from "@react-email/components";
import { sequenzy } from "../email";
import WelcomeEmail from "../../emails/welcome";

const router = Router();

router.post("/api/send-welcome", async (req, res) => {
const { name, email } = req.body;
const html = await render(WelcomeEmail({ name, loginUrl: "https://app.yoursite.com" }));

const result = await sequenzy.transactional.send({
  to: email,
  subject: `Welcome, ${name}`,
  body: html,
});

res.json({ jobId: result.jobId });
});

export default router;
src/routes/welcome.ts
import { Router } from "express";
import { render } from "@react-email/components";
import { resend } from "../email";
import WelcomeEmail from "../../emails/welcome";

const router = Router();

router.post("/api/send-welcome", async (req, res) => {
const { name, email } = req.body;
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 res.status(500).json({ error: error.message });
}

res.json({ id: data?.id });
});

export default router;
src/routes/welcome.ts
import { Router } from "express";
import { render } from "@react-email/components";
import { sgMail } from "../email";
import WelcomeEmail from "../../emails/welcome";

const router = Router();

router.post("/api/send-welcome", async (req, res) => {
const { name, email } = req.body;
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,
  });

  res.json({ sent: true });
} catch (err) {
  const message = err instanceof Error ? err.message : "Send failed";
  res.status(500).json({ error: message });
}
});

export default router;

More React Email Components

React Email has components for most things you need in emails:

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 is especially useful. It lets you write Tailwind classes that get compiled to inline styles:

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>
  );
}

You can preview your templates locally with npx react-email dev which spins up a browser preview at localhost:3000.

Express Middleware for Email

Wrap your async route handlers so unhandled errors get caught instead of crashing the server:

// src/middleware/async-handler.ts
import { Request, Response, NextFunction } from "express";
 
export function asyncHandler(
  fn: (req: Request, res: Response, next: NextFunction) => Promise<void>
) {
  return (req: Request, res: Response, next: NextFunction) => {
    fn(req, res, next).catch(next);
  };
}

Then add an error handler that catches email failures:

// src/app.ts
import express from "express";
 
const app = express();
 
// ... routes ...
 
// Global error handler - catches anything thrown in asyncHandler
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
  console.error("Email send error:", { path: req.path, error: err.message });
  res.status(500).json({ error: "Something went wrong" });
});

Common Email Patterns for SaaS

Here are the emails almost every SaaS app needs, with production-ready implementations.

Password Reset

src/emails/password-reset.ts
import { sequenzy } from "../email";

export async function sendPasswordResetEmail(email: string, resetToken: string) {
const resetUrl = `${process.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>
  `,
});
}
src/emails/password-reset.ts
import { resend } from "../email";

export async function sendPasswordResetEmail(email: string, resetToken: string) {
const resetUrl = `${process.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>
  `,
});
}
src/emails/password-reset.ts
import { sgMail } from "../email";

export async function sendPasswordResetEmail(email: string, resetToken: string) {
const resetUrl = `${process.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

src/emails/receipt.ts
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>
  `,
});
}
src/emails/receipt.ts
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>
  `,
});
}
src/emails/receipt.ts
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

If your Express app uses Stripe, you'll want to send emails when payments come through. For a complete guide on Stripe-triggered emails, see sending emails from Stripe webhooks. Express needs express.raw() to parse the raw body for Stripe signature verification:

src/routes/stripe-webhook.ts
import { Router } from "express";
import express from "express";
import Stripe from "stripe";
import { sequenzy } from "../email";

const router = Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// Important: use express.raw() for webhook routes, not express.json()
router.post(
"/api/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
  const signature = req.headers["stripe-signature"]!;

  const event = stripe.webhooks.constructEvent(
    req.body,
    signature,
    process.env.STRIPE_WEBHOOK_SECRET!
  );

  if (event.type === "checkout.session.completed") {
    const session = event.data.object as Stripe.Checkout.Session;

    // Send receipt
    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>",
    });

    // Add them as a subscriber with tags for marketing
    await sequenzy.subscribers.create({
      email: session.customer_email!,
      tags: ["customer", "stripe"],
    });
  }

  if (event.type === "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="${process.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>
      `,
    });
  }

  res.json({ received: true });
}
);

export default router;
src/routes/stripe-webhook.ts
import { Router } from "express";
import express from "express";
import Stripe from "stripe";
import { resend } from "../email";

const router = Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

router.post(
"/api/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
  const signature = req.headers["stripe-signature"]!;

  const event = stripe.webhooks.constructEvent(
    req.body,
    signature,
    process.env.STRIPE_WEBHOOK_SECRET!
  );

  if (event.type === "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>",
    });
  }

  if (event.type === "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="${process.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>
      `,
    });
  }

  res.json({ received: true });
}
);

export default router;
src/routes/stripe-webhook.ts
import { Router } from "express";
import express from "express";
import Stripe from "stripe";
import { sgMail } from "../email";

const router = Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

router.post(
"/api/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
  const signature = req.headers["stripe-signature"]!;

  const event = stripe.webhooks.constructEvent(
    req.body,
    signature,
    process.env.STRIPE_WEBHOOK_SECRET!
  );

  if (event.type === "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>",
    });
  }

  if (event.type === "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="${process.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>
      `,
    });
  }

  res.json({ received: true });
}
);

export default router;

Important: Mount the Stripe webhook route before express.json() middleware, or use a separate router. Stripe needs the raw body for signature verification:

// src/app.ts
import "dotenv/config";
import express from "express";
import stripeWebhook from "./routes/stripe-webhook";
import emailRoutes from "./routes/email";
 
const app = express();
 
// Stripe webhook needs raw body - mount before json middleware
app.use(stripeWebhook);
 
// JSON parsing for everything else
app.use(express.json());
app.use(emailRoutes);
 
app.listen(3000);

Error Handling

Emails fail. Networks time out. Rate limits hit. Here's how to handle it properly in Express.

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, you'll want to add retry logic yourself.

src/emails/send-safe.ts
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");
  } else {
    console.error("Email send failed:", err);
  }
  throw err;
}
}
src/emails/send-safe.ts
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;
}
src/emails/send-safe.ts
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 new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
  throw new Error("Unreachable");
}
 
// Usage (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 setup guide covers this step by step.

Without this, your emails go straight to spam. No exceptions.

Each provider has a dashboard where you add your domain and get the DNS records to configure. Do this first.

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 type
// "noreply@yourapp.com" for some
// "team@yourapp.com" for others

4. Rate Limit Your Email Endpoints

Express has a great rate limiting middleware. Use it on any endpoint that sends email:

npm install express-rate-limit
import rateLimit from "express-rate-limit";
 
const emailLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 10, // 10 emails per window per IP
  message: { error: "Too many requests, try again later" },
});
 
// Apply to email-sending routes
router.post("/api/contact", emailLimiter, asyncHandler(async (req, res) => {
  // ...
}));
 
router.post("/api/reset-password", emailLimiter, asyncHandler(async (req, res) => {
  // ...
}));

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 with the same SDK:

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. No webhook glue code needed.

Wrapping Up

Here's what we covered:

  1. Set up an email client with Sequenzy, Resend, or SendGrid
  2. Send from Express routes with proper validation and error handling
  3. Build templates with React Email for maintainable HTML emails
  4. Handle Stripe webhooks for payment-triggered emails
  5. Common SaaS patterns: password reset, payment receipts, failed payment alerts
  6. Error handling with async middleware, provider-specific error types, and retry logic
  7. 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

Should I send emails synchronously in Express route handlers?

For single transactional emails (password reset, welcome), awaiting the send in the route handler is fine. For bulk sends or non-critical emails, use a job queue like BullMQ to send asynchronously and return the response immediately. This prevents slow email sends from blocking your API.

How do I handle email sending errors in Express middleware?

Create an error-handling middleware that catches email failures and returns appropriate status codes. Log the error details for debugging but don't expose internal error messages to the client. Return a generic "Failed to send email" message with a 500 status.

Can I use Express with React Email for templates?

Yes. Install @react-email/components and @react-email/render, build your templates as React components, and call render() to convert them to HTML strings before passing to your email provider. This works server-side in Express without any client-side React setup.

How do I rate limit email-sending endpoints in Express?

Use express-rate-limit middleware on routes that trigger emails. Set limits like 5 requests per minute per IP for contact forms and 10 per hour per user for password resets. This prevents abuse without affecting legitimate users.

What's the best project structure for email code in Express?

Create a lib/email.ts file for your email client initialization, an emails/ directory for templates, and a services/email.ts for reusable send functions. Keep email logic out of route handlers—routes should call service functions that handle the email details.

How do I validate email addresses before sending in Express?

Use a validation library like zod or joi to validate the email format in your request body. For critical sends, use an email verification API to check if the address actually exists. Never trust client-side validation alone.

Should I use TypeScript with Express for email sending?

Yes. TypeScript catches errors like typos in field names, missing required parameters, and type mismatches before your code runs. Email provider SDKs include TypeScript definitions, so you get autocomplete for send parameters.

How do I send emails from Express when deployed to a serverless environment?

Serverless Express (via @vendia/serverless-express or similar) works with email SDKs. However, background jobs won't work since serverless functions shut down after the response. Use managed queues (SQS, Cloud Tasks) for async email sending in serverless deployments.

How do I test Express email routes without sending real emails?

Mock the email provider's SDK in your tests using Jest or Vitest. Use supertest to make requests to your Express routes and verify that the send function was called with the correct parameters. This tests your route logic without any network calls.

Can I send attachments from an Express endpoint?

Yes. Accept file uploads with multer, read the file buffer, and pass it to your email provider's attachment parameter (usually as a base64-encoded string with a filename and MIME type). Validate file size and type before attaching to prevent abuse.