Send Email from Node.js: fetch, Nodemailer or an Express Route
Send email from Node.js with no SDK: a fetch call to the API, Nodemailer over SMTP on port 587, and an Express contact-form route. Timeouts and errors.
There is no SDK to install. Node 18 and newer have fetch built in, and the send API is one POST with a JSON body, so a complete email helper is twenty lines. If you would rather keep Nodemailer, point it at a mailbox on your domain over SMTP on port 587 and change nothing else. Here are both, plus the Express route most people are actually trying to build: a contact form that emails the team without exposing anything.
Quick answer
Put the API key in the environment as OQUMAIL_API_KEY and call fetch with a Bearer header, a JSON body where to is an array, and AbortSignal.timeout. The API answers 202 with an id when the message is queued. For Nodemailer, use host mail.oqumail.com, port 587, secure: false (which means STARTTLS on 587) and the mailbox login.
Option 1: a fetch helper
// lib/email.js (Node 18+, ESM)
const API = "https://api.oqumail.com/api/v1/emails";
export async function sendEmail({ to, subject, html, text, fromName }) {
const res = await fetch(API, {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.OQUMAIL_API_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({ to: [to], subject, html, text, fromName }),
signal: AbortSignal.timeout(15000)
});
if (res.status !== 202) {
throw new Error("send failed: " + res.status + " " + (await res.text()));
}
const { id } = await res.json();
return id; // store it with the order or user for support lookups
}Option 2: Nodemailer over SMTP
Keep Nodemailer if your templates and attachments already go through it. The only change is the transport. secure: false is correct for port 587: Nodemailer upgrades the connection with STARTTLS. secure: true is for port 465 and fails here:
import nodemailer from "nodemailer";
const transport = nodemailer.createTransport({
host: "mail.oqumail.com",
port: 587,
secure: false, // STARTTLS on 587
auth: { user: "alerts@yourdomain.com", pass: process.env.MAILBOX_PASSWORD }
});
await transport.sendMail({
from: "Acme Alerts <alerts@yourdomain.com>", // must be the mailbox you log in as
to: "ops@yourdomain.com",
subject: "Nightly report",
text: "Plain-text body.",
html: "<p>HTML body.</p>"
});Option 3: an Express contact-form route
The route validates the input, drops bot submissions with a honeypot field, and emails the team. The visitor's address goes in the body text because the API does not take a Reply-To header:
import express from "express";
import rateLimit from "express-rate-limit";
import { sendEmail } from "./lib/email.js";
const app = express();
app.use(express.json({ limit: "20kb" }));
app.post("/api/contact", rateLimit({ windowMs: 60_000, limit: 5 }), async (req, res) => {
const { name = "", email = "", message = "", website = "" } = req.body;
if (website) return res.json({ ok: true }); // honeypot: bots fill it, people do not
if (!email || !message || message.length > 5000) {
return res.status(400).json({ error: "invalid" });
}
try {
await sendEmail({
to: "team@yourdomain.com",
subject: "Website enquiry from " + name,
text: "From: " + name + " <" + email + ">\n\n" + message,
fromName: "Website form"
});
res.json({ ok: true });
} catch (err) {
console.error(err.message);
res.status(502).json({ error: "send_failed" });
}
});
app.listen(3000);Free business email on your own domain
OquMail gives you up to 15 mailboxes on your domain — free — with guided SPF/DKIM/DMARC, webmail, IMAP/SMTP for any mail app, and a send API. Setup is a few DNS records; changes usually take effect within minutes, occasionally up to 48 hours. Start at oqumail.com.
Which option when
- New code, serverless functions, workers and anything that should show up in the delivery log: the fetch helper.
- An existing codebase built on Nodemailer templates and attachments: the SMTP transport, one config change.
- Bulk newsletters: neither. The free plan allows 250 sends a day per mailbox; campaigns belong in a list tool.
Pitfalls specific to Node
- secure: true with port 587 in Nodemailer. The handshake fails with "wrong version number". Use secure: false on 587.
- A Nodemailer from address that is not the authenticated mailbox. The relay sends only as the mailbox you log in as.
- to as a string in the API body. It must be an array, or the API answers 400.
- Not awaiting the send in a serverless function. The runtime freezes the process before the request finishes; always await.
- No timeout. AbortSignal.timeout on fetch and a connectionTimeout on the transport stop a stuck request from holding a worker forever.
- Logging the key or the mailbox password. Log the API id and the status code, nothing else.
Common questions
What about Node 16 or CommonJS?
Node 16 has no global fetch; install undici or node-fetch, or upgrade, since Node 16 is out of support. For CommonJS, replace the import and export lines with require and module.exports; the body of the helper is unchanged.
How do I attach a file?
With the API, add an attachments array of objects with filename, contentType and contentBase64 (fs.readFileSync(path).toString("base64")). With Nodemailer, pass attachments: [{ filename, path }] to sendMail as before.
Is there a TypeScript type for the request?
The body is small enough to type by hand: to: string[]; subject: string; html?: string; text?: string; fromName?: string; cc?: string[]; bcc?: string[]; attachments?: { filename: string; contentType: string; contentBase64: string }[].