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:
+93
-63
@@ -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
|
||||
) | use_pager
|
||||
fi
|
||||
Reference in New Issue
Block a user