• Aug 8, 2026
  • 6 min read

A Do-Not-Send List That Stores No Email Addresses

Once people can unsubscribe, you need somewhere to remember that they did. The obvious answer is a table of email addresses marked “do not contact”. That works, but it means the one list whose whole purpose is leaving people alone is also a list of their addresses.

For my own lead pipeline I wanted the opposite property: a do-not-send list that every sender can check, that survives a person asking to be forgotten, and that contains no email addresses at all.

The trick is simple: store a hash of the address, not the address.

The key format

Leads already live in Cloudflare Workers KV (how the forms reach KV). Suppressions live in the same namespace under their own prefix:

<site>:<kind>:<iso-timestamp>:<uuid>    a lead
suppress:<sha256(normalised email)>     a suppressed address

The value is deliberately boring:

{ "at": "2026-10-04T21:16:53.769Z", "site": "fossengineer.com", "source": "link" }

When, which site the unsubscribe came from, and how (link for the page with a button, one-click for the RFC 8058 POST that Gmail and Yahoo send). No name, no address, no IP.

Normalisation is the whole game

A hash only works if every writer and every reader computes it from exactly the same string. [email protected] and [email protected] must collide.

The Worker (TypeScript) does:

export async function emailHash(email: string): Promise<string> {
  const digest = await crypto.subtle.digest("SHA-256", encoder.encode(email.trim().toLowerCase()));
  return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
}

The homelab side (Python) does the same thing:

def email_hash(email: str) -> str:
    return hashlib.sha256(email.strip().lower().encode()).hexdigest()

Trim, lowercase, SHA-256, hex. I keep both functions next to a comment saying “same as the other one”, and a test asserts that " [email protected] " and "[email protected]" produce the same digest. If the two ever drift, unsubscribed people start receiving email again, which is the worst failure this system can have.

Lowercasing the local part is technically not RFC-correct (the part before @ may be case-sensitive), but no mainstream provider treats it that way, and for a suppression list a false match is far cheaper than a miss.

Who writes, who removes

EventEffect on suppress:<hash>
Unsubscribe page, button pressedWritten (source: "link")
One-click POST from the mail clientWritten (source: "one-click")
Bounce / complaint webhook (planned)Written
Person subscribes again and confirmsDeleted
Person asks to be forgottenKept

Two rows deserve explanation.

A fresh, confirmed subscription lifts the suppression. If someone unsubscribed last year and today fills in the form and clicks the confirmation link, that is new, explicit consent. The Worker deletes the suppress key at the moment of confirmation, not at the moment of submission: anyone can type someone else’s address into a form, but only the mailbox owner can click the link. (The confirm flow itself is in double opt-in with signed links.)

Erasure keeps the hash. When someone exercises the right to be forgotten, my forget command deletes their leads from KV and from the local SQLite copy, but leaves suppress:<hash> in place. The hash is what stops me from emailing them again if their address shows up in some future import. It holds no address, and the privacy pages are being updated to say this is what happens.

Checked before every send, by every sender

A suppression list is only as good as its weakest sender. In my setup there are three paths that can put an email in someone’s inbox:

  1. The Worker’s double opt-in confirmation email.
  2. The drip engine on the homelab.
  3. Manual test commands (drip-preview, mail-test).

Path 1 is special: it only goes to someone who just submitted the form, so that is a request, not marketing. Paths 2 and 3 both call the same check first:

def is_suppressed(conn, email: str) -> bool:
    return conn.execute(
        "SELECT 1 FROM suppressions WHERE hash = ?", (email_hash(email),)
    ).fetchone() is not None

The drip engine checks it twice: once when deciding who is in the audience, and again immediately before handing a message to the sender, because a pull from KV may have just brought in a new unsubscribe. The preview commands refuse outright (“address is suppressed: not sending”) rather than having a --force flag. There is no code path that sends to a suppressed hash.

Mirroring it to the homelab

The homelab keeps a SQLite mirror of KV (the pull-only mirror). For leads, “missing from KV” can mean expired or deleted, so the mirror waits before purging. For suppressions the rule is stricter and simpler: mirror KV exactly.

local = {r["hash"] for r in conn.execute("SELECT hash FROM suppressions")}
for digest in local - suppress_keys.keys():
    conn.execute("DELETE FROM suppressions WHERE hash = ?", (digest,))
for digest in suppress_keys.keys() - local:
    ...insert...

If a suppress key disappears from KV, the only thing that removes one is a confirmed re-subscription, so the local copy must drop it too. Otherwise the homelab would keep suppressing someone who explicitly asked to come back.

The gotcha: everything that lists “leads”

Putting suppressions in the same KV namespace as leads is convenient: one binding, one read token, one listing call. The cost showed up immediately: every tool that listed the namespace now thought suppress:... keys were leads.

My make leads script parses keys as <site>:<kind>:<timestamp>:<uuid>. A suppress key has no site and no kind, and its “email” metadata is empty. The fix was to filter by prefix in every reader and to count suppressions instead of listing them:

10 lead(s), 1 suppressed address(es).

If you adopt this pattern, grep for every place that lists the namespace before deploying the first unsubscribe. A separate namespace avoids the problem at the cost of a second binding and token scope.

What a hash does and doesn’t protect

To be honest about the threat model:

  • A SHA-256 of an email address is not anonymous. Anyone holding a candidate address can hash it and check membership. Email addresses have far too little entropy to resist a dictionary.
  • What it does give you is no list of addresses at rest. A leaked suppression table doesn’t hand anyone a mailing list, and the table can be kept after erasure without keeping the personal data itself.

For a do-not-contact list that is the right trade. A keyed hash (HMAC with a secret) would resist dictionary checks, but then every sender needs the secret, and losing it makes the whole list unreadable.

Takeaways

  • Store sha256(trim(lower(email))), not the address. Use the same normalisation everywhere, and test it.
  • Write on unsubscribe, lift only on a confirmed re-subscription, keep it through erasure.
  • Every sender path checks it, immediately before sending, with no override.
  • Mirrors should copy suppressions exactly, including deletions.
  • If suppressions share a namespace with your data, every reader must skip the prefix.

Next: a drip email engine in standard-library Python, the main consumer of this list.