# Send email from Express

Send transactional email from an Express app with emails.sh. A complete server with a send route, validation, and a signed webhook receiver.

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

1. **Create a key** https://emails.sh/dashboard issues a key starting with esh_.
1. **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.
1. **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.
1. **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.

## Install

```bash
npm install express @emails.sh/sdk
```

## .env

```bash
EMAILSSH_API_KEY=esh_your_key_here
EMAILSSH_FROM="Acme <hello@acme.com>"
EMAILSSH_WEBHOOK_SECRET=whsec_your_secret_here
```

## The server

server.js (Express 4 or 5, Node 20+, ESM)

```ts
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('&', '&amp;')
		.replaceAll('<', '&lt;')
		.replaceAll('>', '&gt;');
}

app.listen(3000, () => console.log('listening on http://localhost:3000'));
```

## The webhook receiver

webhooks.js, mounted with app.use(webhooks)

```ts
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.


Docs: https://emails.sh/docs.md