#!/usr/bin/env bash
#
# CheckMK local check: mailcow-dockerized
#
#   ohne Parameter : erzeugt <<<local:sep(0)>>> Output
#   --detect       : exit 0 = Plugin wird auf dieser Maschine benoetigt
#   --stacks       : Debug - zeigt gefundene mailcow-Stacks
#
# Benoetigt: bash, docker, awk, sed, openssl (optional, sonst via Container)
# Netz:      github.com (Update-Check via update.sh)
#
# Achtung: kein "set -e" - viele Aufrufe duerfen fehlschlagen, ohne dass das
# Plugin abbricht. Fehler werden pro Service in einen Status uebersetzt.
set -uo pipefail

########################### Konfiguration ##################################
DOCKER_BIN="${DOCKER_BIN:-docker}"
DOCKER_TIMEOUT="${DOCKER_TIMEOUT:-30}"     # Timeout je docker-Aufruf
UPDATE_TIMEOUT="${UPDATE_TIMEOUT:-90}"     # update.sh spricht mit github

# Gleichzeitig laufende Checks insgesamt (nicht pro Stack). 1 = seriell.
MAX_PARALLEL="${MAX_PARALLEL:-$(nproc 2>/dev/null || echo 4)}"

# tags = nur neue Releases melden (empfohlen)
# code = jeder neue Commit auf dem Branch (sehr geschwaetzig)
MAILCOW_UPDATE_MODE="${MAILCOW_UPDATE_MODE:-tags}"

CERT_WARN_DAYS="${CERT_WARN_DAYS:-21}"
CERT_CRIT_DAYS="${CERT_CRIT_DAYS:-7}"

QUEUE_WARN="${QUEUE_WARN:-50}"             # Postfix-Queue: Nachrichten
QUEUE_CRIT="${QUEUE_CRIT:-500}"

# Mailbox- und Quota-Auswertung ueber die mailcow-Datenbank. Die Zugangsdaten
# stehen in mailcow.conf, es ist also nichts zu konfigurieren - aber es wird
# eine DB-Abfrage ausgefuehrt, deshalb bewusst opt-in.
MAILCOW_DB_CHECKS="${MAILCOW_DB_CHECKS:-0}"
QUOTA_WARN_PCT="${QUOTA_WARN_PCT:-90}"     # Mailbox gilt ab hier als "voll"

# Standardpfade, die vor den Compose-Labels probiert werden. Der offizielle
# Installationspfad hat einen Bindestrich; die Variante ohne kommt vor, weil
# der Compose-PROJEKTNAME "mailcowdockerized" heisst und manche danach
# benennen.
MAILCOW_DIR_CANDIDATES="${MAILCOW_DIR_CANDIDATES:-/opt/mailcow-dockerized /opt/mailcowdockerized}"

########################### Helfer #########################################

have() { command -v "$1" >/dev/null 2>&1; }

emit() {  # <state> <servicename> <perfdata|-> <text>
  local state="$1" name="$2" perf="${3:--}" text="$4"
  name="${name//\"/}"; name="${name//$'\t'/ }"
  text="${text//$'\n'/ }"; text="${text//$'\t'/ }"
  printf '%s "%s" %s %s\n' "$state" "$name" "$perf" "$text"
}

dk() { timeout "$DOCKER_TIMEOUT" "$DOCKER_BIN" "$@" 2>/dev/null; }

# Wert aus mailcow.conf. Die Datei wird von bash gesourct, der Wert reicht
# also bis Zeilenende - '#' ist dort KEIN Kommentarbeginn. Wer es als solchen
# behandelt, bekommt bei einem Passwort mit '#' einen leeren String zurueck.
mc_conf() {  # $1 = dir, $2 = key
  sed -nE "s/^[[:space:]]*$2=(.*)\$/\1/p" "$1/mailcow.conf" 2>/dev/null \
    | tail -n1 \
    | sed -E "s/[[:space:]]+\$//; s/^\"(.*)\"\$/\1/; s/^'(.*)'\$/\1/"
}

########################### Stack-Discovery ################################

# mailcow benennt jeden Compose-Service "<name>-mailcow". Darueber ist der
# Stack eindeutig erkennbar, unabhaengig vom Projektnamen (den kann man in
# mailcow.conf via COMPOSE_PROJECT_NAME aendern) und vom Installationspfad.
#
# Trennzeichen ist "|" statt Tab: Tab ist ein IFS-Whitespace-Zeichen, leere
# Felder wuerden beim Einlesen kollabieren und alle Werte verschieben.
# Gehoert das Verzeichnis wirklich zu diesem Stack? mailcow setzt
# COMPOSE_PROJECT_NAME in mailcow.conf - ohne diese Pruefung wuerde auf einem
# Host mit zwei Stacks der Standardpfad dem falschen Projekt zugeordnet.
mc_dir_matches() {  # $1 = dir, $2 = projekt, $3 = strict|loose
  [[ -n "$1" && -f "$1/mailcow.conf" ]] || return 1
  [[ "$3" == "loose" ]] && return 0
  local p; p="$(mc_conf "$1" COMPOSE_PROJECT_NAME)"
  [[ "$p" == "$2" ]]
}

# $4 = 1 erlaubt den ungeprueften Rueckfall auf einen Standardpfad. Das ist
# nur zulaessig, wenn es genau EINEN Stack auf dem Host gibt - sonst bekaeme
# ein Stack ohne Labels das Verzeichnis eines anderen zugewiesen und man
# pruefte dessen Zertifikate und Update-Stand.
mc_resolve_dir() {  # $1 = projekt, $2 = working_dir, $3 = config_files, $4 = allow_loose
  local proj="$1" wdir="$2" cfg="$3" loose="${4:-0}" c d
  cfg="${cfg%%,*}"; d="${cfg%/*}"

  # 1. Standardpfade - ein Dateitest kostet nichts und traegt auch dann, wenn
  #    die Compose-Labels fehlen (docker-compose v1, manuell erzeugte Container).
  for c in $MAILCOW_DIR_CANDIDATES; do
    mc_dir_matches "$c" "$proj" strict && { printf '%s\n' "$c"; return 0; }
  done
  # 2./3. Labels des laufenden Stacks
  mc_dir_matches "$wdir" "$proj" loose && { printf '%s\n' "$wdir"; return 0; }
  mc_dir_matches "$d"    "$proj" loose && { printf '%s\n' "$d";    return 0; }
  # 4. Standardpfad ohne Projektabgleich - nur bei einem einzigen Stack.
  if [[ "$loose" == "1" ]]; then
    for c in $MAILCOW_DIR_CANDIDATES; do
      mc_dir_matches "$c" "$proj" loose && { printf '%s\n' "$c"; return 0; }
    done
  fi
  return 0
}

# mailcow benennt jeden Compose-Service "<name>-mailcow". Darueber ist der
# Stack eindeutig erkennbar, unabhaengig vom Projektnamen und vom Pfad.
#
# Trennzeichen ist "|" statt Tab: Tab ist ein IFS-Whitespace-Zeichen, leere
# Felder wuerden beim Einlesen kollabieren und alle Werte verschieben.
mailcow_stacks() {  # -> "projekt|verzeichnis"
  have "$DOCKER_BIN" || return 0
  local -a raw=()
  mapfile -t raw < <(
    dk ps -a --format '{{.Label "com.docker.compose.project"}}|{{.Label "com.docker.compose.service"}}|{{.Label "com.docker.compose.project.working_dir"}}|{{.Label "com.docker.compose.project.config_files"}}' \
      | awk -F'|' '$2 ~ /-mailcow$/ && $1 != "" && !seen[$1]++ { print $1 "|" $3 "|" $4 }'
  )
  local loose=0
  (( ${#raw[@]} == 1 )) && loose=1

  local line proj wdir cfg
  for line in "${raw[@]}"; do
    IFS='|' read -r proj wdir cfg <<< "$line"
    printf '%s|%s\n' "$proj" "$(mc_resolve_dir "$proj" "$wdir" "$cfg" "$loose")"
  done
}

# Containername zu einem Compose-Service innerhalb eines Projekts
mc_container() {  # $1 = projekt, $2 = service (z.B. postfix-mailcow)
  dk ps --filter "label=com.docker.compose.project=$1" \
        --filter "label=com.docker.compose.service=$2" \
        --format '{{.Names}}' | head -n1
}

########################### debug / detect #################################

if [[ "${1:-}" == "--stacks" ]]; then
  echo "== mailcow-Stacks =="
  mailcow_stacks | while IFS='|' read -r p d; do
    printf '  projekt=%-24s dir=%s\n' "$p" "${d:-<nicht gefunden>}"
  done
  exit 0
fi

if [[ "${1:-}" == "--detect" ]]; then
  have "$DOCKER_BIN" || exit 1
  [[ -n "$(mailcow_stacks | head -n1)" ]] && exit 0
  exit 1
fi

########################### Job-Pool #######################################

SEM_FD=""

pool_init() {
  local fifo i
  (( MAX_PARALLEL > 1 )) || return 1
  fifo="$(mktemp -u)" || return 1
  mkfifo "$fifo" 2>/dev/null || return 1
  exec {SEM_FD}<>"$fifo" || { rm -f "$fifo"; SEM_FD=""; return 1; }
  rm -f "$fifo"
  for (( i = 0; i < MAX_PARALLEL; i++ )); do printf '.' >&"$SEM_FD"; done
  return 0
}

pool_run() {  # <ausgabedatei> <kommando...>
  local out="$1"; shift
  if [[ -z "$SEM_FD" ]]; then "$@" > "$out" 2>/dev/null; return 0; fi
  local tok
  IFS= read -r -n1 -u "$SEM_FD" tok
  { "$@" > "$out" 2>/dev/null; printf '.' >&"$SEM_FD"; } &
}

########################### Einzelchecks ###################################

# SKIP_<NAME>=y in mailcow.conf schaltet den zugehoerigen Dienst ab. Die
# Container werden dabei NICHT entfernt - ihr Entrypoint legt sich schlafen
# oder beendet sich. Je nach Dienst steht das als "exited" oder als
# "running (unhealthy)" da, weil der Healthcheck weiterlaeuft. Ohne diese
# Auswertung meldet der Check auf jedem Host mit deaktiviertem ClamAV
# dauerhaft CRIT.
#
# Die Zuordnung folgt der Namenskonvention SKIP_CLAMD -> clamd-mailcow. Das
# gilt auch fuer kuenftige SKIP-Variablen; nur Ausnahmen brauchen einen
# Eintrag in der case-Liste. Variablen ohne zugehoerigen Container
# (SKIP_IP_CHECK & Co.) laufen ins Leere und stoeren nicht.
mailcow_disabled_services() {  # $1 = dir -> ein Servicename je Zeile
  local dir="$1" var val name
  [[ -n "$dir" && -f "$dir/mailcow.conf" ]] || return 0
  while IFS= read -r var; do
    [[ -n "$var" ]] || continue
    val="$(mc_conf "$dir" "$var")"
    [[ "${val,,}" =~ ^(y|yes)$ ]] || continue
    name="${var#SKIP_}"; name="${name,,}"
    case "$name" in
      lets_encrypt) name="acme" ;;
    esac
    printf '%s-mailcow\n' "$name"
  # sed statt grep+tr: 'tr -d [:space:]' wuerde auch die Zeilenumbrueche
  # loeschen und alle Variablennamen zu einer einzigen Zeile verkleben.
  done < <(sed -nE 's/^[[:space:]]*(SKIP_[A-Z_]+)=.*/\1/p' "$dir/mailcow.conf" 2>/dev/null | sort -u)
  return 0
}

check_containers() {  # svc projekt dir
  local svc="$1" proj="$2" dir="${3:-}"
  local name state status service sname
  local total=0 running=0 expected=0
  local -a bad=() warn=() disabled=()
  local -A skip=()

  while IFS= read -r sname; do
    [[ -n "$sname" ]] && skip["$sname"]=1
  done < <(mailcow_disabled_services "$dir")

  while IFS='|' read -r name state status service; do
    [[ -n "$name" ]] || continue
    total=$(( total + 1 ))

    # SKIP heisst nicht "Container ist weg", sondern "sein Ausfall ist kein
    # Alarm". mailcow laesst die Container stehen; je nach Dienst laufen sie
    # weiter (acme schlaeft bei SKIP_LETS_ENCRYPT=y trotzdem als gesunder
    # Prozess) oder beenden sich. Ein gesund laufender Container wird deshalb
    # normal gezaehlt - nur ein auffaelliger wird ausgeblendet statt gemeldet.
    local skipped=""
    [[ -n "${skip[$service]:-}" ]] && skipped=1

    expected=$(( expected + 1 ))
    case "$state" in
      running)
        running=$(( running + 1 ))
        case "$status" in
          *"(unhealthy)"*)
            if [[ -n "$skipped" ]]; then
              disabled+=("${service%-mailcow}:unhealthy"); expected=$(( expected - 1 )); running=$(( running - 1 ))
            else
              bad+=("$name:unhealthy")
            fi ;;
          *"(health: starting)"*) warn+=("$name:starting") ;;
        esac
        ;;
      restarting)
        if [[ -n "$skipped" ]]; then
          disabled+=("${service%-mailcow}:restarting"); expected=$(( expected - 1 ))
        else
          warn+=("$name:restarting")
        fi ;;
      *)
        if [[ -n "$skipped" ]]; then
          disabled+=("${service%-mailcow}:$state"); expected=$(( expected - 1 ))
        else
          bad+=("$name:$state")
        fi ;;
    esac
  done < <(dk ps -a --filter "label=com.docker.compose.project=$proj" \
              --format '{{.Names}}|{{.State}}|{{.Status}}|{{.Label "com.docker.compose.service"}}')

  local perf="containers_running=$running;;;0;$expected containers_bad=${#bad[@]};;1;0 containers_disabled=${#disabled[@]}"
  local note=""
  (( ${#disabled[@]} > 0 )) && note=", ${#disabled[@]} per SKIP_ deaktiviert und daher nicht bewertet: ${disabled[*]}"

  if (( total == 0 )); then
    emit 3 "$svc Container" "-" "UNKNOWN - keine Container fuer Projekt $proj gefunden"
  elif (( ${#bad[@]} > 0 )); then
    emit 2 "$svc Container" "$perf" \
      "CRIT - $running/$expected laufen, Probleme: ${bad[*]}$note"
  elif (( ${#warn[@]} > 0 )); then
    emit 1 "$svc Container" "$perf" \
      "WARN - $running/$expected laufen, in Uebergang: ${warn[*]}$note"
  else
    emit 0 "$svc Container" "$perf" \
      "OK - alle $expected aktiven Container laufen und sind gesund$note"
  fi
}

check_update() {  # svc dir
  local svc="$1" dir="$2" flag out rc label
  if [[ ! -x "$dir/update.sh" ]]; then
    emit 3 "$svc Update" "-" "UNKNOWN - $dir/update.sh nicht ausfuehrbar"
    return 0
  fi

  if [[ "$MAILCOW_UPDATE_MODE" == "code" ]]; then
    flag="--check";      label="Code-Update"
  else
    flag="--check-tags"; label="Release"
  fi

  # update.sh besteht darauf, aus dem Installationsverzeichnis zu laufen.
  out="$(cd "$dir" && timeout "$UPDATE_TIMEOUT" ./update.sh "$flag" 2>&1 </dev/null)"
  rc=$?

  case "$rc" in
    0)  emit 1 "$svc Update" "update_available=1;1;;0;1" \
          "WARN - $label verfuegbar (./update.sh $flag)" ;;
    3)  emit 0 "$svc Update" "update_available=0;1;;0;1" "OK - kein $label verfuegbar" ;;
    99) emit 3 "$svc Update" "-" \
          "UNKNOWN - update.sh konnte github nicht abfragen" ;;
    124) emit 3 "$svc Update" "-" \
          "UNKNOWN - update.sh nach ${UPDATE_TIMEOUT}s abgebrochen" ;;
    *)  emit 3 "$svc Update" "-" \
          "UNKNOWN - update.sh Exit $rc: ${out%%$'\n'*}" ;;
  esac
}

# openssl bevorzugt vom Host, sonst aus dem nginx-Container.
cert_enddate() {  # $1 = pfad, $2 = projekt
  local f="$1" proj="$2" c
  if have openssl; then
    openssl x509 -enddate -noout -in "$f" 2>/dev/null | cut -d= -f2
    return 0
  fi
  c="$(mc_container "$proj" nginx-mailcow)"
  [[ -n "$c" ]] || return 0
  dk exec "$c" openssl x509 -enddate -noout -in /etc/ssl/mail/cert.pem 2>/dev/null | cut -d= -f2
}

cert_names() {  # $1 = pfad -> SAN-Liste
  have openssl || return 0
  openssl x509 -noout -ext subjectAltName -in "$1" 2>/dev/null \
    | tr ',' '\n' | sed -nE 's/.*DNS:([^ ,]+).*/\1/p'
}

# Prueft, ob ein Hostname von der SAN-Liste abgedeckt wird, und gibt den
# passenden Eintrag aus. Wildcards nach RFC 6125: "*.example.de" deckt genau
# eine Ebene ab - mail.example.de ja, example.de nein, a.b.example.de nein.
# DNS-Namen sind case-insensitiv, deshalb beidseitig kleingeschrieben.
san_covers() {  # $1 = hostname, $2 = SAN-Liste (eine pro Zeile)
  local host="${1,,}" n base left
  while read -r n; do
    n="${n,,}"
    [[ -n "$n" ]] || continue
    if [[ "$n" == "$host" ]]; then printf '%s\n' "$n"; return 0; fi
    if [[ "$n" == \*.* ]]; then
      base="${n#\*.}"
      [[ "$host" == *".$base" ]] || continue
      left="${host%".$base"}"
      [[ -n "$left" && "$left" != *.* ]] && { printf '%s\n' "$n"; return 0; }
    fi
  done <<< "$2"
  return 1
}

check_certs() {  # svc dir projekt hostname
  local svc="$1" dir="$2" proj="$3" host="$4"
  local f end end_ts days state txt names covered n match found=0

  for f in "$dir/data/assets/ssl/cert.pem" "$dir"/data/assets/ssl/*/cert.pem; do
    [[ -f "$f" ]] || continue
    found=$(( found + 1 ))

    end="$(cert_enddate "$f" "$proj")"
    if [[ -z "$end" ]]; then
      emit 3 "$svc Cert ${f#$dir/data/assets/ssl/}" "-" \
        "UNKNOWN - Ablaufdatum aus $f nicht lesbar (openssl vorhanden?)"
      continue
    fi
    end_ts="$(date -d "$end" +%s 2>/dev/null)" || end_ts=""
    if [[ -z "$end_ts" ]]; then
      emit 3 "$svc Cert ${f#$dir/data/assets/ssl/}" "-" "UNKNOWN - Datum '$end' nicht lesbar"
      continue
    fi
    days=$(( (end_ts - $(date +%s)) / 86400 ))

    if   (( days < CERT_CRIT_DAYS )); then state=2; txt="CRIT"
    elif (( days < CERT_WARN_DAYS )); then state=1; txt="WARN"
    else                                   state=0; txt="OK"
    fi

    # Fehlende SAN-Abdeckung ist der haeufigste stille ACME-Fehler: das
    # Zertifikat ist gueltig, deckt aber eine neu angelegte Domain nicht ab.
    covered=""
    if [[ -n "$host" ]]; then
      names="$(cert_names "$f")"
      if [[ -n "$names" ]]; then
        n="$(printf '%s\n' "$names" | wc -l)"
        if match="$(san_covers "$host" "$names")"; then
          if [[ "$match" == "$host" ]]; then
            covered=", $n SAN inkl. $host"
          else
            covered=", $n SAN, $host abgedeckt durch Wildcard $match"
          fi
        else
          covered=", $n SAN aber OHNE $host"
          (( state < 1 )) && { state=1; txt="WARN"; }
        fi
      fi
    fi

    emit "$state" "$svc Cert ${f#$dir/data/assets/ssl/}" \
      "days_left=${days};${CERT_WARN_DAYS};${CERT_CRIT_DAYS};0" \
      "$txt - laeuft in $days Tagen ab ($end)$covered"
  done

  (( found > 0 )) || emit 3 "$svc Cert" "-" \
    "UNKNOWN - kein Zertifikat unter $dir/data/assets/ssl/ gefunden"
}

check_queue() {  # svc projekt
  local svc="$1" proj="$2" c out n
  c="$(mc_container "$proj" postfix-mailcow)"
  if [[ -z "$c" ]]; then
    emit 3 "$svc Postfix Queue" "-" "UNKNOWN - postfix-mailcow laeuft nicht"
    return 0
  fi

  out="$(dk exec "$c" postqueue -p | tail -n1)"
  if [[ -z "$out" ]]; then
    emit 3 "$svc Postfix Queue" "-" "UNKNOWN - postqueue lieferte keine Ausgabe"
    return 0
  fi
  if grep -qi 'queue is empty' <<< "$out"; then
    n=0
  else
    n="$(sed -nE 's/.* in ([0-9]+) Request.*/\1/p' <<< "$out")"
    [[ -n "$n" ]] || { emit 3 "$svc Postfix Queue" "-" "UNKNOWN - postqueue: $out"; return 0; }
  fi

  local state=0 txt="OK"
  if   (( n >= QUEUE_CRIT )); then state=2; txt="CRIT"
  elif (( n >= QUEUE_WARN )); then state=1; txt="WARN"
  fi
  emit "$state" "$svc Postfix Queue" "queue_length=$n;$QUEUE_WARN;$QUEUE_CRIT;0" \
    "$txt - $n Nachricht(en) in der Queue"
}

# Reine Trenddaten, deshalb immer OK. Ein sinnvoller Schwellwert fuer die
# Spam-Rate haengt vom Kunden ab - das gehoert in eine CheckMK-Regel, nicht
# in ein Plugin, das auf jedem Server dieselben Grenzen setzen wuerde.
check_rspamd() {  # svc projekt
  local svc="$1" proj="$2" c out scanned spam ham learned
  c="$(mc_container "$proj" rspamd-mailcow)"
  if [[ -z "$c" ]]; then
    emit 3 "$svc Rspamd" "-" "UNKNOWN - rspamd-mailcow laeuft nicht"
    return 0
  fi
  out="$(dk exec "$c" rspamc stat)"
  if [[ -z "$out" ]]; then
    emit 3 "$svc Rspamd" "-" "UNKNOWN - rspamc stat lieferte keine Ausgabe"
    return 0
  fi
  scanned="$(sed -nE 's/^Messages scanned: ([0-9]+).*/\1/p'        <<< "$out" | head -n1)"
  spam="$(sed -nE   's/^Messages treated as spam: ([0-9]+).*/\1/p' <<< "$out" | head -n1)"
  ham="$(sed -nE    's/^Messages treated as ham: ([0-9]+).*/\1/p'  <<< "$out" | head -n1)"
  learned="$(sed -nE 's/^Total learns: ([0-9]+).*/\1/p'            <<< "$out" | head -n1)"

  emit 0 "$svc Rspamd" \
    "scanned=${scanned:-0} spam=${spam:-0} ham=${ham:-0} learns=${learned:-0}" \
    "OK - ${scanned:-0} gescannt, ${spam:-0} Spam, ${ham:-0} Ham, ${learned:-0} Lernvorgaenge"
}

check_mailboxes() {  # svc dir projekt
  local svc="$1" dir="$2" proj="$3" c dbuser dbpass dbname row
  local domains mailboxes full bytes

  c="$(mc_container "$proj" mysql-mailcow)"
  [[ -n "$c" ]] || c="$(mc_container "$proj" mariadb-mailcow)"
  if [[ -z "$c" ]]; then
    emit 3 "$svc Mailboxes" "-" "UNKNOWN - Datenbank-Container laeuft nicht"
    return 0
  fi

  dbuser="$(mc_conf "$dir" DBUSER)"
  dbpass="$(mc_conf "$dir" DBPASS)"
  dbname="$(mc_conf "$dir" DBNAME)"
  if [[ -z "$dbuser" || -z "$dbpass" || -z "$dbname" ]]; then
    emit 3 "$svc Mailboxes" "-" "UNKNOWN - DB-Zugangsdaten nicht aus mailcow.conf lesbar"
    return 0
  fi

  # Passwort per Umgebungsvariable, nicht als Argument - sonst steht es in
  # der Prozessliste des Containers.
  row="$(dk exec -e MYSQL_PWD="$dbpass" "$c" mysql -u"$dbuser" -N -B "$dbname" -e "
    SELECT
      (SELECT COUNT(*) FROM domain  WHERE active = 1),
      (SELECT COUNT(*) FROM mailbox WHERE active = 1),
      (SELECT COUNT(*) FROM mailbox m JOIN quota2 q ON q.username = m.username
         WHERE m.active = 1 AND m.quota > 0 AND q.bytes >= m.quota * $QUOTA_WARN_PCT / 100),
      (SELECT COALESCE(SUM(q.bytes), 0) FROM quota2 q)
  ")"

  IFS=$'\t' read -r domains mailboxes full bytes <<< "$row"
  if [[ -z "${mailboxes:-}" ]]; then
    emit 3 "$svc Mailboxes" "-" "UNKNOWN - DB-Abfrage lieferte kein Ergebnis"
    return 0
  fi

  local state=0 txt="OK"
  (( full > 0 )) && { state=1; txt="WARN"; }
  emit "$state" "$svc Mailboxes" \
    "mailboxes=$mailboxes domains=$domains quota_full=$full;1;;0 used_bytes=${bytes:-0}" \
    "$txt - $mailboxes Mailboxen in $domains Domains, $full ueber ${QUOTA_WARN_PCT}% Quota, $(( ${bytes:-0} / 1073741824 )) GiB belegt"
}

########################### Ausgabe sammeln ################################

collect() {
  echo "<<<local:sep(0)>>>"

  if ! have "$DOCKER_BIN"; then
    emit 2 "mailcow" "-" "CRIT - $DOCKER_BIN nicht gefunden"
    return 0
  fi
  if ! dk info >/dev/null; then
    emit 2 "mailcow" "-" "CRIT - Docker-Daemon nicht erreichbar"
    return 0
  fi

  local -a proj=() dir=()
  local line p d
  while IFS='|' read -r p d; do
    [[ -n "$p" ]] || continue
    proj+=("$p"); dir+=("$d")
  done < <(mailcow_stacks)

  emit 0 "mailcow Stacks" "stacks=${#proj[@]}" \
       "OK - ${#proj[@]} mailcow-Stack(s) gefunden"
  (( ${#proj[@]} )) || return 0

  local tmpdir
  tmpdir="$(mktemp -d)" || return 0
  trap 'rm -rf "$tmpdir"' RETURN

  pool_init || SEM_FD=""

  local i n svc host version branch out
  for i in "${!proj[@]}"; do
    d="${dir[i]}"
    host="";  version=""; branch=""
    if [[ -n "$d" ]]; then
      host="$(mc_conf "$d" MAILCOW_HOSTNAME)"
      version="$(git -C "$d" describe --tags --abbrev=0 2>/dev/null)"
      branch="$(git -C "$d" rev-parse --abbrev-ref HEAD 2>/dev/null)"
    fi
    svc="mailcow ${host:-${proj[i]}}"

    out="$(printf '%s/%03d-00.out' "$tmpdir" "$i")"
    if [[ -z "$d" ]]; then
      emit 3 "$svc Status" "-" \
        "UNKNOWN - Installationsverzeichnis zu Projekt ${proj[i]} nicht gefunden (mailcow.conf?)" > "$out"
      # Container laufen ja - die lassen sich auch ohne Verzeichnis pruefen.
      pool_run "$(printf '%s/%03d-01.out' "$tmpdir" "$i")" \
               check_containers "$svc" "${proj[i]}" ""
      continue
    fi
    emit 0 "$svc Status" "-" \
      "OK - mailcow ${version:-unbekannt} (Branch ${branch:-?}) in $d, Projekt ${proj[i]}" > "$out"

    pool_run "$(printf '%s/%03d-01.out' "$tmpdir" "$i")" \
             check_containers "$svc" "${proj[i]}" "$d"
    pool_run "$(printf '%s/%03d-02.out' "$tmpdir" "$i")" \
             check_update     "$svc" "$d"
    pool_run "$(printf '%s/%03d-03.out' "$tmpdir" "$i")" \
             check_certs      "$svc" "$d" "${proj[i]}" "$host"
    pool_run "$(printf '%s/%03d-04.out' "$tmpdir" "$i")" \
             check_queue      "$svc" "${proj[i]}"
    pool_run "$(printf '%s/%03d-05.out' "$tmpdir" "$i")" \
             check_rspamd     "$svc" "${proj[i]}"
    [[ "$MAILCOW_DB_CHECKS" == "1" ]] && \
      pool_run "$(printf '%s/%03d-06.out' "$tmpdir" "$i")" \
               check_mailboxes "$svc" "$d" "${proj[i]}"
  done
  wait

  for n in "$tmpdir"/*.out; do
    [[ -s "$n" ]] && cat "$n"
  done
  return 0
}

########################### Main ###########################################

collect

exit 0