# A bot added around 5,000 addresses to my newsletter

> How out-of-office replies exposed a bot attack on my newsletter, why double opt-in was not enough, and how I protected the signup flow.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-29 | Topics: [Cloudflare](https://flaviocopes.com/tags/cloudflare/) | Canonical: https://flaviocopes.com/newsletter-spam-attack/

Yesterday I started receiving a strange number of automatic email replies.

“I’m on vacation.”

“I’m out of the office.”

One reply like this is normal after sending a newsletter. Several replies arriving together were not normal, especially because I had not sent one.

The replies were being triggered by newsletter confirmation emails.

That was the clue.

I opened Sendy and found thousands of suspicious new subscribers. Looking at the signup spike, the bot appeared to have submitted around 5,000 addresses.

Most were unconfirmed. But 34 addresses from the same attack window had reached the confirmed state.

## The mistake I made

A while ago, I removed Google reCAPTCHA from my newsletter forms. It had caused problems, and I thought double opt-in would be enough.

It was not.

Double opt-in and bot protection solve two different problems.

With double opt-in, a submitted address is not added to the active newsletter until the confirmation link is opened. This worked: most of the fake submissions remained unconfirmed.

But Sendy still stored every pending address and sent every confirmation email. The attack could generate thousands of unwanted emails, bounces, automatic replies, and complaints. It could also damage my sending reputation.

And in this case, 34 records did become confirmed.

I could not determine how those 34 confirmations happened, so I treated all of them as part of the incident.

Double opt-in protected the quality of the active list. It did not protect the signup endpoint.

## The form was too easy to call

The newsletter forms posted directly to Sendy.

Anyone who found that endpoint could bypass my site and submit addresses with a script. Removing the CAPTCHA had left no useful barrier in front of it.

I changed the flow so the browser no longer talks to Sendy directly:

```text
browser
  → my Cloudflare Pages Function
  → Sendy API
```

The server endpoint accepts only the newsletter lists I explicitly allow. It talks to Sendy using an API key that never reaches the browser.

I also blocked direct public submissions to the main newsletter list on the Sendy server. This part matters. Adding protection to the new endpoint would be pointless if the old unprotected route still worked.

## Adding Turnstile

I replaced the removed bot check with [Cloudflare Turnstile](https://flaviocopes.com/cloudflare-turnstile/).

Turnstile adds a token to the form after checking the visitor. My Pages Function sends that token to Cloudflare and verifies it before doing anything with Sendy.

I check more than the `success` value. The token must also contain the expected action and hostname. A valid token created for another form or another site is rejected.

The server-side check is the important part. A Turnstile widget in the browser is not protection by itself because a bot can ignore the page and call the endpoint directly.

## Adding a rate limit

Turnstile is one layer, not the whole solution.

I added a limit of five signup attempts per IP address in a ten-minute window. The IP is hashed before the counter is stored in Cloudflare KV.

The rate limit runs before Turnstile and Sendy. When an IP address has made too many attempts, the request is rejected immediately with a `429 Too Many Requests` response.

A distributed bot can use many IP addresses, so rate limiting is not perfect either. Combined with Turnstile, server-side validation, and a closed Sendy endpoint, it makes the attack much harder and limits the damage if one layer fails.

The complete flow is now:

```text
browser
  → allowed-list check
  → rate limit
  → server-verified Turnstile
  → authenticated Sendy API
  → confirmation email
```

This is the same approach I describe in [how to harden a public form endpoint](https://flaviocopes.com/harden-public-form-endpoint/): reject bad requests before they reach the system that performs the expensive or irreversible action.

## Cleaning up the list

Preventing the next attack did not remove the records already in Sendy.

I added a bulk cleanup command to my Sendy CLI. It performs a dry run first, shows what it will remove, and requires an explicit confirmation before deleting anything.

I used it to remove the suspicious records from the attack. I also cleared older unconfirmed records that had accumulated over time.

I then removed the 34 confirmed records from the same attack window and verified that none remained.

The dry run was essential. A bulk delete command aimed at a mailing list is not something I want to run on trust.

## What I learned

I had treated double opt-in as a complete newsletter signup defense. It is not.

Double opt-in keeps unconfirmed addresses out of the active list. Bot protection controls who can submit the form in the first place. Rate limiting controls how often they can try. Protecting the provider endpoint stops them from going around both.

I now want all four:

- double opt-in
- server-verified bot protection
- rate limiting
- no direct public path to the email provider

And I want cleanup tools ready before I need them.

The out-of-office replies looked like noise at first. They turned out to be the alert that exposed the whole problem.
