• Aug 25, 2026
  • 5 min read

A Spam Blocklist That Stores Instead of Drops — and Why I Deferred Turnstile

Within a week of my forms going live, fossengineer received three “enquiries” from the same company, blastleadgeneration.com:

Hello Fossengineer Com Owner, My name is Luann and I’m betting you’d like your website Fossengineer Com to generate more leads…

The next one was signed Katrin, the one after that Gay. Same template, word for word. All three came from the US, and none carried the page field that my browser forms add to every submission. A script was posting straight to the forms endpoint.

Each one cost me a high-priority notification on my phone. That’s the real cost here. Volume was nothing, but real enquiries arrive through the same alert, and three fake ones in three days is how you learn to ignore it.

Why the honeypot didn’t catch them

My forms have a hidden company_website field. Real visitors never see it; naive bots fill every field and get silently discarded (how the forms Worker handles it).

This script didn’t fill unknown fields. It posted exactly the fields it needed (kind, email, details) and nothing else. A honeypot only catches clients that touch it.

The obvious answer, and why I didn’t take it

The usual next step is a challenge: Cloudflare Turnstile, hCaptcha, reCAPTCHA. I wrote down why not, as an architecture decision, because it’s the kind of choice I’ll be tempted to revisit:

CostWhy it matters on my sites
A third-party script (challenges.cloudflare.com) on every page with a formEvery article has a mid-article subscribe box, so that’s every article. Today the only third-party script is analytics
Browser signal collection; some visitors get an interactive checkboxFriction, plus a privacy-page change, for a problem I don’t have
Forms stop working without JavaScriptThe token can only be obtained with JS. Today every form works with JS off; the Worker would have to reject token-less posts
Not needed for this spamOne sender, one domain. A one-line blocklist entry stops it with zero impact on visitors

A challenge is worth its cost against many rotating senders. It’s overkill for one company with a script.

The blocklist

The forms Worker reads a comma-separated list from its config, kept in git:

# wrangler.toml
# Sender blocklist (adr/0004): domains (incl. subdomains) and exact addresses,
# comma-separated. Matches are kept 30 days as spam:* — no alert, no email.
SPAM_BLOCKLIST = "blastleadgeneration.com"

Matching is deliberately small:

export function blocklistMatch(email: string, blocklist: string | undefined): string | undefined {
  const address = email.trim().toLowerCase();
  const domain = address.split("@")[1] ?? "";
  for (const raw of (blocklist ?? "").split(",")) {
    const entry = raw.trim().toLowerCase();
    if (!entry) continue;
    if (entry.includes("@") ? address === entry : domain === entry || domain.endsWith(`.${entry}`)) return entry;
  }
  return undefined;
}

An entry with @ is an exact address. Anything else is a domain, and it also matches subdomains, but only on a whole label. mail.blastleadgeneration.com matches; notblastleadgeneration.com doesn’t. That last case is in the tests, because a plain endsWith("blastleadgeneration.com") would get it wrong.

Stored, not dropped

What happens to a match was the real decision. I store it:

const blocked = blocklistMatch(lead.email, env.SPAM_BLOCKLIST);
if (blocked) {
  await env.LEADS.put(`spam:${leadKey(lead, crypto.randomUUID())}`, JSON.stringify({ ...lead, spam: { matched: blocked } }), {
    expirationTtl: SPAM_TTL_SECONDS, // 30 days
    metadata: { site: lead.site, kind: lead.kind, domain: lead.email.split("@")[1] ?? "", matched: blocked },
  });
  return isForm ? redirectBack(lead, request, "sent") : json({ ok: true }, 202, cors);
}

For a blocklisted sender:

  • No ntfy alert, no confirmation email, never a lead. The check runs before double opt-in, so a blocklisted address subscribing to the newsletter never gets mail from me either.
  • The same success answer as everyone else. A 202, or the normal no-JS redirect. The filter isn’t advertised, so there’s nothing to probe.
  • Kept for 30 days under its own prefix, spam:<site>:<kind>:<iso>:<uuid>. If I ever block a domain too broadly, the “lost” submissions are still there to find.

Dropping would have been simpler. But a false positive that vanishes without trace is the worst kind of bug: nobody complains, because nobody knows.

Update the readers before the writer

A new key prefix in a shared KV namespace is a schema change. Two programs read that namespace: my laptop’s make leads and the homelab mirror (which pulls KV into SQLite).

The mirror would have read spam:fossengineer.com:enquiry:… as an old-style key, taken spam as the kind, and stored the pitch as a lead. So both readers learned to skip spam:* and count it, and only then did the Worker start writing it. The mirror records the count on every pull, and the weekly digest reports it:

Spam filtered (last 30 days): 0

That line is how I’ll know the filter is doing something, without an alert for each blocked pitch.

When I’d revisit

The decision record names a trigger: an automated flood (dozens of submissions a day, from many sources) that the blocklist can’t keep up with. Even then the order is:

  1. Per-IP rate limiting in the Worker. It’s invisible to visitors, and the abuse protection on my web-audit tool already uses the same idea.
  2. Turnstile only after that, and only on the forms that are actually attacked.

Takeaways

  • Look at the spam before choosing the defence. One scripted sender is a blocklist problem, not a CAPTCHA problem.
  • Store filtered submissions for a while instead of dropping them. False positives are then recoverable and countable.
  • Answer blocked senders like everyone else. Don’t tell a script which inputs get it filtered.
  • A new key prefix is a schema change. Update every reader before the writer starts using it.
  • Write down why you deferred something, with a trigger to revisit. It stops the same debate from starting every time spam appears.

Next: writing the privacy page found a retention bug.