Send email from Vercel
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
- 01
Create a key
https://emails.sh/dashboard issues a key starting with esh_.
- 02
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.
- 03
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.
- 04
Add the function
api/send.ts below, which works in any framework Vercel hosts and as a bare function with no framework at all.
npm install @emails.sh/sdk# 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_stringThe function
api/send.ts (Vercel Functions, Node runtime)
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('&', '&').replaceAll('<', '<').replaceAll('>', '>');
}A cron send
api/cron/digest.ts, triggered by vercel.json
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
{
"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.
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.