- Aug 9, 2026
- 6 min read
A Drip Email Engine in Standard-Library Python
A “drip” is a short series of emails sent on a schedule after someone subscribes: a welcome on day 0, a pointer to good content on day 3, a question on day 7. Newsletter tools sell this as a premium feature. I wanted it on my own homelab, next to the SQLite copy of my leads, without adding another service or a dependency tree to keep patched.
The result is a few hundred lines of Python using only sqlite3, tomllib,
string.Template, smtplib, email and urllib. This post covers the
decisions that matter more than the code: what “never send twice” really
requires, who is allowed in a sequence, and how to keep the unsubscribe link
from ever being forgotten.
Sequences are files
A sequence is a TOML file plus one plain-text file per email:
id = "jt-welcome"
site = "jalcocertech.com"
origin = "https://www.jalcocertech.com"
from_name = "JAlcocerTech"
reply_to = "[email protected]"
audience = "subscribers"
enabled = false
enroll_since = 2026-10-05
[[steps]]
id = "welcome"
delay_days = 0
subject = "Welcome to JAlcocerTech"
body = "jt-welcome/01-welcome.txt"
Files in git mean every wording change is a reviewable diff, and the homelab
gets new sequences with git pull (they are mounted read-only into the
container, so no rebuild).
Loading validates more than syntax:
- step ids are unique and steps are in
delay_daysorder - a re-confirmation sequence must contain
$confirm_urlin every body - unknown
$placeholdersraise instead of rendering (string.Template.substitute, notsafe_substitute), so a typo like$nmefails a test rather than reaching an inbox
Everything starts with enabled = false, and a test asserts that every
sequence in the repository is disabled until someone flips it deliberately.
Who gets enrolled
Two audiences exist:
| Audience | Who |
|---|---|
subscribers | Confirmed (double opt-in) on that site, not suppressed, confirmed on or after enroll_since |
reconfirm | Single-opt-in subscribers from before double opt-in, who get one confirmation link |
enroll_since is the guard I’m most glad I added. Without it, enabling a
welcome series on a Tuesday would send “Welcome!” to everyone who confirmed
months ago. With it, only people confirmed from that date on are enrolled.
Pending sign-ups are never enrolled, and neither are legacy subscribers who
never confirmed. The source of truth for “confirmed” is the status the
forms Worker writes
(double opt-in with signed links).
Never send the same step twice
This is the core of the engine. One row per (sequence, step, person):
CREATE TABLE sends (
sequence TEXT NOT NULL, step TEXT NOT NULL, email TEXT NOT NULL,
status TEXT NOT NULL, -- sending | sent | failed
at TEXT NOT NULL, sender TEXT, provider_id TEXT, error TEXT,
attempts INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (sequence, step, email)
);
The order of operations is what makes it safe:
- Insert the row with
status = 'sending'and commit. - Call the sender.
- Update to
sent(with the provider’s message id) orfailed.
If the process dies between 1 and 3 (container restart, power cut), the row
stays sending. The engine never retries a sending row, because the
email may have gone out. A missed email is an annoyance; a duplicate is
spam. leadshub drip-status reports stuck rows so I can check the
provider’s log by hand.
failed is different: it means the provider refused the message (an HTTP
error, an SMTP rejection), so nothing was delivered. Those are retried up to
three attempts:
done = {
(r["email"], r["step"])
for r in conn.execute("SELECT email, step, status, attempts FROM sends WHERE sequence = ?", (seq.id,))
if r["status"] != "failed" or r["attempts"] >= MAX_ATTEMPTS
}
A test simulates the crash by raising SystemExit from the sender, then runs
the engine again with a working sender and asserts that nothing was sent.
One step per person per run, and a cap
Each run sends at most one due step per person, and at most
DRIP_MAX_PER_RUN emails in total (50 by default). If someone’s day-0 and
day-3 steps are both due (say the homelab was off for a week), they get
day 0 now and day 3 on a later run rather than two emails in one minute. The
cap protects sender reputation if something goes wrong at scale; the rest
waits for the next run.
Check again right before sending
Enrollment happens at the start of a run, but the suppression list can change underneath it: the same loop pulls new data from KV every 15 minutes, and an unsubscribe may have just arrived. So immediately before each send:
if is_suppressed(conn, email) or email not in set(audience(conn, seq)):
continue
And when someone leaves the audience, their enrollment is stopped with a
reason (suppressed or left audience) so the sequence doesn’t resume if
they come back through some other path. The suppression list itself is
described in
a do-not-send list that stores no email addresses.
The footer is not optional
Every rendered email gets this appended by the renderer, not by the template:
--
You get this because you subscribed at $origin.
Unsubscribe (one click): $unsubscribe_url
The same URL goes into List-Unsubscribe and
List-Unsubscribe-Post: List-Unsubscribe=One-Click headers (RFC 8058). The
HTML part is generated from the text: paragraphs, escaped, with URLs turned
into links. Templates can’t forget the unsubscribe link because they never
contain it.
Links signed exactly like the Worker’s
The unsubscribe URL points at the forms Worker on Cloudflare, which verifies an HMAC-signed token. The homelab signs links in Python; the Worker verifies them in TypeScript. If the two disagree by a single byte, every unsubscribe link in every drip email is broken, silently.
The format is base64url(JSON payload) + "." + base64url(HMAC-SHA256). The
Python side matches JSON.stringify by using compact separators and
insertion order:
body = _b64url(json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode())
sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).digest()
Rather than trusting that reasoning, I generated two tokens with the Worker’s
own signToken() and pinned them as test vectors in the Python tests. If
either side changes, the test fails. As a final check, a preview email’s
unsubscribe link opened the live Worker’s “Unsubscribe me” page.
A sender adapter, chosen by configuration
class Sender(Protocol):
name: str
def send(self, msg: Message) -> str: ...
Two implementations:
SmtpSender:smtplibto any relay. In development that’s Mailpit from the samecompose.yml, which captures mail and delivers nothing.MailtrapSender: Mailtrap’s bulk API over HTTPS, headers passed as JSON.
SENDER=smtp|mailtrap picks one. Nothing is sent at all unless
DRIPS_ENABLED=1 and the sequence says enabled = true; otherwise
leadshub drip is a dry run that prints who would get what. The SMTP path
had one nasty surprise with long headers, which got its own post:
the Python email header bug that silently breaks one-click unsubscribe.
Takeaways
- Write the ledger row before sending.
sendingis never retried; provider refusals are. - Gate enrollment on a date so enabling a sequence can’t email your whole history.
- Re-check suppression immediately before each send.
- Append the unsubscribe footer in code, and fail on unknown placeholders.
- When two languages must agree on a signature, pin vectors from one in the other’s tests.
- Standard library only means nothing to update on an always-on box.
Next: the Python email header bug that silently breaks one-click unsubscribe.