Files
mail-status/mail-status.sh
T
robott 8a301ebf75 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.
2026-09-07 18:23:47 +02:00

412 lines
18 KiB
Bash

#!/bin/bash
# Postfix Mail Summary Tool - PRODUCTION READY EDITION (v4.0.9)
set -euo pipefail
IFS=$'\n\t'
# ==============================================================================
# 1. HELP MENU (EXTENDED & DETAILED)
# ==============================================================================
show_help() {
cat << 'EOF'
Usage: mail-stats [OPTIONS]
FILTERING OPTIONS (Combine multiple filters to narrow down results):
-d, --date "MMM DD" Filter by exact date. Default is today.
Examples: -d "Sep 4" | -d "Oct 12"
Special: -d "" (Empty string searches ALL available dates)
--time "HH:MM" Filter by exact time or whole hour.
Examples: --time "14:30" (exact minute) | --time "14:" (entire hour)
-f, --from STRING Filter by sender email address or domain.
Examples: -f "admin@domain.com" | -f "paypal"
-t, --to STRING Filter by recipient email address or domain.
Examples: -t "user@local.sk" | -t "gmail.com"
-a, --ip STRING Filter by source/relay IP address (full or partial).
Examples: -a "192.168.1.50" | -a "10.0."
-s, --status STATUS Filter by delivery status (case-insensitive).
Examples: -s sent | -s deferred | -s reject | -s bounced | -s greylist
-i, --id QUEUE_ID Search for a specific Postfix Queue ID (Enables Detail View).
Example: -i 4T3bV22xLnz1t
--min-size BYTES Show only emails larger than specified BYTES.
Example: --min-size 10485760 (Find emails larger than ~10MB)
-R, --regex Treat filters (-f, -t, -a, -s) as Regular Expressions.
Example: -R -f "^admin.*@.*\.sk$"
OUTPUT & RUN MODE OPTIONS:
-l, --list NUMBER Number of latest records to display (1-1000). Default: 10.
Example: -l 50
-S, --summary Show aggregate statistics (Top IPs, Senders, Data usage) instead of list.
-c, --csv Output data in CSV format (disables colors for easy Excel import).
-w, --watch Live Watch Mode (tail -f). Runs continuously and shows new emails in real-time.
MISC OPTIONS:
--log-dir PATH Custom path to log files (default: /var/log).
-h, --help Display this detailed help message.
EOF
exit 0
}
# ==============================================================================
# 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
echo "Error: Option '$1' requires an argument." >&2; exit 1
fi
}
while [[ "$#" -gt 0 ]]; do
case $1 in
-h|--help) show_help ;;
-l|--list) check_arg "$1" $#; if [[ "$2" =~ ^[0-9]+$ ]]; then LIMIT="$2"; fi; shift ;;
-f|--from) check_arg "$1" $#; FROM_FILTER="$2"; shift ;;
-t|--to) check_arg "$1" $#; TO_FILTER="$2"; shift ;;
-i|--id) check_arg "$1" $#; ID_FILTER="$2"; shift ;;
-a|--ip) check_arg "$1" $#; IP_FILTER="$2"; shift ;;
-s|--status) check_arg "$1" $#; STATUS_FILTER="$2"; shift ;;
--time) check_arg "$1" $#; TIME_FILTER="$2"; shift ;;
--log-dir) check_arg "$1" $#; LOG_DIR="$2"; shift ;;
--min-size) check_arg "$1" $#; if [[ "$2" =~ ^[0-9]+$ ]]; then MIN_SIZE="$2"; fi; shift ;;
-S|--summary) SUMMARY_MODE=1 ;;
-c|--csv) CSV_MODE=1 ;;
-w|--watch) WATCH_MODE=1 ;;
-R|--regex) REGEX_MODE=1 ;;
-d|--date) check_arg "$1" $#; DATE_FILTER="$2"; DATE_SET=1; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
shift
done
if (( LIMIT < 1 )); then LIMIT=1; fi
if (( LIMIT > MAX_LIMIT )); then LIMIT=$MAX_LIMIT; fi
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
if [[ -n "$ID_FILTER" && "$ID_FILTER" != "NOQUEUE" ]]; then
DETAIL_VIEW=1
fi
export USE_COLOR=0
if [[ -t 1 ]]; then
USE_COLOR=1
fi
if [[ "$CSV_MODE" -eq 1 ]]; then
USE_COLOR=0
fi
export CSV_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
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
# ==============================================================================
# 4. RESOURCE LIMITS & LOCKFILES
# ==============================================================================
if [[ "$WATCH_MODE" -eq 1 ]]; then
LOCKFILE="${TMPDIR:-/tmp}/postfix_watch_${UID}.lock"
ulimit -v 500000
else
LOCKFILE="${TMPDIR:-/tmp}/postfix_summary_${UID}.lock"
ulimit -t 15
ulimit -v 500000
ulimit -f 102400
fi
exec 9>>"$LOCKFILE"
if ! flock -n 9; then
echo "Error: Another instance of this mode is already running." >&2
exit 1
fi
TMP_DATA=$(mktemp "${TMPDIR:-/tmp}/postfix_log.XXXXXX")
trap 'rm -f "$TMP_DATA" 2>/dev/null' EXIT
# ==============================================================================
# 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
fi
get_log_stream() {
if [[ "$WATCH_MODE" -eq 1 ]]; then
ACTIVE_LOG=""
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 [[ -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
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
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 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
else
if [[ -n "$DATE_FILTER" ]]; then
if ! grep -m 1 -q -e "$DATE_FILTER" -- "$log" 2>/dev/null; then continue; fi
fi
cat -- "$log" 2>/dev/null
fi
done
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);
for(i in n) { m[n[i]]=sprintf("%02d",i); months[n[i]]=1; }
db_count = 0;
}
function chk(val, filt, is_regex) {
if (filt == "") return 1;
if (is_regex == 1 || is_regex == "1") {
return (tolower(val) ~ tolower(filt));
}
return (index(tolower(val), tolower(filt)) > 0);
}
function sanitize(str) { gsub(/[^[:print:]]/, "", str); return str; }
!($1 in months) { next }
{ if (length($0) < 20 || length($0) > 4096) next; }
(w_mode == 0 && 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); split("", db_subj); split("", db_size); 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 (qid != "" && index($0, "warning: header Subject:") > 0) {
if (match($0, /Subject: .* from /)) {
subj = substr($0, RSTART+9, RLENGTH-15); db_subj[qid] = sanitize(subj);
}
}
if (qid != "" && index($0, "size=") > 0) {
if (match($0, /size=[0-9]+/)) db_size[qid] = substr($0, RSTART+5, RLENGTH-5);
}
if (index($0, "amavis") > 0 && index($0, "queued_as:") > 0) {
if(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) {
if (match($0, /from=<[^>]*>/)) 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;
s_sz = (db_size[qid] != "") ? db_size[qid] : 0;
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)) {
# 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; }
}
}
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);
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;
if (w_mode == 1) { print out; fflush(); } else { print out > tmp_file; }
}
}
}'
# ==============================================================================
# 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" '
function csv_safe(s) { if(s ~ /^[=+\-@]/) { s=" "s }; gsub(/"/,"\"\"",s); return s; }
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="";
if(csv==1 && d_view==0) print "DATE-TIME,QUEUE_ID,EXT_IP,SENDER,RECIPIENT,STATUS,SIZE_BYTES,SUBJECT,REASON"; }
{
tm=$2; gsub(/_/," ",tm); id=$3; ip=$4;
from=$5; to=$6; st=$7; sz=$8;
subj=$9; gsub(/_/," ",subj);
re=$10; gsub(/_/," ",re);
if (d_view == 1) {
printf "\n%s--- Detail for ID: %s ---%s\nDate: %s\nIP: %s\nFrom: %s\nTo: %s\nStatus: %s\nSize: %s Bytes\nSubject: %s\nReason: %s\n", b,id,r,tm,ip,from,to,st,sz,subj,re;
fflush();
} else if (csv == 1) {
printf "\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\"\n", csv_safe(tm), csv_safe(id), csv_safe(ip), csv_safe(from), csv_safe(to), csv_safe(st), csv_safe(sz), csv_safe(subj), csv_safe(re);
fflush();
} else {
scol = (st == "sent") ? g : (st == "deferred" || st == "GREYLIST" ? y : rd);
s_re = (match(re, /[0-9]{3} [0-9]\.[0-9]\.[0-9]/)) ? substr(re, RSTART, RLENGTH) : substr(re, 1, 15);
printf "%-16s %s%-12s%s %s%-16s%s %-32s %-28s %s%-10s%s %s[%s]%s\n", tm, (id=="NOQUEUE"?rd:b), id, r, gr, sprintf("%.15s", ip), r, d_cut(from, 31), d_cut(to, 27), scol, st, r, (st=="sent"?c:scol), s_re, r;
fflush();
}
}'
}
# 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 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 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})"
echo "=========================================================="
if [[ "$USE_COLOR" -eq 1 ]]; then printf "\033[0m"; fi
if [[ "$total_records" -eq 0 ]]; then
echo "No data matched your filters."
else
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
) | use_pager
exit 0
fi
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 | 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