Send email from Express
Express gives you nothing for email, which makes this short: one route, one SDK call. This page has the whole server file, the raw-body webhook receiver that verifies signatures, and the reason nodemailer plus Gmail keeps failing for people once they deploy.
Setup
- 01
Create a key
https://emails.sh/dashboard issues a key starting with esh_.
- 02
Load it from the environment
Node 20.6+ supports node --env-file=.env, so you do not need dotenv. Never hardcode the key in the source file.
- 03
Add the route
server.js below. Construct the client once at module scope, not per request, so you are not rebuilding it on every call.
- 04
Mount the webhook before express.json()
Signature verification needs the raw bytes. express.raw on that one path gives you a Buffer while the rest of the app still parses JSON.
npm install express @emails.sh/sdkEMAILSSH_API_KEY=esh_your_key_here
EMAILSSH_FROM="Acme <hello@acme.com>"
EMAILSSH_WEBHOOK_SECRET=whsec_your_secret_hereThe server
server.js (Express 4 or 5, Node 20+, ESM)
import express from 'express';
import { Emailssh } from '@emails.sh/sdk';
const app = express();
const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY });
const FROM = process.env.EMAILSSH_FROM ?? 'Acme <onboarding@emails.sh>';
// The webhook route is mounted before express.json() so it still receives the
// raw bytes the signature was computed over.
app.use('/webhooks/emailssh', express.raw({ type: 'application/json' }));
app.use(express.json());
app.post('/send', async (req, res) => {
const { email, name = 'there' } = req.body ?? {};
if (typeof email !== 'string' || !email.includes('@')) {
return res.status(400).json({ error: 'A valid email is required' });
}
try {
const sent = await mail.send({
from: FROM,
to: [email],
subject: 'Welcome to Acme',
html: `<p>Hi ${escapeHtml(name)}, your Acme account is ready.</p>`,
text: `Hi ${name}, your Acme account is ready.`,
idempotencyKey: `welcome:${email.toLowerCase()}`
});
res.json({ id: sent.id, status: sent.status });
} catch (error) {
// The API describes what to do next in the body, so surface it in logs
// and keep the generic message for the caller.
console.error('emails.sh send failed', error);
res.status(502).json({ error: 'Could not send right now' });
}
});
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>');
}
app.listen(3000, () => console.log('listening on http://localhost:3000'));The webhook receiver
webhooks.js, mounted with app.use(webhooks)
import crypto from 'node:crypto';
import express from 'express';
export const webhooks = express.Router();
webhooks.post('/webhooks/emailssh', (req, res) => {
const signature = req.get('x-emailssh-signature') ?? '';
const expected = crypto
.createHmac('sha256', process.env.EMAILSSH_WEBHOOK_SECRET)
.update(req.body) // req.body is a Buffer here, thanks to express.raw
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
// timingSafeEqual throws on a length mismatch, so check that first.
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ error: 'bad signature' });
}
const event = JSON.parse(req.body.toString('utf8'));
switch (event.type) {
case 'email.bounced':
case 'email.complained':
console.warn('Suppress', event.data.to[0], 'after', event.type);
break;
case 'email.delivered':
console.log('Delivered', event.data.id);
break;
}
// Reply 2xx quickly. Anything slow here gets retried as a failure.
res.status(204).end();
});Worth knowing
express.json() destroys the bytes the signature covers
Once the JSON body parser runs you have an object, and re-serialising it changes key order and whitespace, so the HMAC never matches. Mount express.raw on the webhook path before the parser.
nodemailer plus Gmail fails on most hosts
Gmail SMTP needs an app password and an open outbound port, and platforms including most serverless hosts block port 465/587. An HTTPS API has neither problem, which is why the same code that worked on your laptop stopped working on deploy.
Do not put the send in front of the response
If the user does not need the email id, respond first and send after, or push it to a queue. A 15 second API timeout should not become a 15 second signup.
Express 5 handles async errors, Express 4 does not
In Express 4 a rejected promise in a handler is not caught by your error middleware, which is why the try/catch above is explicit. Express 5 forwards it for you, but keeping the catch costs nothing.
Questions
How do I send an email in Node.js and Express?
One POST to https://emails.sh/v1/emails with a Bearer token, or mail.send from the SDK, inside a route handler. No SMTP setup at all.
Why does nodemailer work locally but not in production?
Your host blocks outbound SMTP ports, or Gmail rejected the sign-in from a datacenter IP. An HTTPS API is not affected by either.
How do I verify emails.sh webhooks in Express?
HMAC-SHA256 the raw request body with your webhook secret and compare it against the x-emailssh-signature header with crypto.timingSafeEqual.
Can I send to more than one recipient?
Yes, to takes an array. For different bodies per recipient, POST to /v1/emails/batch with up to 100 messages in one call.
The rest of the API
POST /v1/emails sends one and POST /v1/emails/batch sends up to 100 in a call. GET /v1/emails/:id returns the delivery events for a message, which is how you answer "did it arrive" without a support ticket. Webhooks push the same events to you as they happen.