How to send email with Node.js
Send transactional email from Node.js with the SDK, plain fetch, or SMTP — with the error handling and queueing that production actually needs.
Most Node applications need to send email long before they need an email strategy: a password reset, a receipt, a notification. This covers the three ways to do it, and the parts that are easy to get wrong.
Before you start
You need three things, and they take about five minutes:
- An account. Sign up free — 5,000 emails a month, no card.
- A verified sending domain. Add your domain in the dashboard and publish the DKIM and SPF records it gives you. You cannot send from a domain you have not proved you control, and neither can anyone pretending to be you — that is the whole point. If the terms are unfamiliar, SPF, DKIM and DMARC explained covers them properly.
- An API key. Create one under API Keys. Treat it like a password: it can send mail as you.
Put the key in your environment rather than your source. Node reads it from there in every example below.
The quickest version
npm install pigeonify
import { Pigeonify } from "pigeonify"
const pigeonify = new Pigeonify(process.env.PIGEONIFY_API_KEY)
const result = await pigeonify.emails.send({
from: "you@yourdomain.com",
to: "customer@example.com",
subject: "Welcome aboard",
html: "<p>Thanks for signing up.</p>",
})
console.log(result.id)
That is the whole thing. The SDK retries on rate limits and transient network failures, respects Retry-After, and never retries a 4xx — because retrying "that address does not exist" a hundred times does nothing but damage your sending reputation.
Without the SDK
If you would rather not add a dependency, the API is a single POST:
const response = await fetch("https://www.pigeonify.com/api/v1/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.PIGEONIFY_API_KEY,
},
body: JSON.stringify({
from: "you@yourdomain.com",
to: "customer@example.com",
subject: "Welcome aboard",
html: "<p>Thanks for signing up.</p>",
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(`${error.code}: ${error.error}`)
}
Read the body on failure. The code field tells you whether to retry — quota_exhausted and rate_limited are worth retrying, invalid_recipient never is.
Over SMTP
If you already have a Nodemailer setup, you can keep it and change three lines:
import nodemailer from "nodemailer"
const transporter = nodemailer.createTransport({
host: "smtp.pigeonify.com",
port: 587, // 587 = STARTTLS, 465 = implicit TLS
auth: {
user: process.env.PIGEONIFY_SMTP_USER,
pass: process.env.PIGEONIFY_SMTP_PASS,
},
})
await transporter.sendMail({
from: "you@yourdomain.com",
to: "customer@example.com",
subject: "Welcome aboard",
html: "<p>Thanks for signing up.</p>",
})
SMTP is the right choice when you are migrating something that already speaks it. For new code the API is easier to debug, because a failure comes back as JSON rather than a three-digit code and a sentence. We wrote about the trade-off in SMTP vs API sending.
Sending HTML that survives
The single most common surprise: email HTML is not web HTML. Flexbox, grid and <style> blocks are unreliable or unsupported across clients, and Outlook on Windows renders with Word's engine. Use tables for layout, put styles inline, and keep the width at 600px.
const html = `
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0"
style="width:600px;max-width:600px;background:#ffffff;">
<tr>
<td style="padding:32px;font-family:Helvetica,Arial,sans-serif;font-size:15px;color:#0c1a19;">
<h1 style="margin:0 0 16px;font-size:24px;">Welcome aboard</h1>
<p style="margin:0;line-height:1.6;">Thanks for signing up.</p>
</td>
</tr>
</table>`
What to get right before production
Do not send from a request handler and wait. An HTTP call to any email provider can take a second or more, and a slow provider becomes a slow checkout page. Queue the send and return.
Handle the failure. A send can fail because the address is wrong, because you are rate limited, or because the provider is having a bad minute. The first is permanent and the other two are not — retrying a dead address forever is how a sender's reputation degrades. See how to handle bounces.
Use a subdomain. Send transactional mail from mail.yourdomain.com rather than the domain that carries your business correspondence. If sending reputation is ever damaged, the damage stays contained.
Never send to an address that has bounced or unsubscribed. Suppression should be automatic, not a thing you remember. Pigeonify suppresses permanent bounces and complaints for you and refuses to send to them afterwards.
Queueing the send
The minimum viable version in plain Node, without adding a queue library:
// Fire the send without blocking the response. The .catch is not optional:
// an unhandled rejection takes the process down in modern Node.
function sendInBackground(message) {
pigeonify.emails
.send(message)
.catch((error) => console.error("[email] send failed", message.to, error))
}
app.post("/signup", async (req, res) => {
const user = await createUser(req.body)
sendInBackground({
from: "you@yourdomain.com",
to: user.email,
subject: "Welcome aboard",
html: renderWelcome(user),
})
res.json({ ok: true })
})
This is fine for low volume. Once email matters to the business, move it to a real queue so a failed send is retried rather than logged and forgotten.
Where to go next
- Node.js API reference — every parameter, including attachments and custom headers
- Transactional vs marketing email — why they need different handling
- Why emails land in spam — the things that actually cause it
If you are weighing providers, we publish honest comparisons that say where the other product is better.