Solving an issue with sending emails on my server

By

My newsletter took 8 hours to send and the open rate dropped from 24% to 16%. Codex found a Sendy send rate stuck at 20/sec and AWS SNS webhooks blocked by the Cloudflare proxy. Here is the full debugging session.

~~~

I already shared how AI helped me fix a DevOps issue I caused while messing with my DigitalOcean VPS, and how I secured the rebuilt server with AI.

A few days after that incident I sent the first real newsletter from the rebuilt server. It took a LONG time to send. Eight hours.

I use AWS SES to send emails. Sometimes this happens when AWS is hammered, like during Black Friday, or on those days when everyone sends at once. I didn’t think too much about it.

The next morning my girlfriend sent her newsletter from the same server. Same problem. Her list is much smaller, and it still took ages.

Now I knew I had messed something up.

The slow sending

I fired up Codex on my Mac and gave it everything I had: how to reach the server, what happened, and the numbers I could see in the Sendy dashboard.

on my server you can access through ssh root@<IP> -i ~/.ssh/digitalocean I have installed Sendy to send newsletters using AWS SES. Problem is, last newsletter was sent 20 emails at a time and i have 150k subs so it took HOURS. I don’t know how to figure out why. On Sendy I can see: SES region: N. Virginia, Daily quota: 653,500, Sends left: 499,447, Sent today: 154,053, Send rate: 160 per sec

Sendy is a self-hosted PHP app that sends newsletters through SES. It runs on a normal LAMP server, which is why I can run 150k subscribers for the cost of a small VPS plus SES usage.

It took a while, but Codex found it:

Found it.

Sendy was showing the AWS SES account quota correctly: 160/sec.
But Sendy's own adjusted sending-rate setting in the database was
still set to 20.

That value is what the sender actually uses:

login.send_rate = 20

Sendy batches SES queue rows using that setting, so your 150k
campaign was effectively throttled to 20 emails/sec. That makes
the theoretical minimum about 2 hours, before overhead.

I updated the Sendy setting for the main account and tied subusers to:

login.send_rate = 160

Current status:

[email protected]       send_rate 160
[email protected]  send_rate 160
<my girlfriend's account>    send_rate 160

There's no active campaign queue right now, so nothing was
interrupted. Next 150k-send should be closer to ~16 minutes
in ideal SES terms, with real time depending on server/DB/template
overhead.

So: AWS allows me 160 emails per second, because years ago I asked them to raise my limit. Sendy knew that, and displayed it. But Sendy also has its own send rate setting, stored in the database, and when I rebuilt the server it went back to the default of 20.

Two settings that look like the same thing. One of them was silently winning.

The low open rate

Fixed. But I had noticed a second problem.

The last email I sent had a very low open rate compared to usual. And oddly, nobody marked it as spam, and there were no bounces. Bounces always happen: fake addresses, full inboxes, dead domains. Zero is suspicious.

So I asked:

only 16% opened the last email I sent through [email protected] compared to the usual ~24%. is there any setting I am missing?

This took a while, with some back and forth.

Bounces and complaints come back from SES through AWS SNS, which POSTs to two webhook URLs on the Sendy server. Codex used the AWS API key stored in Sendy to look at the SNS configuration directly. It checked my DNS too.

The brand is sending through SES, not SMTP, and
opens_tracking / links_tracking are enabled for the brand.
Two suspicious Sendy-side things showed up for the last
large campaign: links_tracking was 0, and bounce/complaint
setup is 0. Link tracking doesn't drive open tracking, but
bounce/complaint handling is important because it controls whether bad
recipients are cleaned out after SES feedback.
Live DNS has SPF and DMARC records for mail.flaviocopes.com,
which is good. I don't yet see DKIM from simple selector guesses,
so I'm checking SES identity data and Sendy's SES helper instead of
guessing selector names.
SES itself says DKIM is enabled and verified for mail.flaviocopes.com,
and the DKIM CNAMEs are present. That means the core SPF/DKIM/DMARC
authentication is not the obvious missing piece. The
remaining Sendy warning is bounce/complaint feedback
not marked as received.

It then ran Sendy’s own built-in test, which sends to the AWS mailbox simulator addresses and waits for the bounce and complaint notifications to come back:

The built-in Sendy SNS test did not pass: after sending the
AWS simulator emails, Sendy still has bounce_setup=0 and
complaint_setup=0. I'm giving the callbacks a little more time and checking
whether AWS reached the public webhook URLs.

And the intermediate report:

I don't see a missing core authentication setting for
[email protected].

What checks out:

- SES identity mail.flaviocopes.com is verified.
- DKIM is enabled and verified in SES.
- DKIM DNS CNAMEs are present.
- SPF exists on mail.flaviocopes.com.
- DMARC exists on mail.flaviocopes.com.
- Sendy open tracking is enabled.
- One-click unsubscribe headers are being added by Sendy.

The last large campaigns compare like this:

Apr 04  Today's AI coding agents...        25.42%
Apr 14  The things I do with AI            24.82%
Apr 21  Some questions about AI             24.98%
May 04  The AI Workshop May cohort is open  16.52%

So this looks more like inbox placement/content/list-engagement
for that specific campaign than a broken open tracker.

The one real Sendy/AWS issue I found: bounce/complaint feedback
is not passing Sendy's own test. SES has SNS topics configured,
but Sendy still shows:

bounce_setup = 0
complaint_setup = 0

That won't directly lower opens for a single email, but it hurts
list hygiene over time because hard bounces and spam complaints
may not be removed reliably.

At this point the agent’s theory was “the email itself did worse”. Reasonable, but I didn’t buy it. The zero bounces still bothered me. So it kept digging into why SNS wasn’t reaching Sendy.

Narrowing it down

This is the part I found most impressive. It tested each link in the chain separately:

The API view now matches what you see: both SNS subscriptions are HTTPS,
confirmed, and pointed at the right Sendy endpoints.
The Sendy webhook code itself works. I posted correctly shaped SNS
notifications through the public HTTPS URLs; Sendy flipped both flags
to 1, and I restored them to the original 0 afterward so we
don't mask the real AWS path. Now I'm testing SNS publish delivery
specifically.
SNS accepted the test publishes, but after 35 seconds Sendy still
had both flags at 0. That means the remaining failure is between
SNS and the HTTPS endpoint delivery, not Sendy's PHP handler. I'm
checking Apache logs and SNS delivery settings next.
The only POSTs in Apache for those endpoints are from my direct
webhook test, not from the SNS publish test. So the pattern is now
pretty clear: the URL and PHP work, the SNS subscription is
confirmed, but SNS delivery is not making it to Apache. That
strongly points to the Cloudflare layer or SNS HTTPS delivery
failure before origin.

Look at what happened there. It faked an SNS notification and sent it to the webhook itself: Sendy handled it fine. It asked SNS to publish a real one: nothing arrived. Same URL, same handler, different sender. So the problem had to be between AWS and my server.

And it said “Cloudflare”.

That word made me remember something. When I rebuilt the server the week before, I had flipped the DNS record for list.flaviocopes.com to “proxied” in Cloudflare. For no reason at all. It just looked like a good default.

look, I remember I changed the Cloudflare DNS to proxy this domain last week

Yes, that is very likely the issue.

Cloudflare proxying list.flaviocopes.com can affect two things we just saw:

1. SNS callbacks are not reaching Apache.
2. Open tracking can be undercounted because email image proxy requests
   to /t/... may be blocked, challenged, rate-limited, or otherwise
   filtered before Sendy sees them.

That lines up with the timing: you changed Cloudflare last week, then
the next big campaign showed 16% instead of the normal ~24%.

The simplest fix: set list.flaviocopes.com back to DNS only in Cloudflare.

If you want to keep it proxied, add Cloudflare rules to fully bypass
security/cache for at least:

https://list.flaviocopes.com/t/*
https://list.flaviocopes.com/l/*
https://list.flaviocopes.com/unsubscribe/*
https://list.flaviocopes.com/includes/campaigns/bounces.php
https://list.flaviocopes.com/includes/campaigns/complaints.php

But for Sendy, I'd use DNS-only. It avoids Cloudflare interfering with
tracking pixels, link tracking, unsubscribes, and AWS SNS webhooks.

Set list.flaviocopes.com to DNS only in Cloudflare, then re-run Sendy's
"test bounce/complaint setup". If the flags flip to 1, that confirms it.

Both problems, one cause. The open tracking pixel is an image loaded from list.flaviocopes.com/t/.... Gmail and Apple Mail fetch it through their own proxies. Cloudflare was sitting in front of it, challenging or filtering some of those requests. Fewer pixel loads recorded means a lower open rate, even if the same people opened the email.

The fix

I switched the record to DNS-only and told the agent.

DNS is now bypassing Cloudflare from multiple resolvers and
resolves directly to the server. I'm rerunning the same SNS
delivery test now.
That confirmed it: after switching to DNS-only, SNS delivery
reached Sendy and both setup flags changed to 1.
Confirmed. Cloudflare proxying was the issue.

I reran the SNS delivery test. AWS SNS reached Apache directly:

Amazon Simple Notification Service Agent
POST /includes/campaigns/bounces.php     200
POST /includes/campaigns/complaints.php  200

Sendy now shows:

bounce_setup = 1
complaint_setup = 1

So bounces and complaints are fixed now.

This also strongly supports the open-rate theory: while Cloudflare was
proxied, some tracking pixel requests likely never reached Sendy, so
the 16% open rate was probably undercounted and/or affected by
Cloudflare filtering. Keep the Sendy tracking domain DNS-only.

What I learned

Every one of these issues traces back to that one server resize gone wrong. Rebuilding a server from scratch means every default comes back, and every “harmless” tweak you make along the way is a new variable.

Two lessons for anyone running Sendy, or any email tool with webhooks:

  1. The tracking domain should be DNS-only in Cloudflare. Tracking pixels, click redirects, unsubscribe links and SNS webhooks all need to reach your server untouched. A proxy in front of them will quietly break things.
  2. Check both send rates. The SES quota and Sendy’s own send rate are two different settings. After any reinstall, look at both.

I need to be more careful. But I can also say the agent was incredibly useful here. It didn’t guess. It tested each piece of the chain until only one explanation was left, and then it waited for me to confirm before declaring victory.

Tagged: AI · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about ai: