• 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_days order
  • a re-confirmation sequence must contain $confirm_url in every body
  • unknown $placeholders raise instead of rendering (string.Template.substitute, not safe_substitute), so a typo like $nme fails 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:

AudienceWho
subscribersConfirmed (double opt-in) on that site, not suppressed, confirmed on or after enroll_since
reconfirmSingle-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:

  1. Insert the row with status = 'sending' and commit.
  2. Call the sender.
  3. Update to sent (with the provider’s message id) or failed.

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.

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.

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: smtplib to any relay. In development that’s Mailpit from the same compose.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. sending is 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.