One Daily Email for Your Whole Homelab (Python + msmtp)
A stdlib-only Python daily digest: your scripts drop JSON feed files, it renders Gmail/Outlook-safe HTML with mailto approve buttons, and msmtp sends it.
bash#!/usr/bin/env bash
# daily-digest installer — one morning email for the whole lab.
# Run as root on the ONE machine that already has a working mail path
# (set that up first with the msmtp + Gmail playbook on this site).
set -euo pipefail
# 1) A home for the feed files — every *.json in here becomes a card.
mkdir -p /var/lib/digest-feeds
# 2) Install the digest script itself.
cat > /usr/local/bin/daily-digest.py <<'PYEOF'
#!/usr/bin/env python3
"""daily-digest.py — one homelab, one morning email.
Reads small JSON "feed" files that your other scripts write, optionally asks
Prometheus for firing alerts, renders email-safe HTML that survives Gmail AND
Outlook, and sends ONE email via msmtp to everyone in RECIPIENTS.
daily-digest.py gather, render, send
daily-digest.py --dry-run gather, render to /tmp/digest-preview.html, no send
Python standard library only. Pairs with the msmtp + Gmail playbook, which
gives this machine its outbound mail path in the first place.
"""
import glob
import html
import json
import os
import subprocess
import sys
import urllib.parse
import urllib.request
from datetime import date
from email.message import EmailMessage
# ==== MAKE THESE VALUES YOUR OWN =============================================
RECIPIENTS = ["you@example.com", "family@example.com"] # who gets the digest
FROM_ADDR = "you@example.com" # must match the account in ~/.msmtprc
APPROVE_TO = "you@example.com" # where mailto: "Approve" replies land
# Every *.json in FEED_DIR becomes a card. The env override means you can
# test as a normal user: DIGEST_FEEDS=~/feeds ./daily-digest.py --dry-run
FEED_DIR = os.environ.get("DIGEST_FEEDS", "/var/lib/digest-feeds")
PROM_URL = "" # e.g. "http://10.0.0.5:9090" — blank = skip
SITE_NAME = "My Homelab"
PREVIEW = "/tmp/digest-preview.html"
# =============================================================================
# Email clients ignore CSS variables, so every color is a hardcoded hex,
# repeated inline on every element. Ugly on purpose — it renders everywhere.
FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"
TONES = { # border, background, text
"ok": ("#16a34a", "#e9f9ef", "#15803d"),
"info": ("#2563eb", "#eaf1ff", "#1d4ed8"),
"warn": ("#f97316", "#fff4ea", "#ea580c"),
"urgent": ("#e74c3c", "#fdecec", "#c0392b"),
}
DRY = "--dry-run" in sys.argv
def esc(s):
return html.escape(str(s), quote=True)
def mailto(subject, body):
"""One-tap approve link. RFC 6068 wants percent-encoding (%20 for spaces,
%0D%0A for line breaks) — urlencode's default form-style '+' is NOT part
of the mailto spec, so pass quote_via=quote."""
enc = urllib.parse.urlencode(
{"subject": subject, "body": body.replace("\n", "\r\n")},
quote_via=urllib.parse.quote)
return f"mailto:{APPROVE_TO}?{enc}"
# ---------------------------------------------------------------- gathering --
def read_feeds():
"""The whole trick. Each producer script writes ONE tiny JSON file:
{"title": "Backups", "status": "ok|info|warn|urgent",
"summary": "one sentence", "lines": ["detail", ...],
"action": {"label": "Approve — ...", "subject": "APPROVE: ...",
"body": "Approved — go ahead and ..."}}
Only "title" is required. Files render in name order, so a 10-/20-/30-
prefix is your layout engine. A feed that fails to parse becomes a warn
card instead of killing the digest."""
feeds = []
for path in sorted(glob.glob(FEED_DIR + "/*.json")):
name = path.rsplit("/", 1)[-1]
try:
d = json.loads(open(path, encoding="utf-8").read())
d.setdefault("title", name)
feeds.append(d)
except Exception as ex:
feeds.append({"title": name, "status": "warn",
"summary": f"This feed file could not be read ({ex}). "
"Its producer may be broken."})
return feeds
def prometheus_alerts():
"""Firing alerts from Prometheus's HTTP API (GET /api/v1/alerts).
Returns a list of alerts, or None when Prometheus itself is unreachable —
which is worth a card of its own, not a silent shrug."""
if not PROM_URL:
return []
try:
with urllib.request.urlopen(PROM_URL + "/api/v1/alerts", timeout=6) as r:
alerts = json.load(r).get("data", {}).get("alerts", [])
return [a for a in alerts if a.get("state") == "firing"]
except Exception:
return None
# ---------------------------------------------------------------- rendering --
# Layout rules that keep Gmail AND Outlook happy: real <table> elements with
# role="presentation", widths as attributes, styles inline, no float, no
# flexbox, no grid, no CSS variables, no background images.
def pill(text, tone):
border, bg, fg = TONES[tone]
return (f'<span style="display:inline-block;padding:3px 11px;border-radius:999px;'
f'font-family:{FONT};font-size:12px;font-weight:600;'
f'color:{fg};background:{bg};border:1px solid {border};">{esc(text)}</span>')
def button(action, tone):
border, _bg, _fg = TONES[tone]
href = mailto(action.get("subject", "APPROVE"), action.get("body", "Approved."))
return (f'<p style="margin:12px 0 0;"><a href="{esc(href)}" '
f'style="font-family:{FONT};font-size:13px;font-weight:700;'
f'color:#ffffff;background:{border};padding:9px 16px;border-radius:8px;'
f'text-decoration:none;">{esc(action.get("label", "Approve"))}</a>'
f'<span style="font-family:{FONT};font-size:12px;color:#6b7280;"> '
f' replies by email — nothing exposed to the internet</span></p>')
def card(title, tone, summary="", lines=None, action=None):
border, _bg, _fg = TONES[tone]
body = ""
if summary:
body += (f'<p style="margin:8px 0 0;font-family:{FONT};font-size:14px;'
f'line-height:1.5;color:#374151;">{esc(summary)}</p>')
for line in (lines or []):
body += (f'<p style="margin:4px 0 0;font-family:{FONT};font-size:13px;'
f'line-height:1.5;color:#6b7280;">• {esc(line)}</p>')
if action:
body += button(action, tone)
return (f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
f'style="border:1.5px solid {border};border-radius:12px;background:#ffffff;'
f'margin:0 0 12px;"><tr><td style="padding:14px 16px;">'
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>'
f'<td style="font-family:{FONT};font-size:15px;font-weight:700;'
f'color:#111827;">{esc(title)}</td>'
f'<td align="right" style="vertical-align:top;">{pill(tone.upper(), tone)}</td>'
f'</tr></table>{body}</td></tr></table>')
def feed_card(feed):
tone = feed.get("status", "info")
if tone not in TONES:
tone = "info"
return card(feed.get("title", "Untitled feed"), tone,
feed.get("summary", ""), feed.get("lines"), feed.get("action"))
def alert_card(a):
labels = a.get("labels", {})
ann = a.get("annotations", {})
name = labels.get("alertname", "unnamed alert")
tone = "urgent" if labels.get("severity") == "critical" else "warn"
what = ann.get("description") or ann.get("summary") or \
"A monitored condition crossed its alert threshold."
return card(f"Alert firing: {name}", tone, what,
[f"instance: {labels['instance']}"] if labels.get("instance") else None)
def render(cards_html, verdict, any_urgent):
today = date.today().strftime("%A %d %B")
banner = ""
if any_urgent:
banner = ('<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
'style="background:#fdecec;border:1.5px solid #e74c3c;border-radius:10px;'
'margin:0 0 14px;"><tr><td style="padding:10px 16px;font-family:' + FONT +
';font-size:13px;font-weight:700;color:#c0392b;">'
'Something below needs you today — it’s marked in red.</td></tr></table>')
return (f'<!doctype html><html><head><meta charset="utf-8"></head>'
f'<body style="margin:0;padding:0;background:#f3f4f6;">'
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
f'style="background:#f3f4f6;"><tr><td align="center" style="padding:26px 10px;">'
f'<table role="presentation" width="640" cellpadding="0" cellspacing="0" '
f'style="width:640px;max-width:100%;">'
f'<tr><td style="padding:0 2px 4px;font-family:{FONT};font-size:20px;'
f'font-weight:800;color:#111827;">{esc(SITE_NAME)} · {today}</td></tr>'
f'<tr><td style="padding:0 2px 16px;font-family:{FONT};font-size:14px;'
f'line-height:1.5;color:#374151;">{verdict}</td></tr>'
f'<tr><td>{banner}{cards_html}</td></tr>'
f'<tr><td style="padding:8px 2px 0;font-family:{FONT};font-size:12px;'
f'color:#9ca3af;">This is the one email this lab sends per day. '
f'Add a signal by dropping a JSON file in the feeds folder — '
f'the digest does the rest.</td></tr>'
f'</table></td></tr></table></body></html>')
def text_version(items, verdict_plain):
lines = [f"{SITE_NAME} — {date.today().strftime('%A %d %B')}", verdict_plain, ""]
for title, tone, summary in items:
lines.append(f"* [{tone.upper()}] {title}" + (f" — {summary}" if summary else ""))
lines.append("")
lines.append("(The HTML version has the approve buttons.)")
return "\n".join(lines)
# ------------------------------------------------------------------ sending --
def send(subject, text_body, html_body):
msg = EmailMessage()
msg["From"] = FROM_ADDR
msg["To"] = ", ".join(RECIPIENTS)
msg["Subject"] = subject
msg.set_content(text_body) # plain-text part first,
msg.add_alternative(html_body, subtype="html") # HTML as the alternative
try:
p = subprocess.run(["msmtp", "-t"], input=msg.as_bytes(),
capture_output=True)
except FileNotFoundError:
sys.exit("msmtp not found — set up the msmtp + Gmail playbook first.")
if p.returncode != 0:
sys.exit("msmtp failed: " + p.stderr.decode(errors="replace").strip())
def main():
feeds = read_feeds()
alerts = prometheus_alerts()
cards_html = ""
items = []
if alerts is None:
cards_html += card("Prometheus didn't answer", "warn",
f"The digest couldn't reach {PROM_URL} — the monitoring "
"stack itself may be down.")
items.append(("Prometheus didn't answer", "warn", "monitoring unreachable"))
for a in (alerts or []):
cards_html += alert_card(a)
items.append((f"Alert firing: {a.get('labels', {}).get('alertname', '?')}",
"urgent" if a.get("labels", {}).get("severity") == "critical"
else "warn", ""))
for f in feeds:
cards_html += feed_card(f)
tone = f.get("status", "info")
items.append((f.get("title", "?"), tone if tone in TONES else "info",
f.get("summary", "")))
if not items:
cards_html = card("Nothing reported", "info",
f"No feeds in {FEED_DIR} and no alert source configured. "
"The digest works — it just has nothing to say yet.")
items = [("Nothing reported", "info", "")]
n_attn = sum(1 for _, tone, _ in items if tone in ("warn", "urgent"))
any_urgent = any(tone == "urgent" for _, tone, _ in items)
if any_urgent:
verdict = ('<span style="color:#c0392b;font-weight:700;">Something needs '
'you today.</span> The red cards are the reason this email exists.')
verdict_plain = "Something needs you today — see the red items."
elif n_attn:
s = "s" if n_attn != 1 else ""
verdict = (f'Everything’s running normally. {n_attn} thing{s} would '
'like your attention when you have a minute — none are urgent.')
verdict_plain = (f"Everything's running normally. {n_attn} thing{s} would "
"like your attention — none urgent.")
else:
verdict = "All quiet. Nothing needs you today."
verdict_plain = "All quiet. Nothing needs you today."
subject = "%s %s%s" % (SITE_NAME, date.today().strftime("%a %d %b"),
" — NEEDS YOU" if any_urgent else "")
doc = render(cards_html, verdict, any_urgent)
text = text_version(items, verdict_plain)
if DRY:
open(PREVIEW, "w", encoding="utf-8").write(doc)
print(f"dry run: wrote {PREVIEW}")
print(f"subject: {subject}")
print(f"cards: {len(items)} attention: {n_attn} urgent: {any_urgent}")
return
send(subject, text, doc)
if __name__ == "__main__":
main()
PYEOF
chmod 0755 /usr/local/bin/daily-digest.py
# 3) Open it and make the values yours: RECIPIENTS, FROM_ADDR, APPROVE_TO,
# PROM_URL (leave blank to skip Prometheus), SITE_NAME.
${EDITOR:-nano} /usr/local/bin/daily-digest.py
# 4) Two sample feeds so the first render isn't empty. Delete them once
# your real producers exist.
cat > /var/lib/digest-feeds/10-backups.json <<'EOF'
{"title": "Backups", "status": "ok",
"summary": "Last night's backups all finished.",
"lines": ["3 of 3 guests backed up to the backup server",
"Newest snapshot finished at 01:12 this morning"]}
EOF
cat > /var/lib/digest-feeds/90-disk.json <<'EOF'
{"title": "Disk getting full", "status": "warn",
"summary": "Storage local-lvm is at 84% - time to plan a cleanup.",
"action": {"label": "Approve - clean it up",
"subject": "APPROVE: storage cleanup",
"body": "Approved - prune old ISOs and stale snapshots on local-lvm."}}
EOF
# 5) Preview without sending — writes /tmp/digest-preview.html. Open that
# in a browser and admire your one email.
/usr/local/bin/daily-digest.py --dry-run
# 6) Send yourself one for real, right now.
/usr/local/bin/daily-digest.py
# 7) Then make it a habit: one email, 08:30, every morning.
cat > /etc/cron.d/daily-digest <<'EOF'
30 8 * * * root /usr/local/bin/daily-digest.py >> /var/log/daily-digest.log 2>&1
EOF
What this does
This is the copy-paste companion to One Homelab, One Email: A Daily Digest You’ll Actually Read — the whole design story lives there. The short version: instead of a dozen services each emailing you whenever they feel like it, your scripts write tiny JSON “feed” files, and one Python script reads them every morning, renders an email that looks good in both Gmail and Outlook, and sends it with msmtp at 08:30. One email. That’s the lab’s whole allowance.
The digest script is Python standard library only — nothing to pip install, nothing to keep updated. Action items come with a one-tap Approve button that’s just a mailto: link: tapping it opens a pre-filled reply in your mail app. There’s no web endpoint behind it, so consolidating your alerts this way adds zero internet-facing surface to your lab — nothing for a link scanner to prefetch, nothing to exploit.
The installer also drops two sample feeds and immediately runs a --dry-run, so before anything is scheduled you can open /tmp/digest-preview.html in a browser and see exactly what will land in your inbox.
Prerequisites
- A working outbound mail path on this machine — that’s the msmtp + Gmail playbook, start there if
echo test | msmtp you@example.comisn’t a thing your server can do yet. The digest callsmsmtp -tand will tell you (politely) if msmtp is missing. python3— already present on Proxmox VE and Debian.- Optional: Prometheus, if you want firing alerts folded in. Leave
PROM_URLblank and the digest simply skips it.
The feed contract
This is the part that makes the digest grow with your lab. Any script, on any schedule, writes one small JSON file into /var/lib/digest-feeds/. Only title is required; everything else is optional:
{"title": "Pending updates",
"status": "info",
"summary": "6 package updates are waiting across 2 machines.",
"lines": ["pve1: 4 packages", "pve2: 2 packages"],
"action": {"label": "Approve - install them",
"subject": "APPROVE: apt upgrades",
"body": "Approved - run the pending upgrades tonight."}}
status picks the card color: ok (green), info (blue), warn (orange), urgent (red — also flips the subject line to “NEEDS YOU” and adds a red banner). action adds the mailto Approve button. Files render in name order, so a 10- / 20- / 90- filename prefix is your layout engine.
A producer can be as small as this:
#!/usr/bin/env bash
# Example producer: counts pending apt updates, writes its own digest card.
# Cron it weekly - the digest picks the file up automatically.
count=$(apt list --upgradable 2>/dev/null | grep -c upgradable)
cat > /var/lib/digest-feeds/20-updates.json <<EOF
{"title": "Pending updates", "status": "info",
"summary": "$count package updates are waiting on this host."}
EOF
That’s the whole integration story: adding a new signal to your morning email is writing one file. No digest changes, no restarts. Two real producers worth wiring in: the monthly PBS restore-test playbook emits a PASS/FAIL JSON that surfaces a failed or stale restore, and the Docker image-update check writes a pending-updates JSON — both show up as cards in this digest.
Notes
- Make these values your own — every one of these is a placeholder, not a literal to copy:
you@example.com/family@example.com(the recipients),FROM_ADDR(must match the account in your~/.msmtprc),APPROVE_TO(where Approve replies land),PROM_URLlikehttp://10.0.0.5:9090(your Prometheus, if any),SITE_NAME, the sample-feed details (pve1,local-lvm, the counts), and the08:30cron time. Rule of thumb: if a value looks specific to one machine, it’s a placeholder to change. - Test without root, without sending:
DIGEST_FEEDS=~/my-feeds ./daily-digest.py --dry-runreads feeds from any folder you like and only writes the preview HTML. Iterate on your card layout without emailing yourself forty times. - A broken feed can’t kill the digest. A file that fails to parse becomes an orange “this feed could not be read” card — so a buggy producer becomes a visible fact in tomorrow’s email instead of a silent gap.
- Prometheus down is a card, not a crash. Unreachable Prometheus renders as “Prometheus didn’t answer,” because the monitoring stack being down is exactly the kind of thing your one email exists to tell you.
- Why the HTML looks old-fashioned: classic Outlook renders mail with Word’s engine and Gmail ignores CSS it doesn’t support, so the digest uses real
<table>elements, inline styles, and hardcoded hex colors everywhere. It’s ugly source that renders everywhere — the parent post has the receipts. - The
mailto:encoding gotcha is handled: Python’surlencodedefaults to form-style+for spaces, which the mailto spec (RFC 6068) doesn’t promise to understand — the script passesquote_via=quoteso subjects and bodies are percent-encoded properly. - What “tested” means here: the skeleton was exercised end-to-end (sample feeds → dry-run → rendered HTML verified in a browser, send path verified against a missing/failing msmtp), and it’s distilled from the digest that has emailed my own lab’s mornings since late July. The Gmail leg of the send is exactly the msmtp playbook’s territory.
- Routine Proxmox notification noise (the every-backup emails) is a separate lever — see the one-line severity-matcher fix so failures still email you immediately while successes go quiet.
- Logs land in
/var/log/daily-digest.log(cron) and~/.msmtp.logor wherever your msmtp config points — check those first if a morning goes missing.