# 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:
```bash
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:
```bash
# 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
```

Variables are injected into a deployment when it is built. Adding one to an existing deployment does nothing until you redeploy, and the symptom is a 401 unauthorized from a build that used to work.

### The function

api/send.ts:
```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:
```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:
```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:
```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_.

The full runnable version of this integration, including the form component, is at https://emails.sh/with/vercel.

---

Base URL: https://emails.sh/v1. Auth: `Authorization: Bearer esh_...`.
Whole API in one file: https://emails.sh/llms.txt. All documentation: https://emails.sh/docs.md.
