> ## Site index
> Every page on this site is listed at https://mailkube.com/llms.txt.

> ## Agent instructions
> This site presents mailkube, a service that sends transactional email over a REST API or SMTP.
> Customer data is stored in the European Union.
> Read the developer documentation at https://docs.mailkube.com, which publishes its own llms.txt.
> Take prices and plan volumes from https://mailkube.com/pricing.md, never from a comparison page or a blog post.
> Read the legal documents at their HTML address.
> Every published version of the terms, privacy policy and data processing addendum is kept byte for byte under /legal/<document>/<sha256>/.
> Quote the checked-on date that a comparison page prints with its competitor figures.
> When this site does not state a price, limit or capability, say so instead of guessing.

# Send email from a Cloudflare Worker

> Build a contact form on Cloudflare Workers that sends every submission straight to your mailbox, using the mailkube Node SDK.

Source: https://mailkube.com/blog/send-email-cloudflare-worker/

November 2, 2026


*The email this tutorial's Worker sends. Names, addresses and the IP are examples.*


A visitor fills in a contact form on your site, and the message lands in your mailbox, ready to answer with Reply. This tutorial builds that form on a Cloudflare Worker with the mailkube Node SDK, from an empty folder to the email above.

The footer's IP address and country need no lookup service. Cloudflare adds a `CF-Connecting-IP` header and a `CF-IPCountry` header to every request that reaches your Worker, and the handler copies both into the email.

## Prerequisites

- Node.js 22.12 or later, which the SDK requires
- Wrangler 4.144.0, installed into the project in the first step
- A Cloudflare account
- A mailkube account with a [verified sending domain](https://docs.mailkube.com/docs/domains) and an API key for it

Every example uses `example.com`. Replace it with the domain your key is bound to.

## Create the project

```bash
mkdir contact-form && cd contact-form
npm init -y
npm install @mailkube/mailkube-node
npm install --save-dev wrangler@4.144.0
mkdir public src
```

Then add `wrangler.jsonc` at the project root:

```json
{
  "name": "contact-form",
  "main": "src/index.js",
  "compatibility_date": "2026-10-01",
  "assets": { "directory": "./public" }
}
```

The SDK uses only `fetch` and `crypto.subtle`, so the Worker needs no `nodejs_compat` flag.

## Add the form page

```html
<form method="post" action="/api/contact">
  <label>Name <input name="name" required maxlength="100"></label>
  <label>Email <input type="email" name="email" required></label>
  <label>Message <textarea name="message" required maxlength="5000"></textarea></label>
  <p>We keep your IP address and country with your message.</p>
  <button type="submit">Send</button>
</form>
```

Add a `public/thanks.html` for the confirmation.

## Write the handler

```js
import { ApiError, Mailkube } from '@mailkube/mailkube-node';

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname !== '/api/contact' || request.method !== 'POST') {
      return new Response('Not found', { status: 404 });
    }

    const form = await request.formData();
    const name = String(form.get('name') ?? '');
    const email = String(form.get('email') ?? '');
    const message = String(form.get('message') ?? '');

    const ip = request.headers.get('CF-Connecting-IP') ?? 'unknown';
    const country = request.headers.get('CF-IPCountry') ?? 'unknown';
    const page = sameSite(request.headers.get('Referer'), url);

    const client = new Mailkube({ apiKey: env.MAILKUBE_API_KEY, timeoutMs: 8000 });

    try {
      await client.emails.send({
        from: 'Contact form <contact@example.com>',
        to: 'team@example.com',
        replyTo: email,
        subject: `New message from ${name}`,
        html: `<h2>New message from ${escapeHtml(name)}</h2>`
          + `<p><b>Email:</b> ${escapeHtml(email)}</p>`
          + `<p>${escapeHtml(message).replaceAll('\n', '<br>')}</p>`
          + `<hr><p style="color:#6b7280;font-size:12px">`
          + `Sent from <a href="${escapeHtml(page)}">${escapeHtml(page)}</a>`
          + ` · IP ${escapeHtml(ip)} · ${escapeHtml(flag(country))}</p>`,
        text: `${name} <${email}>\n\n${message}\n\nSent from ${page} · IP ${ip} · ${flag(country)}`,
        idempotencyKey: await fingerprint(name, email, message),
      });
    } catch (error) {
      console.error(error instanceof ApiError
        ? `${error.statusCode} ${error.errorName} (request ${error.requestId})`
        : error);
      return new Response('Your message could not be sent. Please try again.', { status: 502 });
    }

    return Response.redirect(new URL('/thanks.html', url), 303);
  },
};

function flag(country) {
  if (!/^[A-Z]{2}$/.test(country) || country === 'XX') return country;
  return String.fromCodePoint(...[...country].map((c) => 0x1f1a5 + c.charCodeAt(0)));
}

function escapeHtml(value) {
  return value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
}

function sameSite(referer, url) {
  try {
    return new URL(referer).origin === url.origin ? referer : url.origin;
  } catch {
    return url.origin;
  }
}

async function fingerprint(...parts) {
  const bytes = new TextEncoder().encode(parts.join('\n'));
  const digest = await crypto.subtle.digest('SHA-256', bytes);
  return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
```

`CF-IPCountry` is a two-letter code, `XX` when Cloudflare cannot place the address and `T1` for Tor. `flag` turns a real code into its emoji and passes those two through.

`idempotencyKey` is a hash of the submission. A double-click posts the form twice, and the API answers the second post by replaying the first response, for up to 24 hours.

`timeoutMs` lowers the SDK's 30-second default to 8 seconds, since the visitor waits on the submit button.

## Deploy and add the key

```bash
npx wrangler deploy
```

```text
Uploaded contact-form
Deployed contact-form triggers
  https://contact-form.<your-subdomain>.workers.dev
```

```bash
npx wrangler secret put MAILKUBE_API_KEY
```

## Send a test message

Submit the form on your `workers.dev` address, or post the same fields from a terminal:

```bash
curl -i https://contact-form.<your-subdomain>.workers.dev/api/contact \
  -d name='Jane Doe' \
  -d email=jane.doe@example.net \
  -d message='Testing the contact form'
```

```text
HTTP/2 303
location: https://contact-form.<your-subdomain>.workers.dev/thanks.html
```

A 303 means the API accepted the message. On a 502, `npx wrangler tail` shows the error the handler logged.

## Follow the message in Logs

Open Logs in the dashboard and click Details on the message.

*Logs in the dashboard, opening Jane Doe's message. Addresses, IPs and identifiers are examples.*


The header carries the status and the channel. Everything the SDK sends is marked API, and a message submitted over SMTP is marked SMTP.

Message ID is the RFC `Message-ID` header, the value the SDK returns as `email.messageId`. Find it in the headers of the email you received to tie that copy back to this row.

The timeline reads top to bottom. Auth is your API key being checked, Reception is the message accepted for one recipient, and Delivery quotes the receiving server's answer word for word. A message to three recipients gets a Reception and a Delivery for each of them.

Every open and every click after that is an event of its own, with the reader's IP address, country and browser. The panel shows Jane's message read on a laptop, read again on a phone, and its pricing link followed from both.

Opened and Clicked appear only when open and click tracking are on for the sending domain, and the IP address, country and browser appear only when the domain records them. Open tracking works through an invisible image, so a mail client that fetches images ahead of the reader, as Apple Mail Privacy Protection does, records an open nobody made. Read opens as approximate and clicks as the firmer signal.

*The Message Details panel for Jane Doe's message: delivered over the API, with its identifiers and a timeline of Auth, Reception, Delivery, then opens and clicks from two French IP addresses.*


## Troubleshooting

**`403 invalid_api_key` in `npx wrangler tail`.** The Worker has no key, the wrong key, or a key restricted to source IPs. Run `wrangler secret put` from the project folder so the secret lands on this Worker. A Worker's outbound requests leave from Cloudflare's shared addresses, which a source-IP allowlist refuses.

**`422 from_domain_not_allowed`.** The `from` address is on a domain other than the one your key is bound to. Each key sends for one domain, so `contact@` has to be on it.

## Block bots with Turnstile

A public form attracts bots. This optional step puts Cloudflare Turnstile in front of the send, so a submission reaches the SDK only after Turnstile has accepted it.

Create a Turnstile widget for your hostname in the Cloudflare dashboard, then put it inside the form with its site key:

```html
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<form method="post" action="/api/contact">
  <!-- name, email and message fields as before -->
  <div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
  <button type="submit">Send</button>
</form>
```

The widget adds a `cf-turnstile-response` field to the form. Store the secret on the Worker:

```bash
npx wrangler secret put TURNSTILE_SECRET_KEY
```

Then check the token in the handler, right after reading the form:

```js
const check = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
  method: 'POST',
  body: new URLSearchParams({
    secret: env.TURNSTILE_SECRET_KEY,
    response: String(form.get('cf-turnstile-response') ?? ''),
  }),
});
if (!(await check.json()).success) {
  return new Response('Please confirm you are human and try again.', { status: 403 });
}
```

While testing, the site key `1x00000000000000000000AA` with the secret key `1x0000000000000000000000000000000AA` always passes.

## Where to go next

The [Node SDK reference](https://docs.mailkube.com/sdks/node) lists every send parameter and each runtime the SDK is tested on. To receive delivery, bounce, open and click events in a Worker, the webhook section of [sending email from a serverless function](/blog/send-email-serverless/#verifying-webhooks) verifies them on the same runtime.

