Infrastructure9 min read

Cloud functions + email: send from Lambda, Vercel, Workers

Serverless runtimes can't run an MTA, can't hold DKIM keys locally, and can't warm a sending IP. Every email goes through an HTTPS SDK. That has its own deliverability quirks — here's the path per platform.

The whole point of serverless is no server. No long-lived process, no persistent socket, no local Postfix. Every email has to leave via an HTTPS API call to an ESP — SES, Mailgun, Postmark, SendGrid, Resend. That's the easy part. Deliverability is still on you: DKIM alignment, domain reputation, content, rate-limits, and the places where cold starts quietly break things.

This article covers the three big serverless platforms — AWS Lambda, Vercel Functions, Cloudflare Workers — with the path, the pitfalls, and working code snippets for each.

Why serverless email is different

  • No persistent SMTP socket. Every invocation opens a new HTTPS connection. For low volume fine; for high volume, connection setup dominates and timeouts cascade.
  • Cold start kills retry logic. If your function times out in the middle of a send-retry loop, the retry never happens. You need external queueing (SQS, Cloudflare Queues) for anything transactional.
  • Secrets live outside the runtime. DKIM keys don't belong in your function — they belong at the ESP. You never touch them.
  • Observability is harder. No tail -f /var/log/mail.log. You need CloudWatch/Vercel logs + the ESP's own dashboard, and they don't agree on format.
  • Your domain is still yours. SPF, DKIM (at the ESP), DMARC, alignment — all still your responsibility.

AWS Lambda + SES

The native path. SES is AWS's ESP; Lambda has IAM roles that let it call SES without credentials in code. SES domain verification is clean; DKIM is auto-generated by SES and you publish three CNAMEs.

// handler.ts  (Node.js Lambda, AWS SDK v3)
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';

const ses = new SESv2Client({ region: 'eu-west-1' });

export const handler = async (event: {
  to: string;
  subject: string;
  html: string;
}) => {
  const cmd = new SendEmailCommand({
    FromEmailAddress: 'hello@yourdomain.com',
    Destination: { ToAddresses: [event.to] },
    Content: {
      Simple: {
        Subject: { Data: event.subject },
        Body: { Html: { Data: event.html } },
      },
    },
    ConfigurationSetName: 'transactional',  // for event destinations
  });

  try {
    const out = await ses.send(cmd);
    return { messageId: out.MessageId };
  } catch (err) {
    // Transient? Re-queue to SQS. Hard fail? Log and drop.
    throw err;
  }
};
# serverless.yml  - give the function SES permissions
service: txn-email

provider:
  name: aws
  runtime: nodejs20.x
  region: eu-west-1
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - ses:SendEmail
            - ses:SendRawEmail
            - sesv2:SendEmail
          Resource: '*'

functions:
  send:
    handler: handler.handler
    timeout: 10
    memorySize: 256
    events:
      - sqs:
          arn: arn:aws:sqs:eu-west-1:123456789012:email-queue
          batchSize: 10

Trigger via SQS, not direct API Gateway. SQS gives you automatic retries with DLQ for failures; API Gateway just drops them.

SES deliverability knobs

  • Move out of the SES sandbox before production (request in console).
  • Verify your sending domain — publish the three DKIM CNAMEs SES gives you.
  • Add include:amazonses.com to your domain's SPF.
  • Request a dedicated IP if sending >50k/month — shared SES IPs have mixed reputation.
  • Enable SES virtual deliverability manager for placement / complaint tracking.

Vercel Functions + Resend (or SendGrid)

Vercel has no native ESP. Resend was founded by Vercel alumni and has best-in-class React Email integration, so it's the path of least resistance. SendGrid and Postmark work equally well via HTTPS.

// app/api/send/route.ts  (Vercel Next.js route handler)
import { Resend } from 'resend';
import { NextResponse } from 'next/server';

const resend = new Resend(process.env.RESEND_API_KEY);

export const runtime = 'nodejs';   // or 'edge' - see below

export async function POST(req: Request) {
  const { to, subject, html } = await req.json();

  try {
    const { data, error } = await resend.emails.send({
      from: 'hello@yourdomain.com',
      to,
      subject,
      html,
      headers: {
        'X-Entity-Ref-ID': crypto.randomUUID(),  // for your logs
      },
    });
    if (error) return NextResponse.json({ error }, { status: 500 });
    return NextResponse.json({ messageId: data?.id });
  } catch (err) {
    return NextResponse.json({ error: String(err) }, { status: 500 });
  }
}
Node runtime vs Edge runtime

Edge runtime has smaller SDK compatibility and no Buffer/crypto quirks. Resend's SDK supports both; SendGrid's Node SDK does not work on Edge. For max compatibility, use Node runtime unless you have a latency reason to go Edge.

Vercel deliverability knobs

  • Vercel doesn't send mail itself — the Vercel dashboard's "deployment notification" email uses Resend under the hood.
  • All auth is at your ESP (Resend, SendGrid, Postmark). Publish their DKIM/return-path records.
  • For long-running send jobs, use Vercel Queues or an external queue (Upstash, Inngest). Function timeout is 10s on Hobby, 60s on Pro; a big campaign doesn't finish in one invocation.

Cloudflare Workers + MailChannels / Resend

Workers can't open outbound TCP (no SMTP). You either use MailChannels (historically free for Workers, now partial paid) or call an ESP's HTTPS API. Resend, SendGrid, Postmark all work over fetch().

// worker.ts  (Cloudflare Workers with Resend via fetch)
export interface Env {
  RESEND_API_KEY: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const { to, subject, html } = await request.json<{
      to: string; subject: string; html: string;
    }>();

    const resp = await fetch('https://api.resend.com/emails', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${env.RESEND_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        from: 'hello@yourdomain.com',
        to,
        subject,
        html,
      }),
    });

    if (!resp.ok) {
      const err = await resp.text();
      return new Response(JSON.stringify({ error: err }), {
        status: 500,
        headers: { 'Content-Type': 'application/json' },
      });
    }

    const data = await resp.json();
    return new Response(JSON.stringify(data), {
      headers: { 'Content-Type': 'application/json' },
    });
  },
};
# wrangler.toml
name = "send-mail"
main = "worker.ts"
compatibility_date = "2027-01-01"

[vars]
# public env vars (non-secret)

# Secrets:  wrangler secret put RESEND_API_KEY

[[queues.consumers]]
queue = "email-queue"
max_batch_size = 10
max_batch_timeout = 5

Workers deliverability knobs

  • No TCP egress means no SMTP. Period. Use HTTPS APIs only.
  • Cloudflare Email Routing handles inbound only — it doesn't send.
  • MailChannels was the de-facto free path for Workers but tightened pricing in 2024. Check current terms.
  • Use Cloudflare Queues for retry; Worker CPU time is capped, big bursts overflow.

What's the same across all three

  1. Your domain owns the reputation. SPF, DKIM at the ESP, DMARC — same as any other setup. A clean ESP account with a dirty sending domain still lands in Spam.
  2. From address must match DKIM domain. Sending From: hello@yourdomain.com with DKIM d=sesmail.amazonses.com is alignment-fail — DMARC rejects. Always verify your own domain at the ESP.
  3. Rate-limits are per-account at the ESP. Serverless can burst; most ESPs throttle gracefully, but if you blast 100k/min you'll hit 429 and your function queue backs up.
  4. Observability needs effort. Log messageId + recipient + template-id to CloudWatch/Logpush; correlate with the ESP's delivery webhook. Without this you can't answer "did that password reset send?"
  5. Placement testing still applies. Before shipping a new template, run it through a seed-mailbox check. Serverless doesn't change spam filters at Gmail — they grade on content + reputation regardless of origin.

How to pick an ESP for serverless

  • SES: cheapest at scale, best if you're already AWS-native. Sharp edges: sandbox mode, shared IPs with mixed reputation, complex bounce/complaint handling.
  • Postmark: best deliverability out of the box for transactional. Expensive per-message but worth it for receipts, password resets.
  • Resend: great DX for Next.js/React shops. Good deliverability, newer platform.
  • SendGrid: universal SDK support, everything-for-everyone. Shared IPs can be noisy; dedicated IP at higher plans.
  • Mailgun: solid, used to be indie-friendly, now requires credit card on sign-up. Good for transactional & marketing split configs.
Verify placement before shipping

Before a new template goes live, run it from your function against 20+ seed addresses. Inbox Check gives you free seed mailboxes across Gmail, Outlook, Yahoo, Mail.ru, Yandex, GMX, ProtonMail. Read the placement + auth headers before a real user ever sees it.

Frequently asked questions

Can I run Postfix inside Lambda?

No. Lambda has no persistent process. Even if you bundled Postfix, the queue would vanish on cold start and port 25 outbound is blocked at AWS. Always relay through an HTTPS ESP.

Are Cloudflare Workers too limited for serious mail?

For transactional sends up to a few million/month, Workers + Resend or SendGrid via fetch is great. Limits: no TCP, CPU time cap, no big attachments. If you're doing million-per-batch marketing, use a beefier runtime (Lambda behind SQS) and a dedicated marketing ESP.

Should I queue emails at all for low-volume apps?

Yes. Even at 10 emails/day you want retries. A 500 from the ESP during a password-reset request is a lost reset without a queue. SQS/Cloudflare Queues/Upstash costs nearly nothing at that volume.

Do I need a dedicated IP for serverless sends?

Usually no. Transactional on shared IP with a reputable ESP (Postmark, SES with warm account) is fine up to mid-scale. Consider dedicated IP when you're past ~50k/month and want to control reputation yourself, or when shared IP neighbours are causing spikes.
Related reading

Check your deliverability across 20+ providers

Gmail, Outlook, Yahoo, Mail.ru, Yandex, GMX, ProtonMail and more. Real inbox screenshots, SPF/DKIM/DMARC, spam engine verdicts. Free, no signup.

Run Free Test →

Unlimited tests · 20+ seed mailboxes · Live results · No account required