#!/usr/bin/env bash ##################################################################################### #### mail-smoke-tests.sh — Verify mail infrastructure is healthy #### #### Checks Postfix, Dovecot, SMTP/IMAP, STARTTLS, SPF/DKIM/DMARC, queues. #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version: 1.0 #### #### #### #### Usage: MAIL_DOMAIN=example.com ./mail-smoke-tests.sh #### #### SMTP_HOST=mail.example.com IMAP_PORT=993 ./mail-smoke-tests.sh #### #### #### #### See --help for all options. #### ##################################################################################### set -euo pipefail # ── Defaults ────────────────────────────────────────────────────────── MAIL_DOMAIN="${MAIL_DOMAIN:-}" SMTP_HOST="${SMTP_HOST:-localhost}" SMTP_PORT="${SMTP_PORT:-25}" SUBMISSION_PORT="${SUBMISSION_PORT:-587}" IMAP_HOST="${IMAP_HOST:-localhost}" IMAP_PORT="${IMAP_PORT:-993}" IMAP_USER="${IMAP_USER:-}" IMAP_PASS="${IMAP_PASS:-}" DKIM_SELECTOR="${DKIM_SELECTOR:-default}" MAX_QUEUE_SIZE="${MAX_QUEUE_SIZE:-50}" RELAY_TEST_ADDR="${RELAY_TEST_ADDR:-test@example.com}" SSL_WARN_DAYS="${SSL_WARN_DAYS:-30}" SKIP_DNS="${SKIP_DNS:-false}" SKIP_IMAP="${SKIP_IMAP:-false}" SKIP_RELAY="${SKIP_RELAY:-false}" OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" COLOR="${COLOR:-auto}" VERBOSE="${VERBOSE:-false}" # ── 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" detail="${2:-}" ((PASS++)) || true; ((TOTAL++)) || true RESULTS+=("PASS|${name}|${detail}") if [[ "$OUTPUT_FORMAT" == "tap" ]]; then echo "ok ${TOTAL} - ${name}${detail:+ (${detail})}" else echo -e " ${GREEN}✓${RESET} ${name}${detail:+ — ${detail}}"; fi } record_fail() { local name="$1" 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" 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 } # ── Helpers ─────────────────────────────────────────────────────────── has_cmd() { command -v "$1" >/dev/null 2>&1; } section() { if [[ "$OUTPUT_FORMAT" != "tap" ]]; then echo ""; echo -e "${BOLD}$1${RESET}"; fi } # ── Cleanup ─────────────────────────────────────────────────────────── # shellcheck disable=SC2317 cleanup() { verbose "Cleaning up temporary files..." } trap cleanup EXIT # ══════════════════════════════════════════════════════════════════════ # TEST FUNCTIONS # ══════════════════════════════════════════════════════════════════════ # ── 1. Postfix running ─────────────────────────────────────────────── test_postfix_running() { if has_cmd systemctl; then if systemctl is-active --quiet postfix 2>/dev/null; then record_pass "Postfix running" "systemctl active" else record_fail "Postfix running" "systemctl inactive" fi elif pgrep -x master >/dev/null 2>&1; then record_pass "Postfix running" "master process found" else record_fail "Postfix running" "master process not found" fi } # ── 2. Dovecot running ────────────────────────────────────────────── test_dovecot_running() { if [[ "$SKIP_IMAP" == "true" ]]; then record_skip "Dovecot running" "SKIP_IMAP=true"; return; fi if has_cmd systemctl; then if systemctl is-active --quiet dovecot 2>/dev/null; then record_pass "Dovecot running" "systemctl active" else record_fail "Dovecot running" "systemctl inactive" fi elif pgrep -x dovecot >/dev/null 2>&1; then record_pass "Dovecot running" "dovecot process found" else record_fail "Dovecot running" "dovecot process not found" fi } # ── 3. SMTP connect ───────────────────────────────────────────────── test_smtp_connect() { local output exit_code=0 verbose "Testing SMTP connection to ${SMTP_HOST}:${SMTP_PORT}" output=$(timeout 10 bash -c "echo QUIT | nc -w5 ${SMTP_HOST} ${SMTP_PORT}" 2>&1) || exit_code=$? if [[ $exit_code -eq 0 ]] && echo "$output" | grep -q "220"; then local banner banner=$(echo "$output" | grep "220" | head -1 | tr -d '\r') record_pass "SMTP connect (${SMTP_HOST}:${SMTP_PORT})" "${banner:0:60}" else record_fail "SMTP connect (${SMTP_HOST}:${SMTP_PORT})" "no 220 banner received" fi } # ── 4. SMTP EHLO ──────────────────────────────────────────────────── test_smtp_ehlo() { local ehlo_name; ehlo_name=$(hostname -f 2>/dev/null || echo "localhost") local output exit_code=0 verbose "Sending EHLO to ${SMTP_HOST}:${SMTP_PORT}" output=$(timeout 10 bash -c "{ echo 'EHLO ${ehlo_name}'; sleep 1; echo 'QUIT'; } | nc -w5 ${SMTP_HOST} ${SMTP_PORT}" 2>&1) || exit_code=$? if echo "$output" | grep -q "^250"; then local caps caps=$(echo "$output" | grep -c "^250" 2>/dev/null) || caps=0 record_pass "SMTP EHLO" "${caps} capabilities advertised" else record_fail "SMTP EHLO" "no 250 response to EHLO" fi } # ── 5. IMAP connect ───────────────────────────────────────────────── test_imap_connect() { if [[ "$SKIP_IMAP" == "true" ]]; then record_skip "IMAP connect" "SKIP_IMAP=true"; return; fi local output exit_code=0 verbose "Testing IMAP connection to ${IMAP_HOST}:${IMAP_PORT}" if [[ "$IMAP_PORT" == "993" ]]; then output=$(timeout 10 bash -c "echo 'a1 LOGOUT' | openssl s_client -quiet -connect ${IMAP_HOST}:${IMAP_PORT} 2>/dev/null" 2>&1) || exit_code=$? else output=$(timeout 10 bash -c "echo 'a1 LOGOUT' | nc -w5 ${IMAP_HOST} ${IMAP_PORT}" 2>&1) || exit_code=$? fi if echo "$output" | grep -qi "OK\|IMAP\|ready"; then record_pass "IMAP connect (${IMAP_HOST}:${IMAP_PORT})" "server responded" else record_fail "IMAP connect (${IMAP_HOST}:${IMAP_PORT})" "no IMAP banner received" fi } # ── 6. IMAP login ─────────────────────────────────────────────────── test_imap_login() { if [[ "$SKIP_IMAP" == "true" ]]; then record_skip "IMAP login" "SKIP_IMAP=true"; return; fi if [[ -z "$IMAP_USER" || -z "$IMAP_PASS" ]]; then record_skip "IMAP login" "IMAP_USER/IMAP_PASS not set" return fi local output exit_code=0 verbose "Attempting IMAP login as ${IMAP_USER}" if [[ "$IMAP_PORT" == "993" ]]; then output=$(timeout 10 bash -c "{ sleep 1; echo 'a1 LOGIN ${IMAP_USER} ${IMAP_PASS}'; sleep 1; echo 'a2 LOGOUT'; } | openssl s_client -quiet -connect ${IMAP_HOST}:${IMAP_PORT} 2>/dev/null" 2>&1) || exit_code=$? else output=$(timeout 10 bash -c "{ sleep 1; echo 'a1 LOGIN ${IMAP_USER} ${IMAP_PASS}'; sleep 1; echo 'a2 LOGOUT'; } | nc -w5 ${IMAP_HOST} ${IMAP_PORT}" 2>&1) || exit_code=$? fi if echo "$output" | grep -q "a1 OK"; then record_pass "IMAP login" "${IMAP_USER}" else record_fail "IMAP login" "login failed for ${IMAP_USER}" fi } # ── 7. SPF record ─────────────────────────────────────────────────── test_spf_record() { if [[ "$SKIP_DNS" == "true" ]]; then record_skip "SPF record" "SKIP_DNS=true"; return; fi if [[ -z "$MAIL_DOMAIN" ]]; then record_skip "SPF record" "MAIL_DOMAIN not set"; return; fi if ! has_cmd dig; then record_skip "SPF record" "dig not installed"; return; fi local output output=$(dig +short TXT "${MAIL_DOMAIN}" 2>/dev/null) || true if echo "$output" | grep -qi "v=spf1"; then local spf spf=$(echo "$output" | grep -i "v=spf1" | head -1 | tr -d '"') record_pass "SPF record (${MAIL_DOMAIN})" "${spf:0:60}" else record_fail "SPF record (${MAIL_DOMAIN})" "no v=spf1 TXT record found" fi } # ── 8. DKIM record ────────────────────────────────────────────────── test_dkim_record() { if [[ "$SKIP_DNS" == "true" ]]; then record_skip "DKIM record" "SKIP_DNS=true"; return; fi if [[ -z "$MAIL_DOMAIN" ]]; then record_skip "DKIM record" "MAIL_DOMAIN not set"; return; fi if ! has_cmd dig; then record_skip "DKIM record" "dig not installed"; return; fi local selector_domain="${DKIM_SELECTOR}._domainkey.${MAIL_DOMAIN}" local output output=$(dig +short TXT "${selector_domain}" 2>/dev/null) || true if [[ -n "$output" ]] && echo "$output" | grep -qi "v=DKIM1\|p="; then record_pass "DKIM record (${selector_domain})" "key present" else record_fail "DKIM record (${selector_domain})" "no DKIM TXT record found" fi } # ── 9. DMARC record ───────────────────────────────────────────────── test_dmarc_record() { if [[ "$SKIP_DNS" == "true" ]]; then record_skip "DMARC record" "SKIP_DNS=true"; return; fi if [[ -z "$MAIL_DOMAIN" ]]; then record_skip "DMARC record" "MAIL_DOMAIN not set"; return; fi if ! has_cmd dig; then record_skip "DMARC record" "dig not installed"; return; fi local dmarc_domain="_dmarc.${MAIL_DOMAIN}" local output output=$(dig +short TXT "${dmarc_domain}" 2>/dev/null) || true if echo "$output" | grep -qi "v=DMARC1"; then local dmarc dmarc=$(echo "$output" | grep -i "v=DMARC1" | head -1 | tr -d '"') record_pass "DMARC record (${dmarc_domain})" "${dmarc:0:60}" else record_fail "DMARC record (${dmarc_domain})" "no v=DMARC1 TXT record found" fi } # ── 10. Mail queue size ───────────────────────────────────────────── test_mail_queue() { if ! has_cmd postqueue; then record_skip "Mail queue size" "postqueue not available"; return; fi local queue_output queue_count queue_output=$(postqueue -p 2>/dev/null) || true if echo "$queue_output" | grep -q "Mail queue is empty"; then record_pass "Mail queue size" "queue empty" return fi queue_count=$(echo "$queue_output" | grep -c "^[A-F0-9]" 2>/dev/null) || queue_count=0 if [[ "$queue_count" -le "$MAX_QUEUE_SIZE" ]]; then record_pass "Mail queue size" "${queue_count} messages (<= ${MAX_QUEUE_SIZE})" else record_fail "Mail queue size" "${queue_count} messages (> ${MAX_QUEUE_SIZE})" fi } # ── 11. Open relay test ───────────────────────────────────────────── test_open_relay() { if [[ "$SKIP_RELAY" == "true" ]]; then record_skip "Open relay test" "SKIP_RELAY=true"; return; fi local ehlo_name; ehlo_name=$(hostname -f 2>/dev/null || echo "localhost") local output exit_code=0 verbose "Testing open relay via ${SMTP_HOST}:${SMTP_PORT}" output=$(timeout 10 bash -c "{ sleep 1 echo 'EHLO ${ehlo_name}' sleep 1 echo 'MAIL FROM:' sleep 1 echo 'RCPT TO:<${RELAY_TEST_ADDR}>' sleep 1 echo 'QUIT' } | nc -w5 ${SMTP_HOST} ${SMTP_PORT}" 2>&1) || exit_code=$? if echo "$output" | grep -qE "^(454|550|553|554|521|503)"; then record_pass "Open relay test" "relay correctly refused" elif echo "$output" | grep -q "^250.*Ok\|^250.*Accepted"; then record_fail "Open relay test" "server accepted relay — possible open relay" else record_pass "Open relay test" "relay not accepted" fi } # ── 12. SSL/TLS on SMTP ───────────────────────────────────────────── test_smtp_tls() { if ! has_cmd openssl; then record_skip "SMTP STARTTLS" "openssl not installed"; return; fi local output exit_code=0 verbose "Testing STARTTLS on ${SMTP_HOST}:${SUBMISSION_PORT}" output=$(timeout 10 openssl s_client -starttls smtp -connect "${SMTP_HOST}:${SUBMISSION_PORT}" -servername "${SMTP_HOST}" &1) || exit_code=$? if echo "$output" | grep -qi "connected\|verify return\|SSL handshake"; then local expiry_line expiry_date days_left expiry_line=$(echo "$output" | grep -i "notAfter" | head -1) || true if [[ -n "$expiry_line" ]]; then expiry_date=$(echo "$expiry_line" | sed 's/.*notAfter=//') || true if [[ -n "$expiry_date" ]]; then local expiry_epoch now_epoch expiry_epoch=$(date -d "$expiry_date" +%s 2>/dev/null) || expiry_epoch=0 now_epoch=$(date +%s) if [[ "$expiry_epoch" -gt 0 ]]; then days_left=$(( (expiry_epoch - now_epoch) / 86400 )) if [[ "$days_left" -lt "$SSL_WARN_DAYS" ]]; then record_fail "SMTP STARTTLS (${SMTP_HOST}:${SUBMISSION_PORT})" "cert expires in ${days_left}d (< ${SSL_WARN_DAYS}d)" return fi record_pass "SMTP STARTTLS (${SMTP_HOST}:${SUBMISSION_PORT})" "cert valid, ${days_left}d remaining" return fi fi fi record_pass "SMTP STARTTLS (${SMTP_HOST}:${SUBMISSION_PORT})" "TLS handshake OK" else record_fail "SMTP STARTTLS (${SMTP_HOST}:${SUBMISSION_PORT})" "STARTTLS handshake failed" fi } # ── 13. SSL/TLS on IMAP ───────────────────────────────────────────── test_imap_tls() { if [[ "$SKIP_IMAP" == "true" ]]; then record_skip "IMAP TLS" "SKIP_IMAP=true"; return; fi if ! has_cmd openssl; then record_skip "IMAP TLS" "openssl not installed"; return; fi local output exit_code=0 verbose "Testing TLS on ${IMAP_HOST}:${IMAP_PORT}" output=$(timeout 10 openssl s_client -connect "${IMAP_HOST}:${IMAP_PORT}" -servername "${IMAP_HOST}" &1) || exit_code=$? if echo "$output" | grep -qi "connected\|verify return\|SSL handshake"; then local expiry_line expiry_date days_left expiry_line=$(echo "$output" | grep -i "notAfter" | head -1) || true if [[ -n "$expiry_line" ]]; then expiry_date=$(echo "$expiry_line" | sed 's/.*notAfter=//') || true if [[ -n "$expiry_date" ]]; then local expiry_epoch now_epoch expiry_epoch=$(date -d "$expiry_date" +%s 2>/dev/null) || expiry_epoch=0 now_epoch=$(date +%s) if [[ "$expiry_epoch" -gt 0 ]]; then days_left=$(( (expiry_epoch - now_epoch) / 86400 )) if [[ "$days_left" -lt "$SSL_WARN_DAYS" ]]; then record_fail "IMAP TLS (${IMAP_HOST}:${IMAP_PORT})" "cert expires in ${days_left}d (< ${SSL_WARN_DAYS}d)" return fi record_pass "IMAP TLS (${IMAP_HOST}:${IMAP_PORT})" "cert valid, ${days_left}d remaining" return fi fi fi record_pass "IMAP TLS (${IMAP_HOST}:${IMAP_PORT})" "TLS handshake OK" else record_fail "IMAP TLS (${IMAP_HOST}:${IMAP_PORT})" "TLS handshake failed" fi } # ── 14. Deferred mail check ───────────────────────────────────────── test_deferred_queue() { if ! has_cmd postqueue; then record_skip "Deferred mail check" "postqueue not available"; return; fi local deferred_count=0 if [[ -d /var/spool/postfix/deferred ]]; then deferred_count=$(find /var/spool/postfix/deferred -type f 2>/dev/null | wc -l) || deferred_count=0 fi if [[ "$deferred_count" -eq 0 ]]; then record_pass "Deferred mail check" "no deferred messages" else record_fail "Deferred mail check" "${deferred_count} deferred message(s)" fi } # ══════════════════════════════════════════════════════════════════════ # OUTPUT # ══════════════════════════════════════════════════════════════════════ print_tap_header() { echo "TAP version 13" } print_tap_footer() { echo "1..${TOTAL}" echo "# pass ${PASS}" echo "# fail ${FAIL}" echo "# skip ${SKIP}" } 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} Mail Smoke Tests" 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 } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ usage() { cat <