How I sent 100,000 emails for 8% of the quote
A hosted ESP wanted more for one campaign than the campaign was worth. The delivery itself costs almost nothing, so here's what you're actually paying for, and what it takes to build the rest yourself.
- Engineering
- Open Source
I had a list of about 100,000 addresses and one campaign to send. I opened Mailchimp, then Brevo, priced it, and closed both tabs.
The thing that bothered me wasn't the number. It was knowing what the underlying send actually costs. Amazon SES bills per thousand messages at a rate that puts a 100,000-message campaign well under the price of lunch. Everything above that is the interface: list management, templates, scheduling, dashboards.
I had the list. I could write the interface. That decision became Emailblink, and the first version cut the bill by 92%.
The naive version works, and then it doesn't
The first script was about forty lines. Read a CSV, loop over the rows, send.
import smtplib, ssl
from email.message import EmailMessage
import pandas as pd
recipients = pd.read_csv("list.csv")
with smtplib.SMTP_SSL("email-smtp.ap-south-1.amazonaws.com", 465,
context=ssl.create_default_context()) as server:
server.login(SES_USER, SES_PASSWORD)
for row in recipients.itertuples():
msg = EmailMessage()
msg["From"] = "you@yourdomain.com"
msg["To"] = row.email
msg["Subject"] = "..."
msg.set_content(body_for(row))
server.send_message(msg)This is correct, it is 40 lines, and at 100,000 recipients it will fail in at least three ways.
What actually breaks
You will hit the rate limit. SES gives you a maximum send rate measured in messages per second, and a maximum daily quota. A tight for loop blows straight through the per-second rate and starts collecting throttling errors, which, if you aren't inspecting them, look exactly like successful sends followed by silence.
The fix is a token bucket, not a sleep:
import time
class RateLimiter:
"""Paces sends to a fixed rate without drifting or bursting."""
def __init__(self, per_second: float):
self.interval = 1.0 / per_second
self.next_slot = time.monotonic()
def wait(self) -> None:
now = time.monotonic()
if now < self.next_slot:
time.sleep(self.next_slot - now)
# Schedule from the slot, not from now, so we don't lose ground.
self.next_slot = max(self.next_slot + self.interval, now)You will not finish in one run. Something will die at message 61,000: a dropped connection, a rotated credential, a laptop lid. If your only state is "the loop got this far", you're choosing between not finishing and sending 61,000 people a duplicate.
Per-recipient state is the fix, and it's what turns a script into a tool:
# Each recipient is a row with a status, not a position in a loop.
# pending → sent | bounced | failed
# Resuming means: select where status = 'pending'.That one change makes the whole thing restartable, pausable, and safe to run twice.
One bad address will take down the run. Real lists contain malformed addresses, and an unhandled exception 8,000 messages in ends everything. Every send needs its own try/except that records the failure against that recipient and moves on.
The part that isn't code
Here's what I underestimated: getting mail sent is the easy half. Getting it delivered is a reputation problem.
- Authenticate the domain. SPF, DKIM and DMARC on your own sending domain. Without these you are, from a receiving server's point of view, indistinguishable from a spammer.
- Warm up. A brand-new sending identity that emits 100,000 messages on day one looks precisely like an abuse pattern. Volume ramps over days.
- Honour bounces and complaints immediately and permanently. This is the big one. SES tracks your bounce and complaint rates and will suspend a sending identity that lets them climb. A suppression list that persists across campaigns isn't a feature, it's the price of continuing to have an account.
- Get out of the sandbox first. New SES accounts are capped until you request production access, which is a manual review. Discovering this the morning of your send is a bad morning.
None of that is hard. All of it is invisible until it isn't, and a hosted ESP does every bit of it silently, which, to be fair, is a real part of what you're paying for.
Was it worth it?
For one campaign of 100,000: unambiguously. The saving was 92%, and I ended up with something reusable.
The reusable part is what mattered. Every time I ran it again, the script grew the piece it was missing, whether that was dedupe, personalisation, resumability or suppression, until it stopped being a script. That's the general shape of the best things I've built: they start as a bill I refused to pay, and the bill turns out to be a very well-specified problem statement with a guaranteed first user.
Building something where this is relevant? Write to me.