613 lines
21 KiB
Bash
Executable File
613 lines
21 KiB
Bash
Executable File
#!/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 <report-path> [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'<code style="background:{COLORS["code_bg"]};color:{COLORS["code_text"]};'
|
|
f'padding:1px 6px;border-radius:4px;'
|
|
f'font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12.5px">'
|
|
f'{m.group(1)}</code>'
|
|
),
|
|
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 = (
|
|
'<div style="margin-top:14px;font-size:12px;color:rgba(255,255,255,0.92);'
|
|
'line-height:1.5">'
|
|
+ " · ".join(chips)
|
|
+ "</div>"
|
|
)
|
|
return (
|
|
'<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" '
|
|
'style="margin-bottom:20px"><tr><td '
|
|
f'bgcolor="{a}" '
|
|
f'style="background-color:{a};'
|
|
f'background-image:linear-gradient(135deg,{a} 0%,{b} 100%);'
|
|
'padding:28px 32px;border-radius:12px;color:#ffffff">'
|
|
'<div style="font-size:12px;text-transform:uppercase;letter-spacing:0.14em;'
|
|
'opacity:0.85;font-weight:600">Homelab Operations Report</div>'
|
|
f'<div style="font-size:30px;font-weight:700;margin-top:8px;line-height:1.1">'
|
|
f'{html.escape(date_label)}</div>'
|
|
'<div style="margin-top:14px">'
|
|
'<span style="display:inline-block;background:rgba(255,255,255,0.22);'
|
|
'padding:6px 16px;border-radius:999px;font-weight:700;'
|
|
f'letter-spacing:0.08em;font-size:13px">{html.escape(status)}</span>'
|
|
'</div>'
|
|
f'{chips_html}'
|
|
'</td></tr></table>'
|
|
)
|
|
|
|
|
|
def render_section_open(title):
|
|
return (
|
|
'<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" '
|
|
f'style="margin:16px 0;background:{COLORS["card_bg"]};'
|
|
f'border:1px solid {COLORS["border"]};border-radius:10px;'
|
|
'box-shadow:0 1px 2px rgba(15,23,42,0.04);overflow:hidden">'
|
|
'<tr>'
|
|
f'<td width="4" bgcolor="{COLORS["accent"]}" '
|
|
f'style="background-color:{COLORS["accent"]};width:4px;min-width:4px"></td>'
|
|
'<td style="padding:18px 24px">'
|
|
f'<h2 style="margin:0 0 14px;font-size:19px;color:{COLORS["text"]};'
|
|
f'font-weight:700;letter-spacing:-0.01em">{html.escape(title)}</h2>'
|
|
)
|
|
|
|
|
|
def render_section_close():
|
|
return "</td></tr></table>"
|
|
|
|
|
|
def render_heading(level, text):
|
|
if level == 3:
|
|
return (
|
|
f'<h3 style="font-size:14px;margin:18px 0 8px;color:{COLORS["text_muted"]};'
|
|
'font-weight:600;text-transform:uppercase;letter-spacing:0.06em">'
|
|
f'{inline(text)}</h3>'
|
|
)
|
|
return (
|
|
f'<h4 style="font-size:13px;margin:14px 0 6px;color:{COLORS["text_muted"]};'
|
|
f'font-weight:600">{inline(text)}</h4>'
|
|
)
|
|
|
|
|
|
def render_paragraph(text):
|
|
return (
|
|
f'<p style="margin:8px 0;color:{COLORS["text"]};line-height:1.6;'
|
|
f'font-size:14px">{inline(text)}</p>'
|
|
)
|
|
|
|
|
|
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'<div style="background:{t["bg"]};border-left:4px solid {t["border"]};'
|
|
f'color:{t["text"]};padding:12px 16px;margin:14px 0;border-radius:4px;'
|
|
f'line-height:1.55;font-size:14px">{inline(text)}</div>'
|
|
)
|
|
|
|
|
|
def render_ul(items):
|
|
lis = "".join(
|
|
f'<li style="margin:5px 0;color:{COLORS["text"]};'
|
|
f'line-height:1.55;font-size:14px">{inline(it)}</li>'
|
|
for it in items
|
|
)
|
|
return f'<ul style="margin:8px 0 12px 22px;padding:0">{lis}</ul>'
|
|
|
|
|
|
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(
|
|
'<td style="padding:6px;width:33.33%;vertical-align:top">'
|
|
f'<div style="background:{theme["card_bg"]};'
|
|
f'border:1px solid {theme["card_border"]};'
|
|
'border-radius:8px;padding:12px 14px">'
|
|
f'<div style="font-size:11px;color:#1e293b;'
|
|
'text-transform:uppercase;letter-spacing:0.08em;font-weight:700;'
|
|
f'line-height:1.3;opacity:0.78">{html.escape(label)}</div>'
|
|
f'<div style="font-size:17px;font-weight:700;'
|
|
f'color:{theme["card_text"]};margin-top:5px;line-height:1.25;'
|
|
f'word-break:break-word;font-variant-numeric:tabular-nums">'
|
|
f'{html.escape(value)}</div>'
|
|
'</div></td>'
|
|
)
|
|
rows_html = []
|
|
for chunk_start in range(0, len(cards), 3):
|
|
chunk = cards[chunk_start:chunk_start + 3]
|
|
while len(chunk) < 3:
|
|
chunk.append('<td style="padding:6px;width:33.33%"></td>')
|
|
rows_html.append("<tr>" + "".join(chunk) + "</tr>")
|
|
return (
|
|
'<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" '
|
|
'style="margin:12px 0;border-collapse:separate;border-spacing:0">'
|
|
+ "".join(rows_html)
|
|
+ "</table>"
|
|
)
|
|
|
|
|
|
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'<th align="{a}" style="text-align:{a};padding:9px 12px;'
|
|
f'background:{COLORS["zebra"]};'
|
|
f'border-bottom:2px solid {COLORS["border_strong"]};'
|
|
f'color:{COLORS["text"]};font-size:12px;font-weight:600;'
|
|
'text-transform:uppercase;letter-spacing:0.05em">'
|
|
f'{inline(h)}</th>'
|
|
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'<td align="{a}" style="text-align:{a};padding:8px 12px;'
|
|
f'border-bottom:1px solid {COLORS["border"]};'
|
|
f'color:{COLORS["text"]};font-size:13px;{numeric_style}">'
|
|
f'{inline(cell)}</td>'
|
|
)
|
|
tr_html.append(
|
|
f'<tr style="background:{bg}">' + "".join(cells) + "</tr>"
|
|
)
|
|
|
|
return (
|
|
'<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" '
|
|
f'style="border-collapse:collapse;margin:12px 0;'
|
|
f'border:1px solid {COLORS["border"]};border-radius:6px;overflow:hidden">'
|
|
f'<thead><tr>{th_html}</tr></thead>'
|
|
f'<tbody>{"".join(tr_html)}</tbody>'
|
|
'</table>'
|
|
)
|
|
|
|
|
|
def render_pre(text):
|
|
return (
|
|
f'<div style="background:{COLORS["pre_bg"]};'
|
|
f'border:1px solid {COLORS["border"]};'
|
|
f'border-left:4px solid {COLORS["accent"]};'
|
|
'border-radius:6px;padding:12px 14px;margin:12px 0;overflow:auto">'
|
|
'<pre style="margin:0;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;'
|
|
f'font-size:12px;color:{COLORS["text"]};line-height:1.5;'
|
|
'white-space:pre-wrap;word-break:break-word">'
|
|
+ html.escape(text)
|
|
+ '</pre></div>'
|
|
)
|
|
|
|
|
|
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'<div style="margin:24px 0 4px;padding:14px;color:{COLORS["text_muted"]};'
|
|
'font-size:11px;line-height:1.5;text-align:center;'
|
|
f'border-top:1px solid {COLORS["border"]}">'
|
|
+ " · ".join(parts)
|
|
+ '</div>'
|
|
)
|
|
|
|
|
|
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 (
|
|
"<!doctype html>"
|
|
"<html><head><meta charset='utf-8'>"
|
|
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
|
f"<style>{css}</style>"
|
|
"</head><body>"
|
|
f"{body_html}"
|
|
"</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
|