Vercel
A function that sends, the environment variables set per environment, and a cron route that is not a public send button.
A Vercel function is short lived and stateless, which is why every SMTP library fails there in a way that looks intermittent. A connection held open across invocations does not survive, and the send that worked in development times out in production. An HTTPS call has none of that: it is one request, it finishes, the function exits.
This page is the function itself. It works under any framework Vercel hosts, and as a bare function with no framework at all. The Next.js version of the same thing, with the App Router specifics, is at /docs/nextjs.
Install
npm install @emails.sh/sdkThe environment variables
Set them under Settings, then Environment Variables, and tick Production, Preview, and Development separately. A variable ticked only for Production is undefined in every preview deployment, which is the single most common way this works locally and not on a pull request.
# Settings, Environment Variables. Tick all three environments.
# The key comes from https://emails.sh/dashboard/api-keys.
EMAILSSH_API_KEY=esh_your_key_here
EMAILSSH_FROM="Acme <hello@acme.com>"
CRON_SECRET=a_long_random_string
# Then pull them down for local development:
npx vercel env pull .env.localThe function
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.`,
// Derived from the address, so a double-submitted form is one email.
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('>', '>');
}The client is constructed once at module scope rather than per request, so a warm function reuses it. Nothing about it holds a socket open, so a cold start costs nothing either.
The edge runtime
The same code runs unchanged on the edge runtime. The SDK is fetch on top of a JSON body and uses no Node built-in, so there is no polyfill and no bundler configuration. Reading an attachment off disk is the one thing that does not work there, because there is no disk: fetch the bytes or store them base64 encoded already.
export const runtime = 'edge';
import { Emailssh } from '@emails.sh/sdk';
const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY! });
export async function POST(): Promise<Response> {
const sent = await mail.send({
from: process.env.EMAILSSH_FROM ?? 'Acme <onboarding@emails.sh>',
to: ['ada@example.com'],
subject: 'Sent from the edge',
text: 'No Node built-ins were involved.'
});
return Response.json({ id: sent.id });
}Cron
A Vercel cron job is an HTTP GET to a route of yours on a schedule. That route is a public URL, so without an authorisation check it is a button anybody on the internet can press to make you send mail. Vercel sends Authorization: Bearer with the CRON_SECRET you set, and comparing it is two lines.
import { Emailssh } from '@emails.sh/sdk';
const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY! });
export async function GET(request: Request): Promise<Response> {
// Without this check the route is a public URL anyone can hit.
if (request.headers.get('authorization') !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response('Unauthorized', { status: 401 });
}
const recipients = ['ada@example.com', 'grace@example.com'];
// One request for up to 100 emails, so the function finishes well inside
// its wall-clock budget and each recipient gets their own copy.
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 });
}{
"crons": [
{ "path": "/api/cron/digest", "schedule": "0 9 * * *" }
]
}If the digest is going to a real list rather than a hardcoded array, a broadcast is a better fit than a cron job that loops: it checks topics and suppression per recipient, it reports per-recipient results, and it can be booked with scheduled_at instead of needing a cron at all. See /docs/broadcasts.
What bites here
- Nodemailer times out
- It expects a connection it can hold. A function that froze between invocations comes back to a socket the far end closed. Use the HTTPS call above, or
/docs/smtpif the code genuinely cannot change. - It works in production and not in preview
- The variable was ticked for Production only. Tick Preview and Development too, then redeploy.
- The function returns before the send finishes
- Await the send. A promise left floating in a serverless function is cancelled when the invocation ends, and the email silently never goes.
- The cron route sends twice
- A retried invocation. Pass
idempotency_keyderived from the day and the recipient and the repeat sends nothing. - The key is in the client bundle
- Anything a component imports can end up in the browser. Keep the send in api/, and never prefix the variable with NEXT_PUBLIC_.