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

In the project root
npm install @emails.sh/sdk

The 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.

Where the key goes
# 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.local

The function

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

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.

api/edge-send.ts
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.

api/cron/digest.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> {
  // 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 });
}
vercel.json
{
  "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/smtp if 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_key derived 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_.