#!/usr/bin/env bash set -euo pipefail REPORT_PATH="${1:-}" REPORT_STATUS="${2:-UNKNOWN}" MAIL_FROM="${MAIL_FROM:-michideheld@gmx.de}" MAIL_TO="${MAIL_TO:-Mi.Kaleschke@gmx.de}" SMTP_HOST="${SMTP_HOST:-smtp.gmx.net}" SMTP_PORT="${SMTP_PORT:-587}" SMTP_USER="${SMTP_USER:-$MAIL_FROM}" SMTP_PASS_FILE="${SMTP_PASS_FILE:-/mnt/user/appdata/secrets/homelab_smtp_password.txt}" MAIL_IMAGE="${MAIL_IMAGE:-python:3.13-alpine}" MAIL_DNS_1="${MAIL_DNS_1:-1.1.1.1}" MAIL_DNS_2="${MAIL_DNS_2:-8.8.8.8}" if [ -z "$REPORT_PATH" ] || [ ! -f "$REPORT_PATH" ]; then echo "Usage: $0 [status]" >&2 exit 1 fi if [ ! -f "$SMTP_PASS_FILE" ]; then echo "Missing SMTP password file: $SMTP_PASS_FILE" >&2 exit 1 fi REPORT_BASENAME="$(basename "$REPORT_PATH")" REPORT_DATE="${REPORT_BASENAME#homelab-day-}" REPORT_DATE="${REPORT_DATE%.md}" SUBJECT="${MAIL_SUBJECT:-Homelab Operations Report - $REPORT_DATE - $REPORT_STATUS}" docker run -i --rm \ --dns "$MAIL_DNS_1" \ --dns "$MAIL_DNS_2" \ -e MAIL_FROM="$MAIL_FROM" \ -e MAIL_TO="$MAIL_TO" \ -e SMTP_HOST="$SMTP_HOST" \ -e SMTP_PORT="$SMTP_PORT" \ -e SMTP_USER="$SMTP_USER" \ -e MAIL_SUBJECT="$SUBJECT" \ -e REPORT_STATUS="$REPORT_STATUS" \ -e REPORT_HOSTNAME="$(hostname)" \ -v "$REPORT_PATH:/report.md:ro" \ -v "$SMTP_PASS_FILE:/smtp-password:ro" \ "$MAIL_IMAGE" python - <<'PY' import os import html import re import smtplib import ssl from datetime import datetime, timezone from email.message import EmailMessage from pathlib import Path mail_from = os.environ["MAIL_FROM"] mail_to = os.environ["MAIL_TO"] smtp_host = os.environ["SMTP_HOST"] smtp_port = int(os.environ.get("SMTP_PORT", "587")) smtp_user = os.environ.get("SMTP_USER") or mail_from subject = os.environ["MAIL_SUBJECT"] report_status = os.environ.get("REPORT_STATUS", "UNKNOWN") report_hostname = os.environ.get("REPORT_HOSTNAME", "") password = Path("/smtp-password").read_text(encoding="utf-8").strip() report = Path("/report.md").read_text(encoding="utf-8") # ---------- Style constants ---------- COLORS = { "bg": "#f7f8fa", "card_bg": "#ffffff", "text": "#0f172a", "text_muted": "#475569", "border": "#e2e8f0", "border_strong": "#cbd5e1", "zebra": "#f1f5f9", "code_bg": "#eef2ff", "code_text": "#3730a3", "pre_bg": "#f8fafc", "accent": "#3b82f6", } STATUS_THEMES = { "OK": {"banner_a": "#16a34a", "banner_b": "#22c55e", "card_bg": "#dcfce7", "card_border": "#86efac", "card_text": "#166534"}, "WARNUNG": {"banner_a": "#d97706", "banner_b": "#f59e0b", "card_bg": "#fef3c7", "card_border": "#fcd34d", "card_text": "#92400e"}, "KRITISCH": {"banner_a": "#dc2626", "banner_b": "#ef4444", "card_bg": "#fee2e2", "card_border": "#fca5a5", "card_text": "#991b1b"}, "UNKNOWN": {"banner_a": "#475569", "banner_b": "#64748b", "card_bg": "#f1f5f9", "card_border": "#cbd5e1", "card_text": "#334155"}, } OK_VALUES = {"ok", "completed", "0", "aktiv", "ja"} WARN_VALUES = {"warnung", "pending", "insufficient"} CRIT_VALUES = {"kritisch", "failed", "error"} UNKNOWN_VALUES = {"unknown", "missing"} CRIT_LABEL_HINTS = ("unhealthy", "firing") META_LABELS = {"Erstellt", "Zeitraum", "Host", "Gesamtbewertung"} SUMMARY_LIST_RE = re.compile(r"^-\s+(.+?):\s*`(.+?)`\s*$") H1_DATE_RE = re.compile(r"^#\s+Homelab Operations Report\s*-\s*(\d{4}-\d{2}-\d{2})") INLINE_CODE_RE = re.compile(r"`([^`]+)`") def classify(label, value): v = value.strip().lower() lbl = label.strip().lower() if v in OK_VALUES: return "OK" if v in UNKNOWN_VALUES: return "UNKNOWN" if v in WARN_VALUES: return "WARNUNG" if v in CRIT_VALUES: return "KRITISCH" try: n = float(v) except ValueError: return "UNKNOWN" if n == 0: return "OK" if any(hint in lbl for hint in CRIT_LABEL_HINTS): return "KRITISCH" return "WARNUNG" def classify_callout(text): lower = text.lower() if "kritisch" in lower or "sofort" in lower: return "crit" warn_hints = ("drift", "warnung", "ablauf", "brauchen aufmerksamkeit", "pruefen", "prüfen", "ueberalter", "nachverfolgt") if any(h in lower for h in warn_hints): return "warn" return "ok" # ---------- Pass 1: parse_blocks ---------- def parse_blocks(text): lines = text.splitlines() blocks = [] meta = {} report_date = None in_management_section = False seen_h2 = False i = 0 n = len(lines) def flush_paragraph(buf): if not buf: return joined = " ".join(buf).strip() if not joined: return if joined.startswith("Bewertung:"): blocks.append(("callout", classify_callout(joined), joined)) else: blocks.append(("paragraph", joined)) while i < n: line = lines[i] stripped = line.rstrip() m1 = H1_DATE_RE.match(stripped) if m1: report_date = m1.group(1) i += 1 continue if stripped.startswith("# "): i += 1 continue if stripped.startswith("```"): i += 1 pre_buf = [] while i < n and not lines[i].startswith("```"): pre_buf.append(lines[i]) i += 1 if i < n: i += 1 # closing fence blocks.append(("pre", "\n".join(pre_buf))) continue if stripped.startswith("### "): title = stripped[4:].strip() in_management_section = (title == "Management-Bewertung") blocks.append(("heading", 3, title)) i += 1 continue if stripped.startswith("## "): blocks.append(("heading", 2, stripped[3:].strip())) seen_h2 = True in_management_section = False i += 1 continue if stripped.startswith("- "): if in_management_section: entries = [] while i < n and lines[i].rstrip().startswith("- "): body = lines[i].rstrip()[2:].strip() if ":" in body: lbl, val = body.split(":", 1) val = val.strip() val = re.sub(r"`([^`]+)`", r"\1", val) entries.append((lbl.strip(), val)) else: entries.append((body, "")) i += 1 blocks.append(("summary_grid", entries)) in_management_section = False continue if not seen_h2: saved_i = i tmp_items = [] tmp_is_meta = True while i < n and lines[i].rstrip().startswith("- "): body = lines[i].rstrip()[2:].strip() if ":" not in body: tmp_is_meta = False break lbl, val = body.split(":", 1) val = val.strip() m = re.search(r"`([^`]+)`", val) if m: val = m.group(1) else: val = val.strip("`") tmp_items.append((lbl.strip(), val)) i += 1 if tmp_is_meta and tmp_items and any(lbl in META_LABELS for lbl, _ in tmp_items): for lbl, val in tmp_items: meta[lbl] = val continue i = saved_i items = [] while i < n and lines[i].rstrip().startswith("- "): items.append(lines[i].rstrip()[2:].strip()) i += 1 blocks.append(("ul", items)) continue if stripped.startswith("|") and stripped.endswith("|"): header_cells = [c.strip() for c in stripped.strip("|").split("|")] i += 1 alignments = ["left"] * len(header_cells) if i < n: sep = lines[i].rstrip() if sep.startswith("|") and "-" in sep and sep.endswith("|"): sep_cells = [c.strip() for c in sep.strip("|").split("|")] for idx, cell in enumerate(sep_cells): if idx >= len(alignments): continue if cell.startswith(":") and cell.endswith(":"): alignments[idx] = "center" elif cell.endswith(":"): alignments[idx] = "right" i += 1 rows = [] while i < n: row = lines[i].rstrip() if not (row.startswith("|") and row.endswith("|")): break rows.append([c.strip() for c in row.strip("|").split("|")]) i += 1 blocks.append(("table", header_cells, alignments, rows)) continue if not stripped: i += 1 continue para_buf = [stripped] i += 1 while i < n: nxt = lines[i].rstrip() if (not nxt or nxt.startswith("#") or nxt.startswith("- ") or nxt.startswith("```") or (nxt.startswith("|") and nxt.endswith("|"))): break para_buf.append(nxt) i += 1 flush_paragraph(para_buf) return blocks, report_date, meta # ---------- Pass 2: section wrappers ---------- def inject_section_wrappers(blocks): out = [] inside = False for blk in blocks: if blk[0] == "heading" and blk[1] == 2: if inside: out.append(("section_close",)) out.append(("section_open", blk[2])) inside = True continue out.append(blk) if inside: out.append(("section_close",)) return out # ---------- Pass 3: render ---------- def inline(text): escaped = html.escape(text) return INLINE_CODE_RE.sub( lambda m: ( f'' f'{m.group(1)}' ), escaped, ) def render_hero(status, report_date, hostname, meta): theme = STATUS_THEMES.get(status, STATUS_THEMES["UNKNOWN"]) a, b = theme["banner_a"], theme["banner_b"] date_label = report_date or meta.get("Erstellt", "") or "" chips = [] erstellt = meta.get("Erstellt", "") zeitraum = meta.get("Zeitraum", "") if erstellt: chips.append(f"Erstellt {html.escape(erstellt)}") if zeitraum: chips.append(f"Zeitraum {html.escape(zeitraum)}") if hostname: chips.append(f"Host {html.escape(hostname)}") chips_html = "" if chips: chips_html = ( '
' + "  ·  ".join(chips) + "
" ) return ( '
' '
Homelab Operations Report
' f'
' f'{html.escape(date_label)}
' '
' '{html.escape(status)}' '
' f'{chips_html}' '
' ) def render_section_open(title): return ( '' '' f'' '
' f'

{html.escape(title)}

' ) def render_section_close(): return "
" def render_heading(level, text): if level == 3: return ( f'

' f'{inline(text)}

' ) return ( f'

{inline(text)}

' ) def render_paragraph(text): return ( f'

{inline(text)}

' ) def render_callout(flavor, text): themes = { "ok": {"bg": "#ecfdf5", "border": "#16a34a", "text": "#065f46"}, "warn": {"bg": "#fffbeb", "border": "#d97706", "text": "#78350f"}, "crit": {"bg": "#fef2f2", "border": "#dc2626", "text": "#7f1d1d"}, } t = themes.get(flavor, themes["ok"]) return ( f'
{inline(text)}
' ) def render_ul(items): lis = "".join( f'
  • {inline(it)}
  • ' for it in items ) return f'' def render_summary_grid(entries): if not entries: return "" cards = [] for label, value in entries: status = classify(label, value) theme = STATUS_THEMES.get(status, STATUS_THEMES["UNKNOWN"]) cards.append( '' f'
    ' f'
    {html.escape(label)}
    ' f'
    ' f'{html.escape(value)}
    ' '
    ' ) rows_html = [] for chunk_start in range(0, len(cards), 3): chunk = cards[chunk_start:chunk_start + 3] while len(chunk) < 3: chunk.append('') rows_html.append("" + "".join(chunk) + "") return ( '' + "".join(rows_html) + "
    " ) def render_table(header_cells, alignments, rows): def is_numeric_header(h): h_strip = h.strip().rstrip(":") if re.search(r"(anzahl|zeilen|tage|sekunden|gestern|heute|%|nutzung|frei|resttage)$", h_strip, re.IGNORECASE): return True return False final_aligns = [] for idx, h in enumerate(header_cells): if idx < len(alignments) and alignments[idx] != "left": final_aligns.append(alignments[idx]) elif is_numeric_header(h): final_aligns.append("right") else: final_aligns.append("left") th_html = "".join( f'' f'{inline(h)}' for h, a in zip(header_cells, final_aligns) ) tr_html = [] for idx, row in enumerate(rows): bg = COLORS["zebra"] if idx % 2 == 1 else COLORS["card_bg"] cells = [] for cidx, cell in enumerate(row): a = final_aligns[cidx] if cidx < len(final_aligns) else "left" numeric_style = "font-variant-numeric:tabular-nums;" if a == "right" else "" cells.append( f'' f'{inline(cell)}' ) tr_html.append( f'' + "".join(cells) + "" ) return ( '' f'{th_html}' f'{"".join(tr_html)}' '
    ' ) def render_pre(text): return ( f'
    ' '
    '
            + html.escape(text)
            + '
    ' ) def render_footer(hostname): ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") parts = [] if hostname: parts.append(f"Host {html.escape(hostname)}") parts.append("Generator send-operations-report-mail.sh") parts.append(f"Rendered {ts}") return ( f'
    ' + "  ·  ".join(parts) + '
    ' ) def render_blocks(blocks, status, hostname, report_date, meta): out = [render_hero(status, report_date, hostname, meta)] for blk in blocks: kind = blk[0] if kind == "section_open": out.append(render_section_open(blk[1])) elif kind == "section_close": out.append(render_section_close()) elif kind == "heading": out.append(render_heading(blk[1], blk[2])) elif kind == "paragraph": out.append(render_paragraph(blk[1])) elif kind == "callout": out.append(render_callout(blk[1], blk[2])) elif kind == "ul": out.append(render_ul(blk[1])) elif kind == "summary_grid": out.append(render_summary_grid(blk[1])) elif kind == "table": out.append(render_table(blk[1], blk[2], blk[3])) elif kind == "pre": out.append(render_pre(blk[1])) out.append(render_footer(hostname)) return "\n".join(out) def markdown_to_html(text, status="UNKNOWN", hostname=""): blocks, report_date, meta = parse_blocks(text) blocks = inject_section_wrappers(blocks) body_html = render_blocks(blocks, status, hostname, report_date, meta) css = ( "body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;" f"line-height:1.55;color:{COLORS['text']};background:{COLORS['bg']};" "max-width:940px;margin:24px auto;padding:0 18px}" "*{box-sizing:border-box}" f"a{{color:{COLORS['accent']};text-decoration:none}}" "a:hover{text-decoration:underline}" ) return ( "" "" "" f"" "" f"{body_html}" "" ) message = EmailMessage() message["From"] = mail_from message["To"] = mail_to message["Subject"] = subject message.set_content(report, subtype="plain", charset="utf-8") message.add_alternative( markdown_to_html(report, status=report_status, hostname=report_hostname), subtype="html", charset="utf-8", ) context = ssl.create_default_context() with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as smtp: smtp.ehlo() smtp.starttls(context=context) smtp.ehlo() smtp.login(smtp_user, password) smtp.send_message(message) PY