# Send email from Vercel

Send transactional email from a Vercel serverless or edge function with emails.sh, including why SMTP and cron sends fail there.

Vercel functions are short lived and stateless, which breaks SMTP libraries that expect to hold a connection open. This page gives you the function, the environment variable setup per environment, and the cron route, all over HTTPS.

## Setup

1. **Create a key** https://emails.sh/dashboard issues a key starting with esh_.
1. **Add it in project settings** Settings, Environment Variables, name EMAILSSH_API_KEY. Tick Production, Preview, and Development separately: a variable set only for Production is undefined in preview deployments.
1. **Redeploy after adding it** Environment variables are injected at build and runtime for a given deployment. An existing deployment does not pick up a variable you added afterwards.
1. **Add the function** api/send.ts below, which works in any framework Vercel hosts and as a bare function with no framework at all.

## Install

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

## Environment variables

```bash
# Set these in Settings, Environment Variables, ticked for all three
# environments. Pull them locally with: npx vercel env pull .env.local
EMAILSSH_API_KEY=esh_your_key_here
EMAILSSH_FROM="Acme <hello@acme.com>"
CRON_SECRET=a_long_random_string
```

## The function

api/send.ts (Vercel Functions, Node runtime)

```ts
import { Emailssh } from '@emails.sh/sdk';

const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY! });

export async function POST(request: Request): Promise<Response> {
	const body = (await request.json()) as { email?: string; name?: string };

	if (!body.email?.includes('@')) {
		return Response.json({ error: 'A valid email is required' }, { status: 400 });
	}

	const name = body.name ?? 'there';

	try {
		const sent = await mail.send({
			from: process.env.EMAILSSH_FROM ?? 'Acme <onboarding@emails.sh>',
			to: [body.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:${body.email.toLowerCase()}`
		});

		return Response.json({ id: sent.id, status: sent.status });
	} catch (error) {
		console.error('emails.sh send failed', error);
		return Response.json({ error: 'Could not send right now' }, { status: 502 });
	}
}

function escapeHtml(value: string) {
	return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}
```

## A cron send

api/cron/digest.ts, triggered by vercel.json

```ts
import { Emailssh } from '@emails.sh/sdk';

const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY! });

export async function GET(request: Request): Promise<Response> {
	// Vercel Cron sends Authorization: Bearer <CRON_SECRET>. Without this check
	// the route is a public URL anyone can hit to make you send mail.
	const auth = request.headers.get('authorization');
	if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
		return new Response('Unauthorized', { status: 401 });
	}

	const recipients = ['a@example.com', 'b@example.com'];

	// Batch, so 100 recipients is one request and does not run into the
	// function's execution time limit.
	await mail.batch(
		recipients.map((to) => ({
			from: process.env.EMAILSSH_FROM ?? 'Acme <onboarding@emails.sh>',
			to: [to],
			subject: 'Your Acme digest',
			html: '<p>Here is what happened yesterday.</p>',
			text: 'Here is what happened yesterday.'
		}))
	);

	return Response.json({ ok: true, count: recipients.length });
}
```

## The cron schedule

vercel.json

```json
{
	"crons": [
		{
			"path": "/api/cron/digest",
			"schedule": "0 9 * * *"
		}
	]
}
```

## Worth knowing

### A serverless function cannot hold an SMTP connection

The function is frozen or destroyed after the response, so the socket a pooled SMTP transport wants to keep alive does not survive. This is the concrete reason nodemailer plus Gmail works locally and times out on Vercel.

### Preview deployments need their own variables

A variable ticked only for Production is undefined in every preview, so the branch you are testing 500s while production is fine. Tick all three environments, or set a separate test key for preview.

### The execution limit applies to your send loop

Functions have a wall-clock limit (10s on the Hobby default, higher on Pro and configurable). A loop of 200 sequential sends will be cut off partway. Use /v1/emails/batch, which is one request for up to 100 messages.

### Protect cron routes with CRON_SECRET

/api/cron/* is a normal public URL. Vercel sends Authorization: Bearer with your CRON_SECRET, so compare against it and 401 otherwise.


## Questions

### Why does nodemailer not work on Vercel?

Serverless functions do not persist between requests, so a pooled SMTP connection cannot survive, and outbound SMTP ports are commonly blocked. An HTTPS API has neither constraint.

### Do I need to redeploy after adding an environment variable?

Yes. Existing deployments keep the values they were created with.

### Can I send from an edge function?

Yes. The SDK is built on fetch, so it runs on the edge runtime. Only Node-specific libraries fail there.

### How do I test this locally?

npx vercel env pull .env.local to fetch the variables, then npx vercel dev.


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