diff --git a/mail-status.sh b/mail-status.sh index 757c17e..4c55db5 100644 --- a/mail-status.sh +++ b/mail-status.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Postfix Mail Summary Tool - PRODUCTION READY EDITION (v4.0.8) +# Postfix Mail Summary Tool - PRODUCTION READY EDITION (v4.0.9) set -euo pipefail IFS=$'\n\t' @@ -42,39 +42,28 @@ OUTPUT & RUN MODE OPTIONS: MISC OPTIONS: --log-dir PATH Custom path to log files (default: /var/log). -h, --help Display this detailed help message. - -============================================================================== -PRACTICAL EXAMPLES (Copy & Paste): - - 1. Live monitor all incoming REJECTED emails in real-time: - mail-stats -w -s reject - - 2. Show statistical summary of all emails sent to 'gmail.com' today: - mail-stats -t gmail.com -S - - 3. Export the last 100 emails from a specific IP to a CSV file: - mail-stats -a 192.168.1.100 -l 100 -c > ip_report.csv - - 4. Find all large emails (over 10MB) across all old logs (ignoring date): - mail-stats -d "" --min-size 10485760 - - 5. Find what happened around 14:30 today for a specific sender domain: - mail-stats --time "14:30" -f "@mydomain.sk" -l 50 - - 6. Use REGEX to find senders starting with 'info' or 'admin': - mail-stats -R -f "^(info|admin)@" -============================================================================== EOF exit 0 } # ============================================================================== -# 2. ARGUMENT PARSING +# 2. DEPENDENCY CHECK (FEATURE 2) +# ============================================================================== +for cmd in awk grep tail mktemp flock sort; do + if ! command -v "$cmd" &> /dev/null; then + echo "Error: Required command '$cmd' is not installed." >&2 + exit 1 + fi +done + +# ============================================================================== +# 3. ARGUMENT PARSING # ============================================================================== LIMIT=10; MAX_LIMIT=1000 FROM_FILTER=""; TO_FILTER=""; ID_FILTER=""; DATE_FILTER=""; IP_FILTER="" STATUS_FILTER=""; TIME_FILTER=""; LOG_DIR="/var/log"; MIN_SIZE=0 DATE_SET=0; CSV_MODE=0; SUMMARY_MODE=0; WATCH_MODE=0; REGEX_MODE=0 +CURRENT_MONTH=$(date +%m) # Required for New Year's Eve Bug fix check_arg() { if [[ "$2" -lt 1 ]]; then @@ -128,20 +117,18 @@ if [[ "$CSV_MODE" -eq 1 ]]; then fi export CSV_MODE -# SECURITY SAFEGUARD: Prevent ReDoS in Watch Mode if [[ "$WATCH_MODE" -eq 1 && "$REGEX_MODE" -eq 1 ]]; then echo "Security Warning: Regular Expressions (-R) are disabled in Watch Mode (-w) to prevent CPU locking (ReDoS)." >&2 exit 1 fi -# SECURITY SAFEGUARD: Path Validation for --log-dir (Directory Traversal Protection) if [[ "$LOG_DIR" != "/var/log" && "$LOG_DIR" != "/var/log/mail" ]]; then echo "Error: Unsafe log directory specified ($LOG_DIR). Only /var/log or /var/log/mail are allowed." >&2 exit 1 fi # ============================================================================== -# 3. RESOURCE LIMITS & LOCKFILES (PRODUCTION SAFEGUARDS) +# 4. RESOURCE LIMITS & LOCKFILES # ============================================================================== if [[ "$WATCH_MODE" -eq 1 ]]; then LOCKFILE="${TMPDIR:-/tmp}/postfix_watch_${UID}.lock" @@ -163,7 +150,7 @@ TMP_DATA=$(mktemp "${TMPDIR:-/tmp}/postfix_log.XXXXXX") trap 'rm -f "$TMP_DATA" 2>/dev/null' EXIT # ============================================================================== -# 4. LOG FILE DISCOVERY & PROCESSING ENGINE +# 5. LOG FILE DISCOVERY & PROCESSING ENGINE (FEATURE 1: SYSTEMD SUPPORT) # ============================================================================== if [ ! -d "$LOG_DIR" ]; then echo "Error: Directory $LOG_DIR does not exist." >&2; exit 1 @@ -175,29 +162,41 @@ get_log_stream() { if [[ -f "$LOG_DIR/maillog" ]]; then ACTIVE_LOG="$LOG_DIR/maillog"; fi if [[ -z "$ACTIVE_LOG" && -f "$LOG_DIR/mail.log" ]]; then ACTIVE_LOG="$LOG_DIR/mail.log"; fi - if [[ -z "$ACTIVE_LOG" ]]; then - echo "Error: No active log found to watch." >&2; exit 1 + if [[ -n "$ACTIVE_LOG" ]]; then + echo "Starting Live Watch Mode on: $ACTIVE_LOG (Ctrl+C to stop)..." >&2 + tail -n 100 -F "$ACTIVE_LOG" + elif command -v journalctl &> /dev/null; then + echo "Starting Live Watch Mode via systemd journal (Ctrl+C to stop)..." >&2 + journalctl SYSLOG_FACILITY=2 -n 100 -f + else + echo "Error: No active log found to watch and journalctl is unavailable." >&2; exit 1 fi - echo "Starting Live Watch Mode on: $ACTIVE_LOG (Ctrl+C to stop)..." >&2 - tail -n 100 -F "$ACTIVE_LOG" else LOG_FILES=() while IFS= read -r -d '' file; do LOG_FILES+=("$file") done < <(find "$LOG_DIR" -maxdepth 1 \( -name 'maillog*' -o -name 'mail.log*' \) -type f -print0 2>/dev/null | sort -z -r) + # Fallback to journalctl if no log files are found if [ ${#LOG_FILES[@]} -eq 0 ]; then - echo "Error: No Postfix logs found in $LOG_DIR." >&2; exit 1 + if command -v journalctl &> /dev/null; then + journalctl SYSLOG_FACILITY=2 --no-pager 2>/dev/null || true + return + else + echo "Error: No Postfix logs found in $LOG_DIR and journalctl is unavailable." >&2; exit 1 + fi fi for log in "${LOG_FILES[@]}"; do if [ ! -r "$log" ]; then continue; fi if [[ "$log" =~ \.gz$ ]]; then - if [[ -n "$DATE_FILTER" ]]; then - if ! zgrep -m 1 -q -e "$DATE_FILTER" -- "$log" 2>/dev/null; then continue; fi + if command -v zcat &> /dev/null; then + if [[ -n "$DATE_FILTER" ]]; then + if ! zgrep -m 1 -q -e "$DATE_FILTER" -- "$log" 2>/dev/null; then continue; fi + fi + zcat -- "$log" 2>/dev/null fi - zcat -- "$log" 2>/dev/null else if [[ -n "$DATE_FILTER" ]]; then if ! grep -m 1 -q -e "$DATE_FILTER" -- "$log" 2>/dev/null; then continue; fi @@ -208,6 +207,9 @@ get_log_stream() { fi } +# ============================================================================== +# 6. AWK PROCESSING LOGIC (FEATURE 4: NEW YEAR'S EVE BUG FIX) +# ============================================================================== AWK_SCRIPT=' BEGIN { split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", n); @@ -271,7 +273,13 @@ function sanitize(str) { gsub(/[^[:print:]]/, "", str); return str; } s_sub = (db_subj[qid] != "") ? db_subj[qid] : "-"; gsub(/ /,"_",s_sub); if (s_sz >= m_sz && chk(s_f, f_filt, r_mode) && chk(s_to, t_filt, r_mode) && chk(qid, i_filt, r_mode) && chk(final_ip, ip_filt, r_mode) && chk(s_st, st_filt, r_mode)) { - sort_k = m[$1] sprintf("%02d", $2); tm_tmp=$3; gsub(/:/,"",tm_tmp); sort_k = sort_k tm_tmp; + + # New Year Fix: Prefix sorting key with 0 (prev year) or 1 (curr year) + log_m = m[$1]; + yr_prefix = (log_m > cur_m) ? "0" : "1"; + sort_k = yr_prefix log_m sprintf("%02d", $2); + + tm_tmp=$3; gsub(/:/,"",tm_tmp); sort_k = sort_k tm_tmp; out = sort_k " " s_tm " " qid " " final_ip " " s_f " " s_to " " s_st " " s_sz " " s_sub " " s_re; if (w_mode == 1) { print out; fflush(); } else { print out > tmp_file; } } @@ -282,7 +290,12 @@ function sanitize(str) { gsub(/[^[:print:]]/, "", str); return str; } if (match($0, /\[[0-9]{1,3}\.[0-9.]+/)) nip = substr($0, RSTART+1, RLENGTH-1); else nip = "unknown"; s_st = (index($0, "Greylisted") > 0) ? "GREYLIST" : "REJECT"; s_re = "Rejected"; if (match($0, /: [^;]+; /)) s_re = substr($0, RSTART+2, RLENGTH-4); gsub(/ /,"_",s_re); - sort_k = m[$1] sprintf("%02d", $2); tm_tmp=$3; gsub(/:/,"",tm_tmp); sort_k = sort_k tm_tmp; + + log_m = m[$1]; + yr_prefix = (log_m > cur_m) ? "0" : "1"; + sort_k = yr_prefix log_m sprintf("%02d", $2); + + tm_tmp=$3; gsub(/:/,"",tm_tmp); sort_k = sort_k tm_tmp; if (chk(f, f_filt, r_mode) && chk(t, t_filt, r_mode) && chk(nip, ip_filt, r_mode) && chk(s_st, st_filt, r_mode)) { out = sort_k " " curr_t " NOQUEUE " nip " " f " " t " " s_st " 0 - " s_re; @@ -292,7 +305,7 @@ function sanitize(str) { gsub(/[^[:print:]]/, "", str); return str; } }' # ============================================================================== -# 5. EXECUTION & OUTPUT RENDERING +# 7. EXECUTION & OUTPUT RENDERING (FEATURE 3: PAGER INTEGRATION) # ============================================================================== format_output() { awk -v u_col="$USE_COLOR" -v csv="$CSV_MODE" -v d_view="$DETAIL_VIEW" ' @@ -321,20 +334,35 @@ format_output() { }' } +# Wrapper function for the pager (less) +use_pager() { + if [[ -t 1 && "$CSV_MODE" -eq 0 && "$WATCH_MODE" -eq 0 ]]; then + if command -v less &> /dev/null; then + # -R preserves colors, -F exits if content fits on one screen, -X prevents clearing the screen on exit + less -R -F -X + else + cat + fi + else + cat + fi +} + if [[ "$WATCH_MODE" -eq 1 ]]; then if [[ "$USE_COLOR" -eq 1 ]]; then printf "\033[1m%-16s %-12s %-16s %-32s %-28s %-10s %s\033[0m\n" "DATE-TIME" "QUEUE_ID" "EXT_IP" "SENDER" "RECIPIENT" "STATUS" "REASON" fi - get_log_stream | awk -v d_filt="$DATE_FILTER" -v tm_filt="$TIME_FILTER" -v f_filt="$FROM_FILTER" -v t_filt="$TO_FILTER" -v i_filt="$ID_FILTER" -v ip_filt="$IP_FILTER" -v st_filt="$STATUS_FILTER" -v m_sz="$MIN_SIZE" -v w_mode=1 -v r_mode="$REGEX_MODE" -v tmp_file="$TMP_DATA" "$AWK_SCRIPT" | format_output + get_log_stream | awk -v d_filt="$DATE_FILTER" -v tm_filt="$TIME_FILTER" -v f_filt="$FROM_FILTER" -v t_filt="$TO_FILTER" -v i_filt="$ID_FILTER" -v ip_filt="$IP_FILTER" -v st_filt="$STATUS_FILTER" -v m_sz="$MIN_SIZE" -v cur_m="$CURRENT_MONTH" -v w_mode=1 -v r_mode="$REGEX_MODE" -v tmp_file="$TMP_DATA" "$AWK_SCRIPT" | format_output exit 0 fi # Run normal processing -get_log_stream | awk -v d_filt="$DATE_FILTER" -v tm_filt="$TIME_FILTER" -v f_filt="$FROM_FILTER" -v t_filt="$TO_FILTER" -v i_filt="$ID_FILTER" -v ip_filt="$IP_FILTER" -v st_filt="$STATUS_FILTER" -v m_sz="$MIN_SIZE" -v w_mode=0 -v r_mode="$REGEX_MODE" -v tmp_file="$TMP_DATA" "$AWK_SCRIPT" +get_log_stream | awk -v d_filt="$DATE_FILTER" -v tm_filt="$TIME_FILTER" -v f_filt="$FROM_FILTER" -v t_filt="$TO_FILTER" -v i_filt="$ID_FILTER" -v ip_filt="$IP_FILTER" -v st_filt="$STATUS_FILTER" -v m_sz="$MIN_SIZE" -v cur_m="$CURRENT_MONTH" -v w_mode=0 -v r_mode="$REGEX_MODE" -v tmp_file="$TMP_DATA" "$AWK_SCRIPT" if [[ "$SUMMARY_MODE" -eq 1 ]]; then total_records=$(wc -l < "$TMP_DATA") + ( if [[ "$USE_COLOR" -eq 1 ]]; then printf "\033[1;36m"; fi echo "==========================================================" echo " SUMMARY STATISTICS (Total records: ${total_records})" @@ -343,29 +371,29 @@ if [[ "$SUMMARY_MODE" -eq 1 ]]; then if [[ "$total_records" -eq 0 ]]; then echo "No data matched your filters." - exit 0 - fi - - print_top() { - local col=$1; local title=$2; local max=$3 - if [[ "$USE_COLOR" -eq 1 ]]; then - printf "\n\033[1;33m%s\033[0m\n" "$title" - else - printf "\n%s\n" "$title" - fi - awk "{print \$$col}" "$TMP_DATA" | sort | uniq -c | sort -nr | head -n "$max" | awk '{printf " %6d %s\n", $1, $2}' - } - - print_top 7 "STATUS BREAKDOWN:" 10 - print_top 4 "TOP 10 SOURCE IPs:" 10 - print_top 5 "TOP 10 SENDERS:" 10 - - if [[ "$USE_COLOR" -eq 1 ]]; then - printf "\n\033[1;33mBANDWIDTH CONSUMPTION:\033[0m\n" else - printf "\nBANDWIDTH CONSUMPTION:\n" + print_top() { + local col=$1; local title=$2; local max=$3 + if [[ "$USE_COLOR" -eq 1 ]]; then + printf "\n\033[1;33m%s\033[0m\n" "$title" + else + printf "\n%s\n" "$title" + fi + awk "{print \$$col}" "$TMP_DATA" | sort | uniq -c | sort -nr | head -n "$max" | awk '{printf " %6d %s\n", $1, $2}' + } + + print_top 7 "STATUS BREAKDOWN:" 10 + print_top 4 "TOP 10 SOURCE IPs:" 10 + print_top 5 "TOP 10 SENDERS:" 10 + + if [[ "$USE_COLOR" -eq 1 ]]; then + printf "\n\033[1;33mBANDWIDTH CONSUMPTION:\033[0m\n" + else + printf "\nBANDWIDTH CONSUMPTION:\n" + fi + awk '{sum+=$8} END {printf " Total Processed Size: %.2f MB\n", sum/1048576}' "$TMP_DATA" fi - awk '{sum+=$8} END {printf " Total Processed Size: %.2f MB\n", sum/1048576}' "$TMP_DATA" + ) | use_pager exit 0 fi @@ -373,10 +401,12 @@ if [[ "$DETAIL_VIEW" -eq 1 ]]; then if [ ! -s "$TMP_DATA" ]; then echo "No records found for ID: $ID_FILTER"; exit 0 fi - sort -n "$TMP_DATA" | format_output + sort -n "$TMP_DATA" | format_output | use_pager else + ( if [[ "$USE_COLOR" -eq 1 && "$CSV_MODE" -eq 0 ]]; then printf "\033[1m%-16s %-12s %-16s %-32s %-28s %-10s %s\033[0m\n" "DATE-TIME" "QUEUE_ID" "EXT_IP" "SENDER" "RECIPIENT" "STATUS" "REASON" fi sort -n "$TMP_DATA" | tail -n "$LIMIT" | format_output -fi + ) | use_pager +fi \ No newline at end of file