Blog 6 min read

How to send an email campaign with Polar, Resend, and Cloudflare Workers

A practical guide to using Polar customer data, Resend, Cloudflare Workers, and D1 to send email campaigns in small batches with unsubscribe links and deliverability safeguards.

I had a simple problem: thousands of people had downloaded a free theme or bought something through Polar, and I wanted to email them about a new product — without Mailchimp, without blasting 2,000 messages on day one, and without wrecking deliverability on a domain I still need for transactional mail.

The answer was a small system: Polar for customer data, Cloudflare Workers + D1 for campaign state, and Resend for sending. This post walks through how to build the same thing yourself.


What you need

  • A Polar account with customers or orders (and API access, or the Polar MCP inside Cursor)
  • Resend with a verified sending domain and API key
  • Cloudflare Workers and a D1 database
  • A Worker cron trigger (hourly is enough to start)

You do not need a full email marketing platform. You do need discipline: batch sends, unsubscribe links, and skip rules for people who already bought or opted out.


The architecture

txt
Polar (customers / orders)


Import recipients → D1 (pending / sent / skipped / failed)


Cron Worker picks a small batch

        ├── skip unsubscribed / already bought
        ├── render HTML + text
        └── send via Resend (with idempotency key)


Unsubscribe page on the Worker URL

Polar is the source of truth for who paid or signed up. D1 is the source of truth for campaign state — who is queued, who was sent, who unsubscribed. Resend only sends; it does not replace your queue or compliance logic.


Step 1: Create the D1 tables

Start with a recipients table and an unsubscribes table. Keep it boring and explicit:

sql
CREATE TABLE IF NOT EXISTS campaign_recipients (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT NOT NULL UNIQUE COLLATE NOCASE,
  source TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending',
  country TEXT,
  state TEXT,
  timezone TEXT,
  unsubscribe_token TEXT NOT NULL UNIQUE,
  sent_at TEXT,
  skipped_at TEXT,
  skipped_reason TEXT,
  resend_email_id TEXT,
  last_error TEXT,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_campaign_recipients_status
  ON campaign_recipients (status, id);

CREATE TABLE IF NOT EXISTS campaign_unsubscribes (
  email TEXT PRIMARY KEY COLLATE NOCASE,
  unsubscribed_at TEXT NOT NULL DEFAULT (datetime('now')),
  source TEXT NOT NULL
);

status is usually pending, sent, skipped, or failed. Store resend_email_id when a send succeeds so you can debug bounces or support requests later.


Step 2: Get customers from Polar

You have two practical options.

Inside Cursor: use the Polar MCP to list customers or orders, then export emails you want in the campaign. Good for one-off imports and sanity checks.

In production: call the Polar API from a script or admin endpoint. Match on email, dedupe, and optionally pull billing_address.country and billing_address.state for timezone-aware sending later.

Before inserting anyone, filter out:

  • invalid emails
  • people who already bought the product you are promoting
  • people already on your unsubscribe list
  • duplicates

Insert with INSERT OR IGNORE so re-imports do not create duplicate rows.


Step 3: Store recipients safely

An admin import endpoint is enough to start. Accept a JSON body with emails (or objects with email, country, state, timezone), validate, dedupe, and write to D1:

ts
await env.DB.prepare(
  `
    INSERT OR IGNORE INTO campaign_recipients (
      email, source, unsubscribe_token, country, state, timezone
    ) VALUES (?, ?, ?, ?, ?, ?)
  `
)
  .bind(email, "polar-import", crypto.randomUUID(), country, state, timezone)
  .run();

Protect this route with a secret ADMIN_TOKEN. Never expose bulk import publicly.


Step 4: Render and send with Resend

Keep templates in the Worker (React Email works well) or inline simple HTML. Every send should include:

  • a clear From on your verified domain
  • HTML and plain text
  • an unsubscribe link with a unique token per recipient
  • a Resend idempotency key so retries do not double-send
ts
await fetch("https://api.resend.com/emails", {
  method: "POST",
  headers: {
    authorization: `Bearer ${env.RESEND_API_KEY}`,
    "content-type": "application/json",
    "idempotency-key": `campaign-${recipient.id}`,
  },
  body: JSON.stringify({
    from: env.EMAIL_FROM,
    to: [recipient.email],
    subject: template.subject,
    html: template.html,
    text: template.text,
  }),
});

After a successful response, mark the row sent and store the Resend message id. On failure, store last_error and optionally mark failed.


Step 5: Send slowly with a cron

This is the part most people skip — and the part that saves your domain.

Do not send your whole list on day one. Warm up:

  1. 25 emails/day for a few days if the list is cold or the domain is new to bulk-ish mail
  2. 50/day once bounces, complaints, and unsubscribes look normal
  3. Only go higher when Resend analytics and your gut agree

In wrangler.toml, an hourly cron is enough:

toml
[triggers]
crons = ["30 * * * *"]

Each run: select pending recipients up to your daily cap, send, stop. Track sent today in D1 or with a small daily report row so you do not exceed the limit across multiple cron ticks.

Optional but useful: only send during a friendly local window (for example 16:30 US Eastern for a US-heavy list). Store timezone on each row when you know it from Polar billing address; default unknown rows to a single zone rather than blocking the whole campaign.


Step 6: Unsubscribe and skip logic

Non-negotiable:

  • Unsubscribe: GET /unsubscribe?token=... looks up the token, writes to campaign_unsubscribes, shows a confirmation page
  • Skip before send: if email is unsubscribed or already bought the product, mark skipped with a reason — do not send
  • Verified bots: if you use Cloudflare WAF host rules, add and not cf.client.bot so Googlebot is not blocked on your Worker or marketing hostnames

Skip logic belongs in the Worker, not in Resend segments. Your queue owns compliance.


Step 7: Watch and adjust

Send yourself a daily summary email: sent, skipped, failed, pending remaining. Glance at Resend for bounces and spam complaints. If clicks are flat and complaints are zero, you can cautiously raise the daily cap.

This setup is not a replacement for ConvertKit or Mailchimp at massive scale. It is a good fit when you already use Polar, want full control, and have a few hundred to a few thousand people to reach with one focused offer.


Quick checklist

  • D1 tables for recipients and unsubscribes
  • Polar import (MCP for exploration, API for automation)
  • Resend domain verified, API key in Worker secrets
  • Unsubscribe route live on the Worker URL
  • Daily send cap (start at 25, not 2,000)
  • Idempotency keys on every Resend call
  • Summary email to yourself after each batch
  • WAF rule allows verified bots if you block unknown hostnames

Start small, measure, then scale. Your future self — and your domain reputation — will thank you.

/Michael Andreuzza