#!/usr/bin/env bash ######################################################################################### #### mailcow-smoke-tests.sh — Verify Mailcow instance health after upgrades #### #### Zero external dependencies. Requires: bash 4+, curl, openssl, nc (netcat) #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.0 #### #### #### #### Usage: #### #### export MAILCOW_URL="https://mail.example.com" #### #### export MAILCOW_API_KEY="your-api-key" #### #### ./mailcow-smoke-tests.sh #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── MAILCOW_URL="${MAILCOW_URL:-}" MAILCOW_API_KEY="${MAILCOW_API_KEY:-}" MAILCOW_DIR="${MAILCOW_DIR:-/opt/mailcow-dockerized}" CURL_TIMEOUT="${CURL_TIMEOUT:-10}" CURL_INSECURE="${CURL_INSECURE:-false}" SKIP_SMTP="${SKIP_SMTP:-false}" SKIP_IMAP="${SKIP_IMAP:-false}" SKIP_CLAMD="${SKIP_CLAMD:-false}" OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" # text, tap, junit JUNIT_FILE="${JUNIT_FILE:-smoke-results.xml}" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" # ── State ───────────────────────────────────────────────────────────── PASS=0 FAIL=0 SKIP=0 TOTAL=0 RESULTS=() START_TIME="" # ── Colors ──────────────────────────────────────────────────────────── setup_colors() { if [[ "$COLOR" == "never" ]]; then RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" return fi if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' BOLD='\033[1m' RESET='\033[0m' else RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET="" fi } # ── Logging ─────────────────────────────────────────────────────────── log() { echo -e "${BLUE}[INFO]${RESET} $*"; } warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; } err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${BLUE}[DEBUG]${RESET} $*"; fi; } # ── Test Result Recording ───────────────────────────────────────────── record_pass() { local name="$1" local detail="${2:-}" ((PASS++)) || true ((TOTAL++)) || true RESULTS+=("PASS|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name}" else echo -e " ${GREEN}✓${RESET} ${name}${detail:+ — ${detail}}" fi } record_fail() { local name="$1" local detail="${2:-}" ((FAIL++)) || true ((TOTAL++)) || true RESULTS+=("FAIL|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "not ok ${TOTAL} - ${name}" [[ -n "$detail" ]] && echo " # ${detail}" else echo -e " ${RED}✗${RESET} ${name}${detail:+ — ${detail}}" fi } record_skip() { local name="$1" local reason="${2:-}" ((SKIP++)) || true ((TOTAL++)) || true RESULTS+=("SKIP|${name}|${reason}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name} # SKIP ${reason}" else echo -e " ${YELLOW}⊘${RESET} ${name}${reason:+ — ${reason}}" fi } # ── curl wrapper ────────────────────────────────────────────────────── api_curl() { local endpoint="$1" shift local curl_opts=(-s -S --max-time "$CURL_TIMEOUT") [[ "$CURL_INSECURE" == "true" ]] && curl_opts+=(-k) curl_opts+=(-H "X-API-Key: ${MAILCOW_API_KEY}") curl_opts+=(-H "Content-Type: application/json") local url="${MAILCOW_URL}${endpoint}" verbose "curl GET ${url}" curl "${curl_opts[@]}" "$@" "$url" 2>/dev/null } api_curl_status() { local endpoint="$1" shift local curl_opts=(-s -S -o /dev/null -w "%{http_code}" --max-time "$CURL_TIMEOUT") [[ "$CURL_INSECURE" == "true" ]] && curl_opts+=(-k) curl_opts+=(-H "X-API-Key: ${MAILCOW_API_KEY}") curl_opts+=(-H "Content-Type: application/json") local url="${MAILCOW_URL}${endpoint}" curl "${curl_opts[@]}" "$@" "$url" 2>/dev/null } # ── JSON parsing (no jq required) ──────────────────────────────────── json_value() { local key="$1" local json="$2" echo "$json" | { grep -oP "\"${key}\"\s*:\s*\"?\K[^\",}]+" || true; } | head -1 } json_value_string() { local key="$1" local json="$2" echo "$json" | { grep -oP "\"${key}\"\s*:\s*\"\K[^\"]*" || true; } | head -1 } # ── Host extraction ─────────────────────────────────────────────────── get_mailcow_host() { echo "$MAILCOW_URL" | sed 's|https\?://||' | cut -d/ -f1 | cut -d: -f1 } # ── Port check ──────────────────────────────────────────────────────── check_port() { local host="$1" local port="$2" nc -z -w 3 "$host" "$port" >/dev/null 2>&1 } # ══════════════════════════════════════════════════════════════════════ # TEST SUITES # ══════════════════════════════════════════════════════════════════════ # ── 1. Connectivity ────────────────────────────────────────────────── test_connectivity() { echo "" echo -e "${BOLD}Connectivity${RESET}" # 1a. Mailcow UI reachable local curl_opts=(-s -o /dev/null -w "%{http_code}" --max-time "$CURL_TIMEOUT") [[ "$CURL_INSECURE" == "true" ]] && curl_opts+=(-k) local http_code http_code=$(curl "${curl_opts[@]}" "${MAILCOW_URL}/" 2>/dev/null) || http_code="000" if [[ "$http_code" == "200" || "$http_code" == "301" || "$http_code" == "302" ]]; then record_pass "Mailcow UI reachable" "HTTP ${http_code}" else record_fail "Mailcow UI reachable" "HTTP ${http_code}" fi # 1b. TLS certificate validity if [[ "$MAILCOW_URL" == https://* ]]; then local host host=$(get_mailcow_host) local port port=$(echo "$MAILCOW_URL" | grep -oP ':\K[0-9]+$' || echo "443") local expiry expiry=$(echo | openssl s_client -servername "$host" -connect "${host}:${port}" 2>/dev/null | \ openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2) || expiry="" if [[ -n "$expiry" ]]; then local expiry_epoch expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null) || expiry_epoch=0 local now_epoch now_epoch=$(date +%s) local days_left=$(( (expiry_epoch - now_epoch) / 86400 )) if [[ $days_left -gt 30 ]]; then record_pass "TLS certificate valid" "${days_left} days remaining" elif [[ $days_left -gt 0 ]]; then record_pass "TLS certificate valid" "${days_left} days remaining (renew soon)" else record_fail "TLS certificate valid" "expired or expiring in ${days_left} days" fi else record_skip "TLS certificate check" "could not retrieve certificate" fi else record_skip "TLS certificate check" "not using HTTPS" fi } # ── 2. API ──────────────────────────────────────────────────────────── test_api() { echo "" echo -e "${BOLD}API${RESET}" # 2a. API authentication — version endpoint local version_json version_json=$(api_curl "/api/v1/get/status/version" 2>/dev/null) || version_json="" local mc_version mc_version=$(json_value "version" "$version_json") if [[ -n "$mc_version" && "$mc_version" != "null" ]]; then record_pass "API authentication" "Mailcow ${mc_version}" elif [[ "$version_json" == *"error"* || "$version_json" == *"unauthorized"* ]]; then record_fail "API authentication" "API key rejected" else record_fail "API authentication" "no version returned" fi # 2b. Container status local containers_json containers_json=$(api_curl "/api/v1/get/status/containers" 2>/dev/null) || containers_json="" if [[ -n "$containers_json" && "$containers_json" != *"error"* ]]; then local running_count=0 local stopped_count=0 local container_names="" # Count running vs non-running containers running_count=$(echo "$containers_json" | { grep -oP '"state"\s*:\s*"running"' || true; } | wc -l) stopped_count=$(echo "$containers_json" | { grep -oP '"state"\s*:\s*"(?!running)[^"]*"' || true; } | wc -l) if [[ $running_count -gt 0 && $stopped_count -eq 0 ]]; then record_pass "Container status" "all ${running_count} containers running" elif [[ $running_count -gt 0 ]]; then record_fail "Container status" "${running_count} running, ${stopped_count} not running" else record_fail "Container status" "no running containers found" fi else record_fail "Container status" "could not query container status" fi # 2c. List domains local domains_status domains_status=$(api_curl_status "/api/v1/get/domain/all") if [[ "$domains_status" == "200" ]]; then local domains_json domains_json=$(api_curl "/api/v1/get/domain/all" 2>/dev/null) || domains_json="" local domain_count domain_count=$(echo "$domains_json" | { grep -oP '"domain_name"\s*:' || true; } | wc -l) record_pass "List domains" "${domain_count} domain(s) found" else record_fail "List domains" "HTTP ${domains_status}" fi # 2d. List mailboxes local mailboxes_status mailboxes_status=$(api_curl_status "/api/v1/get/mailbox/all") if [[ "$mailboxes_status" == "200" ]]; then local mailboxes_json mailboxes_json=$(api_curl "/api/v1/get/mailbox/all" 2>/dev/null) || mailboxes_json="" local mailbox_count mailbox_count=$(echo "$mailboxes_json" | { grep -oP '"username"\s*:' || true; } | wc -l) record_pass "List mailboxes" "${mailbox_count} mailbox(es) found" else record_fail "List mailboxes" "HTTP ${mailboxes_status}" fi } # ── 3. Mail Services ───────────────────────────────────────────────── test_mail_services() { echo "" echo -e "${BOLD}Mail Services${RESET}" local host host=$(get_mailcow_host) # 3a. SMTP if [[ "$SKIP_SMTP" == "true" ]]; then record_skip "SMTP port 25" "SKIP_SMTP=true" record_skip "SMTP port 587" "SKIP_SMTP=true" else if check_port "$host" 25; then record_pass "SMTP port 25" "accepting connections" else record_fail "SMTP port 25" "not reachable" fi if check_port "$host" 587; then record_pass "SMTP port 587 (submission)" "accepting connections" else record_fail "SMTP port 587 (submission)" "not reachable" fi fi # 3b. IMAP if [[ "$SKIP_IMAP" == "true" ]]; then record_skip "IMAP port 143" "SKIP_IMAP=true" record_skip "IMAP port 993" "SKIP_IMAP=true" else if check_port "$host" 143; then record_pass "IMAP port 143" "accepting connections" else record_fail "IMAP port 143" "not reachable" fi if check_port "$host" 993; then record_pass "IMAP port 993 (IMAPS)" "accepting connections" else record_fail "IMAP port 993 (IMAPS)" "not reachable" fi fi # 3c. POP3 (optional — skip if not available) if check_port "$host" 110; then record_pass "POP3 port 110" "accepting connections" else record_skip "POP3 port 110" "not reachable (may be disabled)" fi if check_port "$host" 995; then record_pass "POP3 port 995 (POP3S)" "accepting connections" else record_skip "POP3 port 995 (POP3S)" "not reachable (may be disabled)" fi } # ── 4. Spam Filter ─────────────────────────────────────────────────── test_spam_filter() { echo "" echo -e "${BOLD}Spam Filter${RESET}" # 4a. rspamd — check container status from API local containers_json containers_json=$(api_curl "/api/v1/get/status/containers" 2>/dev/null) || containers_json="" if [[ -n "$containers_json" ]]; then local rspamd_state rspamd_state=$(echo "$containers_json" | { grep -oP '"rspamd-mailcow[^}]*"state"\s*:\s*"\K[^"]*' || true; } | head -1) if [[ -z "$rspamd_state" ]]; then # Try alternate pattern — look for rspamd in container names rspamd_state=$(echo "$containers_json" | { grep -B5 '"rspamd' || true; } | { grep -oP '"state"\s*:\s*"\K[^"]*' || true; } | head -1) fi if [[ "$rspamd_state" == "running" ]]; then record_pass "rspamd running" "spam filter operational" elif [[ -n "$rspamd_state" ]]; then record_fail "rspamd running" "state: ${rspamd_state}" else record_skip "rspamd status" "could not determine rspamd container state" fi else record_fail "rspamd status" "could not query containers" fi # 4b. ClamAV if [[ "$SKIP_CLAMD" == "true" ]]; then record_skip "ClamAV status" "SKIP_CLAMD=true" elif [[ -n "$containers_json" ]]; then local clamd_state clamd_state=$(echo "$containers_json" | { grep -oP '"clamd-mailcow[^}]*"state"\s*:\s*"\K[^"]*' || true; } | head -1) if [[ -z "$clamd_state" ]]; then clamd_state=$(echo "$containers_json" | { grep -B5 '"clamd' || true; } | { grep -oP '"state"\s*:\s*"\K[^"]*' || true; } | head -1) fi if [[ "$clamd_state" == "running" ]]; then record_pass "ClamAV running" "antivirus operational" elif [[ -n "$clamd_state" ]]; then record_fail "ClamAV running" "state: ${clamd_state}" else record_skip "ClamAV status" "ClamAV container not found (may be disabled)" fi else record_skip "ClamAV status" "could not query containers" fi } # ── 5. Webmail ──────────────────────────────────────────────────────── test_webmail() { echo "" echo -e "${BOLD}Webmail${RESET}" local curl_opts=(-s -o /dev/null -w "%{http_code}" --max-time "$CURL_TIMEOUT") [[ "$CURL_INSECURE" == "true" ]] && curl_opts+=(-k) local sogo_code sogo_code=$(curl "${curl_opts[@]}" "${MAILCOW_URL}/SOGo" 2>/dev/null) || sogo_code="000" if [[ "$sogo_code" == "200" || "$sogo_code" == "301" || "$sogo_code" == "302" ]]; then record_pass "SOGo reachable" "HTTP ${sogo_code}" elif [[ "$sogo_code" == "000" ]]; then record_fail "SOGo reachable" "connection failed" else record_fail "SOGo reachable" "HTTP ${sogo_code}" fi } # ── 6. Quarantine ──────────────────────────────────────────────────── test_quarantine() { echo "" echo -e "${BOLD}Quarantine${RESET}" local quarantine_json quarantine_json=$(api_curl "/api/v1/get/quarantine/all" 2>/dev/null) || quarantine_json="" local quarantine_status quarantine_status=$(api_curl_status "/api/v1/get/quarantine/all") if [[ "$quarantine_status" == "200" ]]; then local q_count q_count=$(echo "$quarantine_json" | { grep -oP '"id"\s*:' || true; } | wc -l) record_pass "Quarantine endpoint" "${q_count} item(s) in quarantine" else record_fail "Quarantine endpoint" "HTTP ${quarantine_status}" fi } # ══════════════════════════════════════════════════════════════════════ # OUTPUT # ══════════════════════════════════════════════════════════════════════ print_summary() { local end_time end_time=$(date +%s) local duration=$(( end_time - START_TIME )) echo "" echo -e "${BOLD}────────────────────────────────────────${RESET}" echo -e "${BOLD}Summary${RESET} ${MAILCOW_URL}" echo -e " ${GREEN}${PASS} passed${RESET} ${RED}${FAIL} failed${RESET} ${YELLOW}${SKIP} skipped${RESET} (${duration}s)" echo -e "${BOLD}────────────────────────────────────────${RESET}" if [[ $FAIL -eq 0 ]]; then echo -e "${GREEN}${BOLD}All tests passed.${RESET}" else echo -e "${RED}${BOLD}${FAIL} test(s) failed.${RESET}" fi } print_tap_header() { echo "TAP version 13" } print_tap_footer() { echo "1..${TOTAL}" echo "# pass ${PASS}" echo "# fail ${FAIL}" echo "# skip ${SKIP}" } write_junit() { local end_time end_time=$(date +%s) local duration=$(( end_time - START_TIME )) cat > "$JUNIT_FILE" < JUNIT_EOF for result in "${RESULTS[@]}"; do local status name detail status=$(echo "$result" | cut -d'|' -f1) name=$(echo "$result" | cut -d'|' -f2) detail=$(echo "$result" | cut -d'|' -f3) # XML-escape the values name=$(echo "$name" | sed 's/&/\&/g; s//\>/g; s/"/\"/g') detail=$(echo "$detail" | sed 's/&/\&/g; s//\>/g; s/"/\"/g') case "$status" in PASS) echo " " >> "$JUNIT_FILE" [[ -n "$detail" ]] && echo " ${detail}" >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; FAIL) echo " " >> "$JUNIT_FILE" echo " FAILED: ${name} — ${detail}" >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; SKIP) echo " " >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; esac done echo " " >> "$JUNIT_FILE" echo "" >> "$JUNIT_FILE" log "JUnit report written to ${JUNIT_FILE}" } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ usage() { cat <