Auto-Unsubscribe & Newsletter Digest
Overview
A cron job that scans Ben’s maildir for newsletter/marketing emails, auto-unsubscribes, moves them out of Inbox, and sends a weekly digest so he can re-subscribe to anything he actually wants.
Decisions
- Backlog: Process full backlog (~2,400 newsletter emails). Unsubscribe from all non-allowlisted senders.
- File location:
Omni/Cloud/Mail/NewsletterFilter.pyin omni repo - Allowlist: See below
Environment
- Mail server: Self-hosted on bensima.com (143.198.118.179), NixOS mailserver module (nixos-mailserver). Dovecot + Postfix.
- Sieve: ManageSieve is enabled (
enableManageSieve = true). Can deploy server-side sieve rules for filtering at delivery time. - Mail config:
Omni/Cloud/Mail.nix(hyperspace worktree) - Mail sync: mbsync (isync) via
~/.config/isyncrc, IMAP to bensima.com:993 - Maildir:
/home/ben/Mail/ben@bensima.com/(Inbox, Junk, Sent, Trash, Archives, etc.) - Search: mu 1.12.9 (Xapian-backed)
- Clients: iOS Mail app + mbsync on beryllium
- Host: beryllium (local machine)
- Catch-all: Already enabled for bensima.com, simatime.com, bsima.me
- Stats: ~6,500 emails in Inbox (3,509 new + 3,011 cur). ~2,431 have List-Unsubscribe headers (~37% of inbox).
Architecture
Two-layer approach:
Layer 1: Server-side Sieve (delivery-time filtering)
Since ManageSieve is enabled, we can deploy sieve rules that filter at delivery time. This means newsletters get filed before they hit the Inbox — iOS Mail and mbsync both see the already-filtered state.
Sieve script capabilities:
- File emails with
List-Unsubscribe+Precedence: bulkintoNewsletters/folder - Skip known allowlisted senders
- This handles the ongoing filtering after initial cleanup
Deploy via: sievec on server, or ManageSieve protocol client from beryllium.
Layer 2: Client-side Python script (unsubscribe + digest)
Runs on beryllium via systemd user timer.
Phase 1: Classify
For each email in Inbox/new/ and Inbox/cur/:
- Parse headers with
emailstdlib - Classify as newsletter if ANY of:
- Has
List-Unsubscribeheader AND sender is not on allowlist - Has
Precedence: bulkorPrecedence: list - Has
X-Mailermatching known bulk senders (Substack, Mailchimp, SendGrid, Constant Contact, etc.) - From address matches known newsletter patterns (substack.com, email., no-reply@, newsletter@*)
- Has
- Classify as transactional (skip) if:
- From matches known transactional senders (banks, utilities, order confirmations)
- Subject matches patterns: “receipt”, “order confirmation”, “password reset”, “verification”
- Classify as personal (skip) if:
- From address is in contacts allowlist
- No bulk/list headers
- Reply-To matches From (not a marketing redirect)
Phase 2: Unsubscribe
For emails classified as newsletter:
- Extract
List-Unsubscribeheader - If
List-Unsubscribe-Post: List-Unsubscribe=One-Clickis present:- Send HTTP POST to the unsubscribe URL (RFC 8058 one-click)
- Else if URL is
mailto::- Send an email to the unsubscribe address (via msmtp/sendmail)
- Else if URL is
https://:- Send HTTP POST/GET to the URL
- Log: timestamp, from, subject, unsubscribe method, success/fail
Phase 3: Move
- Move unsubscribed emails from Inbox to
Newsletters/maildir folder (create if needed) - Preserve read/unread flags
- Run
mu indexafter moves - Next
mbsync -asyncs changes back to IMAP → visible in iOS Mail
Phase 4: Weekly Digest
Once per week (Sunday 8pm ET), compile and email a digest to ben@bensima.com:
Subject: Newsletter Filter Digest — Week of Mar 10
Unsubscribed this week: 23 emails from 14 senders
NEW senders unsubscribed:
- Oura <no-reply@m.ouraring.com> (1 email)
- ParkWhiz <hello@email.parkwhiz.com> (1 email)
- Glenmoor Country Club <membership@glenmoorcc.com> (1 email)
RECURRING senders unsubscribed:
- Marketing Co <promo@example.com> (4 emails)
...
To keep a sender, add to allowlist:
echo "sender@example.com" >> ~/.config/newsletter-filter/allowlist
Failed unsubscribes (manual action needed):
- Rumble <newsletter@rumble.com> — HTTP 403
Phase 5: Allowlist/Blocklist Management
Config dir: ~/.config/newsletter-filter/
Files:
allowlist— one email/domain per line. These newsletters stay in Inbox, never unsubscribed.blocklist— force-unsubscribe even without List-Unsubscribe header (just move to Junk)log.jsonl— append-only log of all actionsstate.json— tracks which senders have been unsubscribed (avoid re-processing)
Pre-seeded allowlist:
# Newsletters Ben likes
@substack.com
pragmaticengineer
semianalysis
ainews
latentspace
latent.space
johndavidebert
johndavideb
curtisyarvin
graymir
# Mailing lists
chicken-hackers@nongnu.org
lists.tailrecursion.com
buffoverflow
# Transactional (never unsubscribe)
questdiagnostics.com
rippling.com
goodleap.com
apple.com
NOTE on Substack: @substack.com is broadly allowlisted because Ben likes Substack content generally. The classifier should treat all substack.com senders as allowlisted. Individual substack newsletters can be blocklisted if unwanted.
Scheduling
- Sieve filtering: At delivery time (instant, server-side)
- Unsubscribe + classify: Every 6 hours via systemd user timer on beryllium
- Digest: Weekly (Sunday 8pm ET)
Implementation Details
Dependencies
- Python 3 (stdlib: email, json, pathlib, urllib, smtplib, mailbox)
requestsfor HTTP unsubscribe calls (or just urllib)- No external services, no API keys
Rate Limiting
- Max 10 unsubscribe requests per run (avoid being flagged as bot)
- 2-second delay between HTTP unsubscribe requests
- Exponential backoff on failures
Safety
- First run: DRY RUN mode. Classify and log but don’t unsubscribe or move anything. Send digest showing what would happen.
- Require explicit
--liveflag to actually unsubscribe - Never delete emails, only move between maildir folders
- Never unsubscribe from anything on the allowlist
- All actions logged to
log.jsonl
Backlog Processing
- Process full backlog of ~2,400 newsletter emails
- Rate limit still applies (10 per run), so full backlog will take ~40 runs (~10 days at 6h intervals)
- Could add a
--backlogflag that increases rate limit for initial cleanup (e.g., 50/run = ~2 days)
iOS Mail Compatibility
- Moving files in maildir + mbsync sync means changes appear in iOS Mail automatically
- Newsletters/ folder will appear as an IMAP folder in iOS Mail
- No special iOS configuration needed
mu Integration
- After moving files, run
mu indexto update the search database
mbsync Interaction
- Moving files locally in maildir and running
mbsync -awill sync changes back to IMAP server - Newsletter/ folder will be created on server too
File Structure
Omni/Cloud/Mail/NewsletterFilter.py # Main script
Omni/Cloud/Mail/newsletter-filter.sieve # Server-side sieve rules (optional)
The script should be a single-file Python script with no external dependencies beyond stdlib + requests.
CLI:
python NewsletterFilter.py --dry-run # Classify + report, no actions
python NewsletterFilter.py --live # Classify + unsubscribe + move
python NewsletterFilter.py --digest # Generate and send weekly digest
python NewsletterFilter.py --backlog # Higher rate limit for initial cleanup
Deployment Plan
- Write Python script with dry-run mode
- Deploy sieve rules for ongoing filtering
- Run dry-run, send Ben the report
- Review allowlist with Ben
- Run with
--liveflag - Set up systemd timers