203 lines
9.4 KiB
Bash
203 lines
9.4 KiB
Bash
#!/bin/bash
|
|
# Postfix Mail Summary Tool - Hardened & Domain-Focused Version (v2026.1.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 fallback if /var/lock is not writable
|
|
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
|
|
readonly USE_COLOR
|
|
|
|
# ==============================================================================
|
|
# 3. HELP MENU
|
|
# ==============================================================================
|
|
show_help() {
|
|
echo "Usage: $0 [OPTIONS]"
|
|
echo ""
|
|
echo "Options:"
|
|
echo " -h, --help Display this help message"
|
|
echo " -l, --list NUMBER Number of records to show (1-1000, default: 10)"
|
|
echo " -f, --from EMAIL/DOMAIN Filter by sender address"
|
|
echo " -t, --to EMAIL/DOMAIN Filter by recipient address"
|
|
echo " -i, --id QUEUE_ID Search for a specific ID (Detail View)"
|
|
echo " -d, --date \"MMM DD\" Filter by date (e.g., \"Mar 27\")"
|
|
echo " Use \"\" to search through all available logs"
|
|
exit 0
|
|
}
|
|
|
|
# ==============================================================================
|
|
# 4. ARGUMENT PARSING
|
|
# ==============================================================================
|
|
LIMIT=10; readonly MAX_LIMIT=1000; readonly MAX_INPUT_LEN=100
|
|
FROM_FILTER=""; TO_FILTER=""; ID_FILTER=""; DATE_FILTER=""; DATE_SET=0
|
|
|
|
check_arg() {
|
|
local opt="$1"
|
|
shift
|
|
if [[ "$#" -lt 1 ]]; then
|
|
echo "Error: Option '$opt' requires an argument (can be \"\")." >&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 ;;
|
|
-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 validation
|
|
(( LIMIT < 1 )) && LIMIT=1
|
|
(( LIMIT > MAX_LIMIT )) && LIMIT=$MAX_LIMIT
|
|
|
|
if [ "$DATE_SET" -eq 0 ]; then DATE_FILTER=$(date '+%b %e'); fi
|
|
readonly DATE_FILTER
|
|
DETAIL_VIEW=0; [[ -n "$ID_FILTER" && "$ID_FILTER" != "NOQUEUE" ]] && DETAIL_VIEW=1
|
|
|
|
# ==============================================================================
|
|
# 5. LOG FILE DISCOVERY
|
|
# ==============================================================================
|
|
LOG_FILES=()
|
|
while IFS= read -r -d '' file; do LOG_FILES+=("$file"); done < <(find /var/log -maxdepth 1 -name 'maillog*' -type f -print0 2>/dev/null | sort -z -r)
|
|
[ ${#LOG_FILES[@]} -eq 0 ] && { echo "Error: No Postfix logs found." >&2; exit 1; }
|
|
|
|
# ==============================================================================
|
|
# 6. LOG PROCESSING (AWK ENGINE)
|
|
# ==============================================================================
|
|
process_logs() {
|
|
for log in "${LOG_FILES[@]}"; do
|
|
[ ! -r "$log" ] && continue
|
|
if [[ "$log" =~ \.gz$ ]]; then
|
|
gzip -t "$log" 2>/dev/null && zcat -- "$log"
|
|
else
|
|
cat -- "$log"
|
|
fi
|
|
done
|
|
}
|
|
|
|
process_logs | awk -v d_filt="$DATE_FILTER" -v f_filt="$FROM_FILTER" -v t_filt="$TO_FILTER" -v i_filt="$ID_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 }
|
|
{
|
|
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)) {
|
|
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)) {
|
|
print sort_k " " curr_t " NOQUEUE " nip " " f " " t " " s_st " " s_re;
|
|
}
|
|
}
|
|
}' > "$TMP_DATA"
|
|
|
|
# ==============================================================================
|
|
# 7. FINAL OUTPUT RENDERING
|
|
# ==============================================================================
|
|
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
|
|
[[ "$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 |