Skip to main content
Guide

Send email from a serverless function

Send email from a serverless function on AWS Lambda, Google Cloud Functions, Azure Functions or Cloudflare Workers, with working code for each one.

mailkube 6 min read Updated August 18, 2026
sdkserverless

A contact form has to reach somebody. A new signup needs a verification link before the account is worth anything. A login needs a one-time code, and a forgotten password needs a reset link within seconds, or the person gives up.

None of those jobs is worth a server that runs all night waiting for them, so they end up in a serverless function: AWS Lambda, Google Cloud Functions, Azure Functions, Cloudflare Workers, Deno Deploy. The function wakes up, sends one message, and stops.

The hard part is the client. It has to run wherever the function does, and function runtimes are missing things a Node server takes for granted. The SDK below has no dependencies and asks for nothing a function runtime lacks, so one call sends email from a serverless function on any of them. What changes between platforms is how the handler is declared and where the API key comes from.

Send email from a serverless function, the short version

npm install @mailkube/mailkube-node
import { Mailkube } from '@mailkube/mailkube-node';

const client = new Mailkube();   // reads MAILKUBE_API_KEY from the environment

export const handler = async (event) => {
  await client.emails.send({
    from: 'hello@example.com',
    to: event.to,
    subject: 'Reset your password',
    html: '<p><a href="https://example.com/reset?token=YOUR_TOKEN">Choose a new password</a></p>',
  });

  return { statusCode: 202 };
};

Two things have to exist before any of that runs: an API key, and a sending domain you have verified. Everything below assumes both.

Send email from AWS Lambda

Lambda reuses the execution environment between invocations, so build the client once at module scope. It survives from one cold start to the next instead of being rebuilt on every request.

import { Mailkube, RateLimitError } from '@mailkube/mailkube-node';

const client = new Mailkube();

export const handler = async (event) => {
  try {
    const email = await client.emails.send({
      from: 'billing@example.com',
      to: event.to,
      subject: 'Your receipt',
      html: '<p>Thanks for your order.</p>',
      idempotencyKey: event.requestId,
    });

    return { statusCode: 202, body: JSON.stringify({ id: email.id }) };
  } catch (error) {
    if (error instanceof RateLimitError) {
      return {
        statusCode: 429,
        headers: { 'retry-after': String(error.retryAfter ?? 60) },
        body: JSON.stringify({ error: error.errorName }),
      };
    }
    throw error;
  }
};

The idempotencyKey is the invocation’s own request id, because Lambda retries a failed invocation and you would rather that produce one receipt than two. The rate-limit branch hands the wait back to the caller instead of sleeping, because time spent waiting inside a function is billed by the millisecond. Neither is how you would write this on a server that stays up.

Send email from Google Cloud Functions

Same Node runtime, same module-scope client, different way of registering the handler.

import functions from '@google-cloud/functions-framework';
import { Mailkube } from '@mailkube/mailkube-node';

const client = new Mailkube();

functions.http('sendReceipt', async (req, res) => {
  const email = await client.emails.send({
    from: 'billing@example.com',
    to: req.body.to,
    subject: 'Your receipt',
    html: '<p>Thanks for your order.</p>',
    idempotencyKey: req.body.orderId,
  });

  res.status(202).json({ id: email.id });
});

Set the API key with --set-secrets MAILKUBE_API_KEY=mailkube-api-key:latest at deploy time so it comes from Secret Manager rather than sitting in the function’s plain configuration.

Send email from Azure Functions

The Node v4 programming model registers the function in code, and the client still belongs at module scope.

import { app } from '@azure/functions';
import { Mailkube } from '@mailkube/mailkube-node';

const client = new Mailkube();

app.http('sendReceipt', {
  methods: ['POST'],
  authLevel: 'function',
  handler: async (request) => {
    const { to, orderId } = await request.json();

    const email = await client.emails.send({
      from: 'billing@example.com',
      to,
      subject: 'Your receipt',
      html: '<p>Thanks for your order.</p>',
      idempotencyKey: orderId,
    });

    return { status: 202, jsonBody: { id: email.id } };
  },
});

MAILKUBE_API_KEY goes in the app settings, and in local development in local.settings.json, which stays out of source control.

Send email from a Cloudflare Worker

Workers are the one case where module scope is wrong. The API key arrives with each request through the env binding rather than from an ambient environment, so build the client inside fetch.

import { Mailkube } from '@mailkube/mailkube-node';

export default {
  async fetch(request, env) {
    const client = new Mailkube({ apiKey: env.MAILKUBE_API_KEY, timeoutMs: 8000 });
    const { to, orderId } = await request.json();

    const email = await client.emails.send({
      from: 'billing@example.com',
      to,
      subject: 'Your receipt',
      html: '<p>Thanks for your order.</p>',
      idempotencyKey: `receipt-${orderId}`,
      tags: [{ name: 'kind', value: 'receipt' }],
    });

    return Response.json({ id: email.id });
  },
};

Store the key as a secret, not as a plain variable:

npx wrangler secret put MAILKUBE_API_KEY

Note timeoutMs. The client waits 30 seconds by default, which is longer than most edge invocations are allowed to live. Setting it below your platform’s limit gives you an error you can handle instead of the platform killing the invocation out from under you.

Deno and Bun need no changes at all. On Deno, import with npm:@mailkube/mailkube-node. On Bun, bun add @mailkube/mailkube-node and the code above is unchanged.

Verifying webhooks

Deliveries, bounces, opens and clicks come back as signed webhooks, and a receiver is the other half of most serverless email setups. It runs on the same runtimes as the sender.

import { verify } from '@mailkube/mailkube-node';

export default {
  async fetch(request, env) {
    const body = new Uint8Array(await request.arrayBuffer());
    const event = await verify(body, request.headers, env.MAILKUBE_WEBHOOK_SECRET);

    switch (event.type) {
      case 'email.bounced':
        console.log(event.data.bounce.reason, event.data.bounce.code);
        break;
      case 'email.clicked':
        console.log(event.data.click.link);
        break;
      default:
        break;
    }

    return new Response(null, { status: 204 });
  },
};

Read the body as bytes with arrayBuffer, never as parsed JSON. The signature covers the exact bytes that arrived, and parsing then re-serializing changes them.

Retries reuse the same X-Webhook-Id, so store it and treat a repeat as already handled.

Common mistakes

  • Building the client inside the handler on Lambda, Cloud Functions or Azure. The execution environment is reused, so module scope costs nothing and saves the setup on every warm invocation. On Workers the opposite holds, because the key arrives with the request.
  • Leaving the default 30 second timeout under a shorter function budget. The platform kills the invocation before the client raises anything you can catch. Set timeoutMs below your limit.
  • Sleeping on a 429 inside the function. You pay for the wait and may exceed the invocation budget anyway. Return retryAfter and let the caller come back.
  • Passing a Buffer as attachment content. Attachments take a base64 string or a Uint8Array. Buffer is a Node type and does not exist on Workers.
  • Parsing a webhook body before verifying it. The signature is over the raw bytes, so verify first and parse after.

Where to go next

The Node SDK reference(opens in a new tab) has every parameter, the full webhook event catalogue and the runtime detail behind the examples above. If you are starting from scratch, sending your first email from Node.js covers domain verification and the first send, and sending email from Express, Fastify, Next.js and NestJS covers the same ground for a server that stays up.

Three steps to your first send

Create an account, verify a domain, and send your first message today.

Join the early access waitlist