Back to blog
Node.jsExpressTutorialIntegration

How to Send Emails from Node.js and Express

Send transactional emails from Node.js using the SendPigeon SDK. Setup, error handling, templates, and production best practices.

SendPigeon TeamDecember 20, 20257 min read

This guide shows how to send transactional emails from any Node.js application using the SendPigeon SDK. Works with Express, Fastify, Hono, Koa, or plain Node.

TL;DR

Quick setup:

  1. npm install sendpigeon
  2. Set SENDPIGEON_API_KEY environment variable
  3. Import and send

Works with: Express, Fastify, Hono, Koa, or plain Node.js.


Installation

npm install sendpigeon

Basic Setup

import { SendPigeon } from "sendpigeon";

const pigeon = new SendPigeon(process.env.SENDPIGEON_API_KEY!);

// Send your first email
const { data, error } = await pigeon.send({
  from: "hello@yourdomain.com",
  to: "user@example.com",
  subject: "Hello from Node.js",
  html: "<p>Your first transactional email.</p>",
});

if (error) {
  console.error(`Failed: ${error.message}`);
} else {
  console.log(`Email sent: ${data.id}`);
}

Express Integration

Project Structure

src/
├── config/
│   └── email.ts       # SDK client
├── services/
│   └── email.ts       # Email logic
├── routes/
│   └── auth.ts        # Routes that send email
└── index.ts

Email Client

// src/config/email.ts
import { SendPigeon } from "sendpigeon";

if (!process.env.SENDPIGEON_API_KEY) {
  throw new Error("SENDPIGEON_API_KEY is required");
}

export const pigeon = new SendPigeon(process.env.SENDPIGEON_API_KEY);

Email Service

// src/services/email.ts
import { pigeon } from "../config/email";

const FROM_ADDRESS = "notifications@yourdomain.com";

export async function sendPasswordReset(email: string, resetUrl: string) {
  return pigeon.send({
    from: FROM_ADDRESS,
    to: email,
    subject: "Reset your password",
    html: `
      <h1>Password Reset</h1>
      <p>Click below to reset your password. This link expires in 1 hour.</p>
      <a href="${resetUrl}">Reset Password</a>
    `,
    text: `Reset your password: ${resetUrl}`,
  });
}

export async function sendWelcome(email: string, name: string) {
  return pigeon.send({
    from: FROM_ADDRESS,
    to: email,
    subject: `Welcome, ${name}!`,
    html: `
      <h1>Welcome to MyApp</h1>
      <p>Hi ${name}, your account is ready.</p>
    `,
  });
}

export async function sendOrderConfirmation(
  email: string,
  order: { id: string; total: number; items: string[] }
) {
  return pigeon.send({
    from: FROM_ADDRESS,
    to: email,
    subject: `Order confirmed: #${order.id}`,
    html: `
      <h1>Order Confirmed</h1>
      <p>Order #${order.id}</p>
      <ul>
        ${order.items.map((item) => `<li>${item}</li>`).join("")}
      </ul>
      <p><strong>Total: $${order.total.toFixed(2)}</strong></p>
    `,
  });
}

Express Routes

// src/routes/auth.ts
import { Router } from "express";
import { sendPasswordReset, sendWelcome } from "../services/email";

const router = Router();

router.post("/signup", async (req, res) => {
  const { email, name, password } = req.body;

  // Create user
  const user = await db.user.create({ email, name, password });

  // Send welcome email (don't await - fire and forget)
  sendWelcome(email, name).catch((err) => {
    console.error("Welcome email failed:", err);
  });

  res.json({ userId: user.id });
});

router.post("/forgot-password", async (req, res) => {
  const { email } = req.body;

  const user = await db.user.findByEmail(email);
  if (!user) {
    // Don't reveal if email exists
    return res.json({ message: "If that email exists, we sent a reset link." });
  }

  const token = crypto.randomUUID();
  await db.passwordReset.create({ userId: user.id, token });

  const resetUrl = `${process.env.APP_URL}/reset-password?token=${token}`;
  await sendPasswordReset(email, resetUrl);

  res.json({ message: "If that email exists, we sent a reset link." });
});

export default router;

Sending Attachments

Attach files via base64 content or URL:

import fs from "fs";

// From file (base64)
await pigeon.send({
  from: "invoices@yourdomain.com",
  to: "customer@example.com",
  subject: "Your invoice",
  html: "<p>Please find your invoice attached.</p>",
  attachments: [
    {
      filename: "invoice.pdf",
      content: fs.readFileSync("./invoices/123.pdf").toString("base64"),
    },
  ],
});

// From URL (fetched server-side)
await pigeon.send({
  from: "reports@yourdomain.com",
  to: "customer@example.com",
  subject: "Your monthly report",
  html: "<p>See attached report.</p>",
  attachments: [
    {
      filename: "report.pdf",
      path: "https://your-cdn.com/reports/march-2025.pdf",
    },
  ],
});

Limits: 7MB per file, 25MB total. Executables (.exe, .bat, etc.) are blocked.


Using Templates

Store templates once, reuse with variables:

// Create template (do this once, or via dashboard)
const template = await pigeon.templates.create({
  name: "order-shipped",
  subject: "Your order #{{orderId}} has shipped!",
  html: `
    <h1>Your order is on the way!</h1>
    <p>Hi {{customerName}},</p>
    <p>Order #{{orderId}} shipped via {{carrier}}.</p>
    <p><a href="{{trackingUrl}}">Track your package</a></p>
  `,
});

console.log(template.id); // "tpl_abc123" - save this for later use

// Use the template (with the ID, not the name)
await pigeon.send({
  from: "orders@yourdomain.com",
  to: customer.email,
  templateId: template.id, // or store as constant: ORDER_SHIPPED_TEMPLATE_ID
  variables: {
    customerName: customer.name,
    orderId: "12345",
    carrier: "FedEx",
    trackingUrl: "https://...",
  },
});

Store template IDs as environment variables or constants for production use.

Managing Templates

// List all templates
const templates = await pigeon.templates.list();

// Get a specific template
const template = await pigeon.templates.get("order-shipped");

// Update a template
await pigeon.templates.update("order-shipped", {
  subject: "Order #{{orderId}} is on its way!",
});

// Delete a template
await pigeon.templates.delete("old-template");

Error Handling

The SDK returns { data, error } instead of throwing:

import { SendPigeon } from "sendpigeon";

async function sendEmailSafe(to: string, subject: string, html: string) {
  const { data, error } = await pigeon.send({
    from: "hello@yourdomain.com",
    to,
    subject,
    html,
  });

  if (error) {
    console.error(`SendPigeon error: ${error.message} (${error.status})`);

    // Handle specific errors
    switch (error.status) {
      case 400:
        return { success: false, error: "Invalid email address" };
      case 401:
        return { success: false, error: "Invalid API key" };
      case 429:
        return { success: false, error: "Rate limited, retry later" };
      default:
        return { success: false, error: "Email send failed" };
    }
  }

  return { success: true, emailId: data.id };
}

Production Best Practices

1. Don't Block Critical Paths

Use fire-and-forget so checkout doesn't fail if email fails.

router.post("/checkout", async (req, res) => {
  // Critical: process payment
  const order = await processPayment(req.body);

  // Non-critical: send confirmation
  sendOrderConfirmation(req.user.email, order).catch((err) => {
    console.error("Order confirmation email failed:", err);
    // Queue for retry in background
  });

  res.json({ orderId: order.id });
});

2. Use a Queue for High Volume

import Bull from "bull";

const emailQueue = new Bull("email", process.env.REDIS_URL);

// Producer: add to queue
emailQueue.add("welcome", { email: "user@example.com", name: "Johan" });

// Consumer: process queue
emailQueue.process("welcome", async (job) => {
  const { email, name } = job.data;
  await sendWelcome(email, name);
});

3. Centralized Configuration

// src/config/email.ts
import { SendPigeon } from "sendpigeon";

const config = {
  apiKey: process.env.SENDPIGEON_API_KEY!,
  defaultFrom: process.env.EMAIL_FROM || "hello@yourdomain.com",
  replyTo: process.env.EMAIL_REPLY_TO,
};

export const pigeon = new SendPigeon(config.apiKey);
export const DEFAULT_FROM = config.defaultFrom;

4. Structured Logging

import { pigeon } from "./config/email";

async function sendEmail(params: Parameters<typeof pigeon.send>[0]) {
  const startTime = Date.now();
  const { data, error } = await pigeon.send(params);

  if (error) {
    console.error({
      event: "email_failed",
      to: params.to,
      subject: params.subject,
      error: error.message,
      status: error.status,
      durationMs: Date.now() - startTime,
    });
    return { data: null, error };
  }

  console.log({
    event: "email_sent",
    emailId: data.id,
    to: params.to,
    subject: params.subject,
    durationMs: Date.now() - startTime,
  });
  return { data, error: null };
}

Environment Variables

# .env
SENDPIGEON_API_KEY=sp_live_xxx
EMAIL_FROM=notifications@yourdomain.com
APP_URL=https://myapp.com
# .env.example (commit this)
SENDPIGEON_API_KEY=
EMAIL_FROM=
APP_URL=

Plain Node.js (No Framework)

// send-email.ts
import { SendPigeon } from "sendpigeon";

const pigeon = new SendPigeon(process.env.SENDPIGEON_API_KEY!);

async function main() {
  const { data, error } = await pigeon.send({
    from: "hello@yourdomain.com",
    to: "user@example.com",
    subject: "Test email",
    html: "<p>Hello from Node.js!</p>",
  });

  if (error) {
    console.error(`Failed: ${error.message}`);
    process.exit(1);
  }

  console.log(`Sent: ${data.id}`);
}

main();

Run with:

npx tsx send-email.ts

Next Steps


Other Frameworks

We have dedicated guides for specific frameworks:

See all framework guides.