• Aug 28, 2026
  • 4 min read

A Weekly ntfy Digest With Counts Only

My lead pipeline already pings my phone for every real enquiry. What it didn’t tell me was the shape of a week: how many people subscribed and actually confirmed, whether the welcome emails went out, whether the spam filter is catching anything, whether the homelab sync is healthy.

I didn’t want a dashboard to remember to open. I wanted one quiet notification on Monday morning. So the homelab job that mirrors my leads (a pull-only homelab mirror) now sends a weekly digest through the same self-hosted ntfy.

What it looks like

Leads weekly - 2026-W41
New leads: 9
  fossengineer.com enquiry: 3
  fossengineer.com newsletter: 1
  jalcocertech.com enquiry: 1
  jalcocertech.com newsletter: 1
  multibodysystemsdynamics.com enquiry: 3
Confirmed subscriptions: 3 (fossengineer.com 1, jalcocertech.com 2)
Unsubscribes: 0
Drips sent: 1 (jt-welcome 1)
Do-not-send list: 0 (+0)
Spam filtered (last 30 days): 0
Pulls: 530 (6 failed)

Every line is a count. That’s the rule: no addresses, names or messages, ever. A notification ends up on a lock screen, in a phone backup, maybe on a smartwatch — it’s the wrong place for personal data. A test enforces it:

title, body = digest.build(conn, NOW)
assert "@" not in body, "counts only"

Where the numbers come from

Everything is a query against the SQLite copy the hub already keeps:

LineSource
New leadsleads rows received in the last 7 days, grouped by site and kind
Confirmed / UnsubscribesconfirmedAt / unsubscribedAt inside each lead’s stored JSON
Drips sentthe drip engine’s sends table, status sent (and failed if any)
Do-not-send listsuppressions: total, and how many appeared this week
Spam filteredthe number of spam:* keys seen in KV at the last pull
Pullsthe pulls log: runs this week and how many failed

The spam line needed a small schema change. Blocklisted submissions live in KV under their own spam: prefix for 30 days and are never mirrored as leads (why they’re stored, not dropped), so the pull now just counts them and records the number on its log row. A new column on an existing database meant a migration — CREATE TABLE IF NOT EXISTS doesn’t add columns to a table that already exists:

columns = {row["name"] for row in conn.execute("PRAGMA table_info(pulls)")}
if "spam" not in columns:
    conn.execute("ALTER TABLE pulls ADD COLUMN spam INTEGER")

Once a week, exactly once

The hub already runs a loop every 15 minutes (pull, daily backup, drips). The digest hooks into the same loop instead of adding a cron job:

def due(conn, now, weekday=0, hour_utc=7):
    """True once per ISO week, from `weekday` (0 = Monday) at `hour_utc` on."""
    if (now.weekday(), now.hour) < (weekday, hour_utc):
        return False
    return conn.execute("SELECT 1 FROM digests WHERE week = ?", (week_id(now),)).fetchone() is None

week_id is the ISO week (2026-W41), and a tiny digests table keyed by it records what was sent. Consequences I like:

  • If the homelab is down on Monday morning, the digest goes out on the first loop after it’s back — still once.
  • Sending manually (leadshub digest --send) marks the week too, so I don’t get a second one on Monday.
  • Weekday, hour and an on/off switch are environment variables, defaulting to Monday 07:00 UTC.

The notification is sent with Priority: low and a chart tag. It’s a summary to read with coffee, not something that should vibrate.

Two bugs the first run found

The first digest --send from the homelab printed a perfectly good digest and then failed with 403. ntfy sits behind Cloudflare, and Cloudflare rejects Python’s default Python-urllib user agent with error 1010 before the request ever reaches ntfy. Which also meant the hub’s failure alerts had never been delivered — the full story is in Cloudflare error 1010 blocked my homelab alerts.

The same run claimed 24 new leads. KV held 9. The other 15 were test and spam leads I had just deleted: the mirror keeps deleted keys for 30 days, marked kv_gone_at, as a safety net. The digest counted them anyway. The fix was one condition on each query:

SELECT site, kind, COUNT(*) AS n FROM leads
WHERE received_at >= ? AND kv_gone_at IS NULL
GROUP BY site, kind

and a test that deletes a lead from the fake KV, pulls, and expects New leads: 1. After both fixes the digest matched the live inbox exactly.

Takeaways

  • A weekly summary beats a dashboard you have to remember to open.
  • Counts only — and a test that fails if an @ ever appears.
  • Hang it on a loop you already run, keyed by ISO week, so it’s sent exactly once even after downtime.
  • Count only what still exists; safety copies of deleted data shouldn’t show up in your numbers.
  • The first real send is a test of the whole alert path. Mine found two bugs.