update mail-status

Dependency Check: In section # 2, the script now immediately checks for the existence of commands like awk, grep, tail, flock, etc., upon execution. If any are missing, the script exits safely with a clear error message instead of collapsing mid-run.

Systemd (Journalctl) Support: The get_log_stream function was overhauled. If it finds no text logs in /var/log (which is standard on new systems without rsyslog), the script automatically calls journalctl SYSLOG_FACILITY=2. This works seamlessly in both standard mode and Live Watch (-w) mode.

Automatic Pagination: The use_pager function was added at the end of the script. If the script detects it is running in a terminal, it pipes the entire output into less -R -F -X. This means if you output 1000 records, the text won't scroll off the screen; you can navigate with arrow keys. If there are few records (fitting on one screen), less automatically quits (-F).

New Year's Eve Bug Fix: A CURRENT_MONTH variable was added to bash and passed into the AWK script (as cur_m). AWK then compares whether the month in the log is greater than the current month. If it is, the log is from the previous year, and for sorting purposes, it prefixes the sorting key with a 0, otherwise 1. This ensures December records appear before January records in the final sorted list.
This commit is contained in:
2026-09-07 18:23:47 +02:00
parent fdacdf8dae
commit 8a301ebf75
+93 -63
View File
@@ -1,5 +1,5 @@
#!/bin/bash #!/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 set -euo pipefail
IFS=$'\n\t' IFS=$'\n\t'
@@ -42,39 +42,28 @@ OUTPUT & RUN MODE OPTIONS:
MISC OPTIONS: MISC OPTIONS:
--log-dir PATH Custom path to log files (default: /var/log). --log-dir PATH Custom path to log files (default: /var/log).
-h, --help Display this detailed help message. -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 EOF
exit 0 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 LIMIT=10; MAX_LIMIT=1000
FROM_FILTER=""; TO_FILTER=""; ID_FILTER=""; DATE_FILTER=""; IP_FILTER="" FROM_FILTER=""; TO_FILTER=""; ID_FILTER=""; DATE_FILTER=""; IP_FILTER=""
STATUS_FILTER=""; TIME_FILTER=""; LOG_DIR="/var/log"; MIN_SIZE=0 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 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() { check_arg() {
if [[ "$2" -lt 1 ]]; then if [[ "$2" -lt 1 ]]; then
@@ -128,20 +117,18 @@ if [[ "$CSV_MODE" -eq 1 ]]; then
fi fi
export CSV_MODE export CSV_MODE
# SECURITY SAFEGUARD: Prevent ReDoS in Watch Mode
if [[ "$WATCH_MODE" -eq 1 && "$REGEX_MODE" -eq 1 ]]; then 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 echo "Security Warning: Regular Expressions (-R) are disabled in Watch Mode (-w) to prevent CPU locking (ReDoS)." >&2
exit 1 exit 1
fi fi
# SECURITY SAFEGUARD: Path Validation for --log-dir (Directory Traversal Protection)
if [[ "$LOG_DIR" != "/var/log" && "$LOG_DIR" != "/var/log/mail" ]]; then 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 echo "Error: Unsafe log directory specified ($LOG_DIR). Only /var/log or /var/log/mail are allowed." >&2
exit 1 exit 1
fi fi
# ============================================================================== # ==============================================================================
# 3. RESOURCE LIMITS & LOCKFILES (PRODUCTION SAFEGUARDS) # 4. RESOURCE LIMITS & LOCKFILES
# ============================================================================== # ==============================================================================
if [[ "$WATCH_MODE" -eq 1 ]]; then if [[ "$WATCH_MODE" -eq 1 ]]; then
LOCKFILE="${TMPDIR:-/tmp}/postfix_watch_${UID}.lock" 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 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 if [ ! -d "$LOG_DIR" ]; then
echo "Error: Directory $LOG_DIR does not exist." >&2; exit 1 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 [[ -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" && -f "$LOG_DIR/mail.log" ]]; then ACTIVE_LOG="$LOG_DIR/mail.log"; fi
if [[ -z "$ACTIVE_LOG" ]]; then if [[ -n "$ACTIVE_LOG" ]]; then
echo "Error: No active log found to watch." >&2; exit 1 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 fi
echo "Starting Live Watch Mode on: $ACTIVE_LOG (Ctrl+C to stop)..." >&2
tail -n 100 -F "$ACTIVE_LOG"
else else
LOG_FILES=() LOG_FILES=()
while IFS= read -r -d '' file; do while IFS= read -r -d '' file; do
LOG_FILES+=("$file") LOG_FILES+=("$file")
done < <(find "$LOG_DIR" -maxdepth 1 \( -name 'maillog*' -o -name 'mail.log*' \) -type f -print0 2>/dev/null | sort -z -r) 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 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 fi
for log in "${LOG_FILES[@]}"; do for log in "${LOG_FILES[@]}"; do
if [ ! -r "$log" ]; then continue; fi if [ ! -r "$log" ]; then continue; fi
if [[ "$log" =~ \.gz$ ]]; then if [[ "$log" =~ \.gz$ ]]; then
if [[ -n "$DATE_FILTER" ]]; then if command -v zcat &> /dev/null; then
if ! zgrep -m 1 -q -e "$DATE_FILTER" -- "$log" 2>/dev/null; then continue; fi 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 fi
zcat -- "$log" 2>/dev/null
else else
if [[ -n "$DATE_FILTER" ]]; then if [[ -n "$DATE_FILTER" ]]; then
if ! grep -m 1 -q -e "$DATE_FILTER" -- "$log" 2>/dev/null; then continue; fi if ! grep -m 1 -q -e "$DATE_FILTER" -- "$log" 2>/dev/null; then continue; fi
@@ -208,6 +207,9 @@ get_log_stream() {
fi fi
} }
# ==============================================================================
# 6. AWK PROCESSING LOGIC (FEATURE 4: NEW YEAR'S EVE BUG FIX)
# ==============================================================================
AWK_SCRIPT=' AWK_SCRIPT='
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);
@@ -271,7 +273,13 @@ function sanitize(str) { gsub(/[^[:print:]]/, "", str); return str; }
s_sub = (db_subj[qid] != "") ? db_subj[qid] : "-"; gsub(/ /,"_",s_sub); 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)) { 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; 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; } 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"; 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_st = (index($0, "Greylisted") > 0) ? "GREYLIST" : "REJECT";
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;
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)) { 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; 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() { format_output() {
awk -v u_col="$USE_COLOR" -v csv="$CSV_MODE" -v d_view="$DETAIL_VIEW" ' 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 [[ "$WATCH_MODE" -eq 1 ]]; then
if [[ "$USE_COLOR" -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" printf "\033[1m%-16s %-12s %-16s %-32s %-28s %-10s %s\033[0m\n" "DATE-TIME" "QUEUE_ID" "EXT_IP" "SENDER" "RECIPIENT" "STATUS" "REASON"
fi 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 exit 0
fi fi
# Run normal processing # 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 if [[ "$SUMMARY_MODE" -eq 1 ]]; then
total_records=$(wc -l < "$TMP_DATA") total_records=$(wc -l < "$TMP_DATA")
(
if [[ "$USE_COLOR" -eq 1 ]]; then printf "\033[1;36m"; fi if [[ "$USE_COLOR" -eq 1 ]]; then printf "\033[1;36m"; fi
echo "==========================================================" echo "=========================================================="
echo " SUMMARY STATISTICS (Total records: ${total_records})" echo " SUMMARY STATISTICS (Total records: ${total_records})"
@@ -343,29 +371,29 @@ if [[ "$SUMMARY_MODE" -eq 1 ]]; then
if [[ "$total_records" -eq 0 ]]; then if [[ "$total_records" -eq 0 ]]; then
echo "No data matched your filters." 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 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 fi
awk '{sum+=$8} END {printf " Total Processed Size: %.2f MB\n", sum/1048576}' "$TMP_DATA" ) | use_pager
exit 0 exit 0
fi fi
@@ -373,10 +401,12 @@ if [[ "$DETAIL_VIEW" -eq 1 ]]; then
if [ ! -s "$TMP_DATA" ]; then if [ ! -s "$TMP_DATA" ]; then
echo "No records found for ID: $ID_FILTER"; exit 0 echo "No records found for ID: $ID_FILTER"; exit 0
fi fi
sort -n "$TMP_DATA" | format_output sort -n "$TMP_DATA" | format_output | use_pager
else else
(
if [[ "$USE_COLOR" -eq 1 && "$CSV_MODE" -eq 0 ]]; then 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" printf "\033[1m%-16s %-12s %-16s %-32s %-28s %-10s %s\033[0m\n" "DATE-TIME" "QUEUE_ID" "EXT_IP" "SENDER" "RECIPIENT" "STATUS" "REASON"
fi fi
sort -n "$TMP_DATA" | tail -n "$LIMIT" | format_output sort -n "$TMP_DATA" | tail -n "$LIMIT" | format_output
) | use_pager
fi fi