#!/bin/bash # Postfix Mail Summary Tool - Hardened & Domain-Focused Version (v3.0.1) # 1. RESOURCE LIMITS & ENVIRONMENT ulimit -t 15 ulimit -v 500000 ulimit -f 102400 set -euo pipefail IFS=$'\n\t' # 2. LOCKFILE & PRE-FLIGHT CHECKS LOCKFILE="/var/lock/postfix_summary.lock" [ -w /var/lock ] || LOCKFILE="/tmp/postfix_summary.lock" exec 9>>"$LOCKFILE" if ! flock -n 9; then echo "Error: Another instance is already running." >&2 exit 1 fi TMP_DATA=$(mktemp /tmp/postfix_log.XXXXXX) trap 'rm -f "$TMP_DATA" 2>/dev/null' EXIT export USE_COLOR=0 [[ -t 1 ]] && USE_COLOR=1 # 3. HELP MENU show_help() { cat << 'EOF' Usage: mail-stats [OPTIONS] FILTERING OPTIONS: -d, --date "MMM DD" Filter by date (e.g., "Sep 4" or "Sep 24"). Default: today. Use "" (empty string) to search all dates in available logs. --time "HH:MM" Filter by specific time/hour (e.g., "14:30" or "14:"). -f, --from EMAIL Filter by sender email or domain (e.g., "admin@" or "domain.com"). -t, --to EMAIL Filter by recipient email or domain. -a, --ip IP_ADDRESS Filter by source/relay IP address (e.g., "192.168.1."). -s, --status STATUS Filter by delivery status (e.g., sent, deferred, reject, bounced). Case-insensitive. -i, --id QUEUE_ID Search for a specific Postfix Queue ID (Enables Detail View). OUTPUT OPTIONS: -l, --list NUMBER Number of records to show (1-1000, default: 10). -S, --summary Show aggregate statistics (Top IPs, Senders, Recipients, Statuses) instead of list. -c, --csv Output data in CSV format (disables colors). MISC OPTIONS: --log-dir PATH Custom path to log files (default: /var/log). -h, --help Display this help message. EXAMPLES: # Search for rejected emails today and show summary stats mail-stats -s reject -S # Find all emails sent from a specific IP around 14:30 on Sep 4 mail-stats -d "Sep 4" --time "14:30" -a "192.168.1.50" # Export 50 latest records for a specific target domain to a CSV file mail-stats -t targetdomain.com -l 50 -c > report.csv # Check logs in a custom backup directory mail-stats --log-dir /mnt/backups/mail_logs/ -d "Aug 15" EOF exit 0 } # 4. ARGUMENT PARSING LIMIT=10; MAX_LIMIT=1000; MAX_INPUT_LEN=100 FROM_FILTER=""; TO_FILTER=""; ID_FILTER=""; DATE_FILTER=""; IP_FILTER="" STATUS_FILTER=""; TIME_FILTER=""; LOG_DIR="/var/log" DATE_SET=0; CSV_MODE=0; SUMMARY_MODE=0 check_arg() { local opt="$1" shift if [[ "$#" -lt 1 ]]; then echo "Error: Option '$opt' requires an argument." >&2 exit 1 fi } sanitize() { echo "${1:0:$MAX_INPUT_LEN}" | tr -cd '[:alnum:]@._ -:'; } while [[ "$#" -gt 0 ]]; do case $1 in -h|--help) show_help ;; -l|--list) check_arg "$1" "${2:-}"; if [[ "$2" =~ ^[0-9]+$ ]]; then LIMIT="$2"; fi; shift ;; -f|--from) check_arg "$1" "${2:-}"; FROM_FILTER=$(sanitize "${2:-}"); shift ;; -t|--to) check_arg "$1" "${2:-}"; TO_FILTER=$(sanitize "${2:-}"); shift ;; -i|--id) check_arg "$1" "${2:-}"; ID_FILTER=$(sanitize "${2:-}"); shift ;; -a|--ip) check_arg "$1" "${2:-}"; IP_FILTER=$(sanitize "${2:-}"); shift ;; -s|--status) check_arg "$1" "${2:-}"; STATUS_FILTER=$(sanitize "${2:-}"); shift ;; --time) check_arg "$1" "${2:-}"; TIME_FILTER=$(sanitize "${2:-}"); shift ;; --log-dir) check_arg "$1" "${2:-}"; LOG_DIR="${2:-}"; shift ;; -S|--summary) SUMMARY_MODE=1 ;; -c|--csv) CSV_MODE=1 ;; -d|--date) if [[ "$#" -lt 2 ]]; then echo "Error: Option '$1' requires an argument."; exit 1; fi DATE_FILTER=$(sanitize "${2:-}"); DATE_SET=1; shift ;; *) echo "Unknown option: $1" >&2; exit 1 ;; esac shift done (( LIMIT < 1 )) && LIMIT=1 (( LIMIT > MAX_LIMIT )) && LIMIT=$MAX_LIMIT if [ "$DATE_SET" -eq 0 ]; then DATE_FILTER=$(date '+%b %e') else DATE_FILTER=$(echo "$DATE_FILTER" | sed -E 's/^([a-zA-Z]{3})[[:space:]]+([0-9])$/\1 \2/') fi readonly DATE_FILTER DETAIL_VIEW=0; [[ -n "$ID_FILTER" && "$ID_FILTER" != "NOQUEUE" ]] && DETAIL_VIEW=1 # 5. LOG FILE DISCOVERY if [ ! -d "$LOG_DIR" ]; then echo "Error: Directory $LOG_DIR does not exist." >&2; exit 1 fi 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) [ ${#LOG_FILES[@]} -eq 0 ] && { echo "Error: No Postfix logs found in $LOG_DIR." >&2; exit 1; } # 6. LOG PROCESSING process_logs() { for log in "${LOG_FILES[@]}"; do [ ! -r "$log" ] && continue if [[ "$log" =~ \.gz$ ]]; then [[ -n "$DATE_FILTER" ]] && ! zgrep -m 1 -q "$DATE_FILTER" "$log" 2>/dev/null && continue zcat -- "$log" 2>/dev/null else [[ -n "$DATE_FILTER" ]] && ! grep -m 1 -q "$DATE_FILTER" "$log" 2>/dev/null && continue cat -- "$log" 2>/dev/null fi done } process_logs | 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" ' BEGIN { split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", n); for(i in n) { m[n[i]]=sprintf("%02d",i); months[n[i]]=1; } db_count = 0; } !($1 in months) { next } { if (length($0) < 20 || length($0) > 2048) next; } (d_filt != "" && substr($0, 1, length(d_filt)) != d_filt) { next } (tm_filt != "" && index($3, tm_filt) != 1) { next } { if (db_count > 100000) { split("", db_from); split("", db_time); split("", db_in_ip); db_count = 0; } qid = ""; for(i=1; i<=9; i++) if($i ~ /^[A-F0-9]+:$/) { qid=$i; gsub(/:/,"",qid); break; } curr_t = $1"-"$2"_"$3; if (qid != "" && !(qid in db_from)) { db_count++; } if (index($0, "amavis") > 0 && index($0, "queued_as:") > 0) { match($0, /queued_as: [A-F0-9]+/); aqid = substr($0, RSTART+11, RLENGTH-11); if (match($0, /\[[0-9]{1,3}\.[0-9.]+/)) db_in_ip[aqid] = substr($0, RSTART+1, RLENGTH-1); } if (qid != "" && index($0, "client=") > 0) { if (match($0, /\[[0-9]{1,3}\.[0-9.]+/)) { cip = substr($0, RSTART+1, RLENGTH-1); if (cip != "127.0.0.1" && db_in_ip[qid] == "") db_in_ip[qid] = cip; } db_time[qid] = curr_t; } if (qid != "" && index($0, "from=<") > 0) { match($0, /from=<[^>]*>/); if (RLENGTH > 0) db_from[qid] = substr($0, RSTART+6, RLENGTH-7); } if (qid != "" && index($0, "to=<") > 0 && index($0, "status=") > 0) { if (index($0, "relay=127.0.0.1") > 0 || index($0, "10025") > 0) next; match($0, /to=<[^>]*>/); s_to = substr($0, RSTART+4, RLENGTH-5); match($0, /status=[a-z]+/); s_st = substr($0, RSTART+7, RLENGTH-7); rip = ""; if (match($0, /relay=[^ ]+\[[0-9]{1,3}\.[0-9.]+/)) { temp_m = substr($0, RSTART, RLENGTH); match(temp_m, /\[[0-9.]+/); rip = substr(temp_m, RSTART+1, RLENGTH-1); } final_ip = (rip != "" && rip != "127.0.0.1") ? rip : (db_in_ip[qid] != "" ? db_in_ip[qid] : "local"); s_re = "OK"; if (match($0, /\([^)]*\)/)) { s_re = substr($0, RSTART+1, RLENGTH-2); gsub(/ /,"_",s_re); } s_f = (db_from[qid] != "") ? db_from[qid] : "-"; s_tm = (db_time[qid] != "") ? db_time[qid] : curr_t; if ((f_filt == "" || index(s_f, f_filt) > 0) && (t_filt == "" || index(s_to, t_filt) > 0) && (i_filt == "" || index(qid, i_filt) > 0) && (ip_filt == "" || index(final_ip, ip_filt) > 0) && (st_filt == "" || index(tolower(s_st), tolower(st_filt)) > 0)) { sort_k = m[$1] sprintf("%02d", $2); tm_tmp=$3; gsub(/:/,"",tm_tmp); sort_k = sort_k tm_tmp; print sort_k " " s_tm " " qid " " final_ip " " s_f " " s_to " " s_st " " s_re; } } if (index($0, "NOQUEUE: reject:") > 0 && (i_filt == "" || i_filt == "NOQUEUE")) { match($0, /from=<[^>]*>/); f=(RLENGTH>0)?substr($0, RSTART+6, RLENGTH-7):"-"; match($0, /to=<[^>]*>/); t=(RLENGTH>0)?substr($0, RSTART+4, RLENGTH-5):"-"; 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; if ((f_filt == "" || index(f, f_filt) > 0) && (t_filt == "" || index(t, t_filt) > 0) && (ip_filt == "" || index(nip, ip_filt) > 0) && (st_filt == "" || index(tolower(s_st), tolower(st_filt)) > 0)) { print sort_k " " curr_t " NOQUEUE " nip " " f " " t " " s_st " " s_re; } } }' > "$TMP_DATA" # 7. FINAL OUTPUT RENDERING [[ "$CSV_MODE" -eq 1 ]] && USE_COLOR=0 # ================= SUMMARY MODE ================= if [[ "$SUMMARY_MODE" -eq 1 ]]; then total_records=$(wc -l < "$TMP_DATA") [[ "$USE_COLOR" -eq 1 ]] && printf "\033[1;36m" echo "==========================================================" echo " SUMMARY STATISTICS (Total records: ${total_records})" echo "==========================================================" [[ "$USE_COLOR" -eq 1 ]] && printf "\033[0m" 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 [[ "$USE_COLOR" -eq 1 ]] && printf "\n\033[1;33m%s\033[0m\n" "$title" || printf "\n%s\n" "$title" 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 print_top 6 "TOP 10 RECIPIENTS:" 10 exit 0 fi # ================= DETAIL / LIST MODE ================= 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" | awk -v u_col="$USE_COLOR" '{ blue=(u_col=="1"?"\033[1;34m":""); reset=(u_col=="1"?"\033[0m":""); tm=$2; gsub(/_/," ",tm); qid=$3; ip=$4; f=$5; gsub(/[^[:print:]]/, "", f); t=$6; gsub(/[^[:print:]]/, "", t); st=$7; r=$8; gsub(/[^[:print:]]/, "", r); gsub(/_/," ",r); printf "\n%s--- Detail for ID: %s ---%s\nDate: %s\nExternal IP: %s\nFrom: %s\nTo: %s\nStatus: %s\nReason: %s\n", blue,qid,reset,tm,ip,f,t,st,r }' else if [[ "$CSV_MODE" -eq 1 ]]; then echo "DATE-TIME,QUEUE_ID,EXT_IP,SENDER,RECIPIENT,STATUS,REASON" sort -n "$TMP_DATA" | tail -n "$LIMIT" | awk '{ tm=$2; gsub(/_/," ",tm); id=$3; ip=$4; from=$5; gsub(/[^[:print:]]/, "", from); to=$6; gsub(/[^[:print:]]/, "", to); st=$7; re=$8; gsub(/[^[:print:]]/, "", re); gsub(/_/," ",re); printf "\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\"\n", tm, id, ip, from, to, st, re }' else [[ "$USE_COLOR" -eq 1 ]] && printf "\033[1m" printf "%-16s %-12s %-16s %-36s %-31s %-12s %s\n" "DATE-TIME" "QUEUE_ID" "EXT_IP" "SENDER" "RECIPIENT" "STATUS" "REASON" [[ "$USE_COLOR" -eq 1 ]] && printf "\033[0m" sort -n "$TMP_DATA" | tail -n "$LIMIT" | awk -v u_col="$USE_COLOR" ' function d_cut(e, m) { if(length(e)<=m)return e; return ".." substr(e, length(e)-m+2); } BEGIN { b="\033[1;34m"; r="\033[0m"; g="\033[1;32m"; rd="\033[1;31m"; y="\033[1;33m"; gr="\033[0;90m"; c="\033[0;36m"; if(u_col!="1")b=r=g=rd=y=gr=c=""; } { tm=$2; gsub(/_/," ",tm); id=$3; ip=$4; from=$5; gsub(/[^[:print:]]/, "", from); to=$6; gsub(/[^[:print:]]/, "", to); st=$7; re_f=$8; gsub(/[^[:print:]]/, "", re_f); gsub(/_/," ",re_f); re = (match(re_f, /[0-9]{3} [0-9]\.[0-9]\.[0-9]/)) ? substr(re_f, RSTART, RLENGTH) : substr(re_f, 1, 15); scol = (st == "sent") ? g : (st == "deferred" || st == "GREYLIST" ? y : rd); printf "%-16s %s%-12s%s %s%-16s%s %-36s %-31s %s%-12s%s %s[%s]%s\n", tm, (id=="NOQUEUE"?rd:b), id, r, gr, sprintf("%.15s", ip), r, d_cut(from, 35), d_cut(to, 30), scol, st, r, (st=="sent"?c:scol), re, r; }' fi fi