update mail-status

rework script 
add better help
add -S 
and more QQ
This commit is contained in:
2026-09-07 08:53:26 +02:00
parent 1cafd05a66
commit c5e42d3a3f
+87 -51
View File
@@ -1,9 +1,7 @@
#!/bin/bash #!/bin/bash
# Postfix Mail Summary Tool - Hardened & Domain-Focused Version (v2026.2.0) # Postfix Mail Summary Tool - Hardened & Domain-Focused Version (v3.0.1)
# ==============================================================================
# 1. RESOURCE LIMITS & ENVIRONMENT # 1. RESOURCE LIMITS & ENVIRONMENT
# ==============================================================================
ulimit -t 15 ulimit -t 15
ulimit -v 500000 ulimit -v 500000
ulimit -f 102400 ulimit -f 102400
@@ -11,9 +9,7 @@ ulimit -f 102400
set -euo pipefail set -euo pipefail
IFS=$'\n\t' IFS=$'\n\t'
# ==============================================================================
# 2. LOCKFILE & PRE-FLIGHT CHECKS # 2. LOCKFILE & PRE-FLIGHT CHECKS
# ==============================================================================
LOCKFILE="/var/lock/postfix_summary.lock" LOCKFILE="/var/lock/postfix_summary.lock"
[ -w /var/lock ] || LOCKFILE="/tmp/postfix_summary.lock" [ -w /var/lock ] || LOCKFILE="/tmp/postfix_summary.lock"
@@ -29,45 +25,63 @@ trap 'rm -f "$TMP_DATA" 2>/dev/null' EXIT
export USE_COLOR=0 export USE_COLOR=0
[[ -t 1 ]] && USE_COLOR=1 [[ -t 1 ]] && USE_COLOR=1
# ==============================================================================
# 3. HELP MENU # 3. HELP MENU
# ==============================================================================
show_help() { show_help() {
echo "Usage: $0 [OPTIONS]" cat << 'EOF'
echo "" Usage: mail-stats [OPTIONS]
echo "Options:"
echo " -h, --help Display this help message" FILTERING OPTIONS:
echo " -l, --list NUMBER Number of records to show (1-1000, default: 10)" -d, --date "MMM DD" Filter by date (e.g., "Sep 4" or "Sep 24"). Default: today.
echo " -f, --from EMAIL/DOMAIN Filter by sender address" Use "" (empty string) to search all dates in available logs.
echo " -t, --to EMAIL/DOMAIN Filter by recipient address" --time "HH:MM" Filter by specific time/hour (e.g., "14:30" or "14:").
echo " -i, --id QUEUE_ID Search for a specific ID (Detail View)" -f, --from EMAIL Filter by sender email or domain (e.g., "admin@" or "domain.com").
echo " -a, --ip IP_ADDRESS Filter by source/relay IP address" -t, --to EMAIL Filter by recipient email or domain.
echo " -d, --date \"MMM DD\" Filter by date (e.g., \"Sep 4\" or \"Sep 24\")" -a, --ip IP_ADDRESS Filter by source/relay IP address (e.g., "192.168.1.").
echo " Use \"\" to search through all available logs" -s, --status STATUS Filter by delivery status (e.g., sent, deferred, reject, bounced). Case-insensitive.
echo " -c, --csv Output data in CSV format (disables colors)" -i, --id QUEUE_ID Search for a specific Postfix Queue ID (Enables Detail View).
echo " mail-stats -d ""Sep 4"" -c > report.csv"
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 exit 0
} }
# ==============================================================================
# 4. ARGUMENT PARSING # 4. ARGUMENT PARSING
# ============================================================================== LIMIT=10; MAX_LIMIT=1000; MAX_INPUT_LEN=100
LIMIT=10; readonly MAX_LIMIT=1000; readonly MAX_INPUT_LEN=100
FROM_FILTER=""; TO_FILTER=""; ID_FILTER=""; DATE_FILTER=""; IP_FILTER="" FROM_FILTER=""; TO_FILTER=""; ID_FILTER=""; DATE_FILTER=""; IP_FILTER=""
DATE_SET=0; CSV_MODE=0 STATUS_FILTER=""; TIME_FILTER=""; LOG_DIR="/var/log"
DATE_SET=0; CSV_MODE=0; SUMMARY_MODE=0
check_arg() { check_arg() {
local opt="$1" local opt="$1"
shift shift
if [[ "$#" -lt 1 ]]; then if [[ "$#" -lt 1 ]]; then
echo "Error: Option '$opt' requires an argument (can be \"\")." >&2 echo "Error: Option '$opt' requires an argument." >&2
exit 1 exit 1
fi fi
} }
sanitize() { sanitize() { echo "${1:0:$MAX_INPUT_LEN}" | tr -cd '[:alnum:]@._ -:'; }
echo "${1:0:$MAX_INPUT_LEN}" | tr -cd '[:alnum:]@._ -'
}
while [[ "$#" -gt 0 ]]; do while [[ "$#" -gt 0 ]]; do
case $1 in case $1 in
@@ -77,6 +91,10 @@ while [[ "$#" -gt 0 ]]; do
-t|--to) check_arg "$1" "${2:-}"; TO_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 ;; -i|--id) check_arg "$1" "${2:-}"; ID_FILTER=$(sanitize "${2:-}"); shift ;;
-a|--ip) check_arg "$1" "${2:-}"; IP_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 ;; -c|--csv) CSV_MODE=1 ;;
-d|--date) -d|--date)
if [[ "$#" -lt 2 ]]; then echo "Error: Option '$1' requires an argument."; exit 1; fi if [[ "$#" -lt 2 ]]; then echo "Error: Option '$1' requires an argument."; exit 1; fi
@@ -86,35 +104,31 @@ while [[ "$#" -gt 0 ]]; do
shift shift
done done
# Limit validation
(( LIMIT < 1 )) && LIMIT=1 (( LIMIT < 1 )) && LIMIT=1
(( LIMIT > MAX_LIMIT )) && LIMIT=$MAX_LIMIT (( LIMIT > MAX_LIMIT )) && LIMIT=$MAX_LIMIT
# Date Formatting Fix for single-digit days
if [ "$DATE_SET" -eq 0 ]; then if [ "$DATE_SET" -eq 0 ]; then
DATE_FILTER=$(date '+%b %e') DATE_FILTER=$(date '+%b %e')
else else
# Automatically add the extra space for dates like "Sep 4" -> "Sep 4"
DATE_FILTER=$(echo "$DATE_FILTER" | sed -E 's/^([a-zA-Z]{3})[[:space:]]+([0-9])$/\1 \2/') DATE_FILTER=$(echo "$DATE_FILTER" | sed -E 's/^([a-zA-Z]{3})[[:space:]]+([0-9])$/\1 \2/')
fi fi
readonly DATE_FILTER readonly DATE_FILTER
DETAIL_VIEW=0; [[ -n "$ID_FILTER" && "$ID_FILTER" != "NOQUEUE" ]] && DETAIL_VIEW=1 DETAIL_VIEW=0; [[ -n "$ID_FILTER" && "$ID_FILTER" != "NOQUEUE" ]] && DETAIL_VIEW=1
# ============================================================================== # 5. LOG FILE DISCOVERY
# 5. LOG FILE DISCOVERY (Universal for Debian/Ubuntu & RHEL/CentOS) if [ ! -d "$LOG_DIR" ]; then
# ============================================================================== echo "Error: Directory $LOG_DIR does not exist." >&2; exit 1
LOG_FILES=() fi
while IFS= read -r -d '' file; do LOG_FILES+=("$file"); done < <(find /var/log -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." >&2; exit 1; }
# ============================================================================== LOG_FILES=()
# 6. LOG PROCESSING (SMART GREP & AWK ENGINE) 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() { process_logs() {
for log in "${LOG_FILES[@]}"; do for log in "${LOG_FILES[@]}"; do
[ ! -r "$log" ] && continue [ ! -r "$log" ] && continue
# Fast skipping: If date filter is set, check if log contains the date before extracting
if [[ "$log" =~ \.gz$ ]]; then if [[ "$log" =~ \.gz$ ]]; then
[[ -n "$DATE_FILTER" ]] && ! zgrep -m 1 -q "$DATE_FILTER" "$log" 2>/dev/null && continue [[ -n "$DATE_FILTER" ]] && ! zgrep -m 1 -q "$DATE_FILTER" "$log" 2>/dev/null && continue
zcat -- "$log" 2>/dev/null zcat -- "$log" 2>/dev/null
@@ -125,7 +139,7 @@ process_logs() {
done done
} }
process_logs | awk -v d_filt="$DATE_FILTER" -v f_filt="$FROM_FILTER" -v t_filt="$TO_FILTER" -v i_filt="$ID_FILTER" -v ip_filt="$IP_FILTER" ' 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 { BEGIN {
split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", n); 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; } for(i in n) { m[n[i]]=sprintf("%02d",i); months[n[i]]=1; }
@@ -134,6 +148,7 @@ BEGIN {
!($1 in months) { next } !($1 in months) { next }
{ if (length($0) < 20 || length($0) > 2048) next; } { if (length($0) < 20 || length($0) > 2048) next; }
(d_filt != "" && substr($0, 1, length(d_filt)) != d_filt) { 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; } 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; } qid = ""; for(i=1; i<=9; i++) if($i ~ /^[A-F0-9]+:$/) { qid=$i; gsub(/:/,"",qid); break; }
@@ -165,8 +180,7 @@ BEGIN {
s_f = (db_from[qid] != "") ? db_from[qid] : "-"; s_f = (db_from[qid] != "") ? db_from[qid] : "-";
s_tm = (db_time[qid] != "") ? db_time[qid] : curr_t; s_tm = (db_time[qid] != "") ? db_time[qid] : curr_t;
# Applying all filters including IP filter 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)) {
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)) {
sort_k = m[$1] sprintf("%02d", $2); tm_tmp=$3; gsub(/:/,"",tm_tmp); sort_k = sort_k tm_tmp; 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; print sort_k " " s_tm " " qid " " final_ip " " s_f " " s_to " " s_st " " s_re;
} }
@@ -179,19 +193,43 @@ BEGIN {
s_re = "Rejected"; if (match($0, /: [^;]+; /)) s_re = substr($0, RSTART+2, RLENGTH-4); gsub(/ /,"_",s_re); 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; sort_k = m[$1] sprintf("%02d", $2); tm_tmp=$3; gsub(/:/,"",tm_tmp); sort_k = sort_k tm_tmp;
# Applying filters for NOQUEUE items including IP 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)) {
if ((f_filt == "" || index(f, f_filt) > 0) && (t_filt == "" || index(t, t_filt) > 0) && (ip_filt == "" || index(nip, ip_filt) > 0)) {
print sort_k " " curr_t " NOQUEUE " nip " " f " " t " " s_st " " s_re; print sort_k " " curr_t " NOQUEUE " nip " " f " " t " " s_st " " s_re;
} }
} }
}' > "$TMP_DATA" }' > "$TMP_DATA"
# ==============================================================================
# 7. FINAL OUTPUT RENDERING # 7. FINAL OUTPUT RENDERING
# ==============================================================================
# Disable color if in CSV mode
[[ "$CSV_MODE" -eq 1 ]] && USE_COLOR=0 [[ "$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 [[ "$DETAIL_VIEW" -eq 1 ]]; then
if [ ! -s "$TMP_DATA" ]; then echo "No records found for ID: $ID_FILTER"; exit 0; fi 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" '{ sort -n "$TMP_DATA" | awk -v u_col="$USE_COLOR" '{
@@ -205,7 +243,6 @@ if [[ "$DETAIL_VIEW" -eq 1 ]]; then
}' }'
else else
if [[ "$CSV_MODE" -eq 1 ]]; then if [[ "$CSV_MODE" -eq 1 ]]; then
# CSV FORMAT OUTPUT
echo "DATE-TIME,QUEUE_ID,EXT_IP,SENDER,RECIPIENT,STATUS,REASON" echo "DATE-TIME,QUEUE_ID,EXT_IP,SENDER,RECIPIENT,STATUS,REASON"
sort -n "$TMP_DATA" | tail -n "$LIMIT" | awk '{ sort -n "$TMP_DATA" | tail -n "$LIMIT" | awk '{
tm=$2; gsub(/_/," ",tm); id=$3; ip=$4; tm=$2; gsub(/_/," ",tm); id=$3; ip=$4;
@@ -216,7 +253,6 @@ else
printf "\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\"\n", tm, id, ip, from, to, st, re printf "\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\"\n", tm, id, ip, from, to, st, re
}' }'
else else
# COLORED FORMAT OUTPUT (Standard)
[[ "$USE_COLOR" -eq 1 ]] && printf "\033[1m" [[ "$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" 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" [[ "$USE_COLOR" -eq 1 ]] && printf "\033[0m"