#!/usr/bin/perl

##########################################################################
# Amavis-logwatch: written and maintained by:
#
#    Mike "MrC" Cappella <mike (at) cappella (dot) us>
#      http://logreporters.sourceforge.net/
#
# Please send all comments, suggestions, bug reports regarding this
# program/module to the email address above.  I will respond as quickly
# as possible. [MrC]
#
# Questions regarding the logwatch program itself should be directed to
# the logwatch project at:
#   http://sourceforge.net/projects/logwatch/support
#
#######################################################
### All work since Dec 12, 2006 (logwatch CVS revision 1.28)
### Copyright (c) 2006-2012  Mike Cappella
### 
### Covered under the included MIT/X-Consortium License:
###    http://www.opensource.org/licenses/mit-license.php
### All modifications and contributions by other persons to
### this script are assumed to have been donated to the
### Logwatch project and thus assume the above copyright
### and licensing terms.  If you want to make contributions
### under your own copyright or a different license this
### must be explicitly stated in the contribution an the
### Logwatch project reserves the right to not accept such
### contributions.  If you have made significant
### contributions to this script and want to claim
### copyright please contact logwatch-devel@lists.sourceforge.net.
##########################################################

##########################################################################
# The original amavis logwatch filter was written by
# Jim O'Halloran <jim @ kendle.com.au>, and has had many contributors over
# the years.
#
# CVS log removed: see Changes file for amavis-logwatch at
#    http://logreporters.sourceforge.net/
# or included with the standalone amavis-logwatch distribution
##########################################################################

package Logreporters;
use 5.008;
use strict;
use warnings;
no warnings "uninitialized";
use re 'taint';

our $Version         = '1.51.03';
our $progname_prefix = 'amavis';

# Specifies the default configuration file for use in standalone mode.
my $config_file = "/usr/local/etc/${progname_prefix}-logwatch.conf";

#MODULE: ../Logreporters/Utils.pm
package Logreporters::Utils;

use 5.008;
use strict;
use re 'taint';
use warnings;

BEGIN {
   use Exporter ();
   use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
   $VERSION = '1.003';
   @ISA = qw(Exporter);
   @EXPORT = qw(&formathost &get_percentiles &get_percentiles2 &get_frequencies &commify &unitize
                &get_usable_sectvars &add_section &begin_section_group &end_section_group
                &get_version &unique_list);
   @EXPORT_OK = qw(&gen_test_log);
}

use subs qw (@EXPORT @EXPORT_OK);


# Formats IP and hostname for even column spacing
#
sub formathost($ $) {
   # $_[0] : hostip
   # $_[1] : hostname;

   if (! $Logreporters::Config::Opts{'unknown'} and $_[1] eq 'unknown') {
      return $_[0];
   }

   return sprintf "%-$Logreporters::Config::Opts{'ipaddr_width'}s  %s",
      $_[0] eq '' ? '*unknown' :    $_[0],
      $_[1] eq '' ? '*unknown' : lc $_[1];
}

# Add a new section to the end of a section table
#
sub add_section($$$$$;$) {
   my $sref = shift;
   die "Improperly specified Section entry: $_[0]" if !defined $_[3];

   my $entry  = {
      CLASS     => 'DATA',
      NAME      => $_[0],
      DETAIL    => $_[1],
      FMT       => $_[2],
      TITLE     => $_[3],
   };
   $entry->{'DIVISOR'}   = $_[4] if defined $_[4];
   push @$sref, $entry;
}

{
my $group_level = 0;

# Begin a new section group.  Groups can nest.
#
sub begin_section_group($;@) {
   my $sref = shift;
   my $group_name = shift;
   my $entry  = {
      CLASS     => 'GROUP_BEGIN',
      NAME      => $group_name,
      LEVEL     => ++$group_level,
      HEADERS   => [ @_ ],
   };
   push @$sref, $entry;
}

# Ends a section group.
#
sub end_section_group($;@) {
   my $sref = shift;
   my $group_name = shift;
   my $entry  = {
      CLASS     => 'GROUP_END',
      NAME      => $group_name,
      LEVEL     => --$group_level,
      FOOTERS   => [ @_ ],
   };
   push @$sref, $entry;
}
}

# Generate and return a list of section table entries or
# limiter key names, skipping any formatting entries.
# If 'namesonly' is set, limiter key names are returned,
# otherwise an array of section array records is returned.
sub get_usable_sectvars(\@ $) {
   my ($sectref,$namesonly) = @_;
   my (@sect_list, %unique_names);

   foreach my $sref (@$sectref) {
      #print "get_usable_sectvars: $sref->{NAME}\n";
      next unless $sref->{CLASS} eq 'DATA';
      if ($namesonly) {
         $unique_names{$sref->{NAME}} = 1;
      }
      else {
         push @sect_list, $sref;
      }
   }
   # return list of unique names
   if ($namesonly) {
      return keys %unique_names;
   }
   return @sect_list;
}

# Print program and version info, preceeded by an optional string, and exit.
# 
sub get_version() {

   print STDOUT "@_\n"  if ($_[0]);
   print STDOUT "$Logreporters::progname: $Logreporters::Version\n";
   exit 0;
}


# Returns a list of percentile values given a
# sorted array of numeric values.  Uses the formula:
#
# r = 1 + (p(n-1)/100) = i + d  (Excel method)
#
# r = rank
# p = desired percentile
# n = number of items
# i = integer part
# d = decimal part
#
# Arg1 is an array ref to the sorted series
# Arg2 is a list of percentiles to use

sub get_percentiles(\@ @) {
   my ($aref,@plist) = @_;
   my ($n, $last, $r, $d, $i, @vals, $Yp);

   $last = $#$aref;
   $n = $last + 1;
   #printf "%6d" x $n . "\n", @{$aref};

   #printf "n: %4d, last: %d\n", $n, $last;
   foreach my $p (@plist) {
      $r = 1 + ($p * ($n - 1) / 100.0);
      $i = int ($r);		# integer part
      # domain: $i = 1 .. n
      if ($i == $n) {
        $Yp = $aref->[$last];
      }
      elsif ($i == 0) {
        $Yp = $aref->[0];
        print "CAN'T HAPPEN: $Yp\n";
      }
      else {
         $d = $r - $i;		# decimal part
	 #p = Y[i] + d(Y[i+1] - Y[i]), but since we're 0 based, use i=i-1
         $Yp = $aref->[$i-1] + ($d * ($aref->[$i] - $aref->[$i-1]));
      }
      #printf "\np(%6.2f), r: %6.2f, i: %6d, d: %6.2f, Yp: %6d", $p, $r, $i, $d, $Yp;
      push @vals, $Yp;
   }

   return @vals;
}

sub get_num_scores($) {
   my $scoretab_r = shift;

   my $totalscores = 0;

   for (my $i = 0; $i < @$scoretab_r; $i += 2) {
      $totalscores += $scoretab_r->[$i+1]
   }

   return $totalscores;
}

# scoretab
#
#  (score1, n1), (score2, n2), ... (scoreN, nN) 
#     $i   $i+1
#
# scores are 0 based (0 = 1st score)
sub get_nth_score($ $) {
   my ($scoretab_r, $n) = @_;

   my $i = 0;
   my $n_cur_scores = 0;
   #print "Byscore (", .5 * @$scoretab_r, "): "; for (my $i = 0; $i < $#$scoretab_r / 2; $i++) { printf "%9s (%d) ", $scoretab_r->[$i], $scoretab_r->[$i+1]; } ; print "\n";

   while ($i < $#$scoretab_r) {
      #print "Samples_seen: $n_cur_scores\n";
      $n_cur_scores += $scoretab_r->[$i+1];
      if ($n_cur_scores >= $n) {
         #printf "range: %s  %s  %s\n", $i >= 2 ? $scoretab_r->[$i - 2] : '<begin>', $scoretab_r->[$i], $i+2 > $#$scoretab_r ? '<end>' : $scoretab_r->[$i + 2];
         #printf "n: $n, i: %8d, n_cur_scores: %8d, score: %d x %d hits\n", $i, $n_cur_scores, $scoretab_r->[$i], $scoretab_r->[$i+1];
         return $scoretab_r->[$i];
      }

      $i += 2;
   }
   print "returning last score $scoretab_r->[$i]\n";
   return $scoretab_r->[$i];
}

sub get_percentiles2(\@ @) {
   my ($scoretab_r, @plist) = @_;
   my ($n, $last, $r, $d, $i, @vals, $Yp);

   #$last = $#$scoretab_r - 1;
   $n = get_num_scores($scoretab_r);
   #printf "\n%6d" x $n . "\n", @{$scoretab_r};

   #printf "\n\tn: %4d, @$scoretab_r\n", $n;
   foreach my $p (@plist) {
  ###print "\nPERCENTILE: $p\n";
      $r = 1 + ($p * ($n - 1) / 100.0);
      $i = int ($r);		# integer part
      if ($i == $n) {
        #print "last:\n";
        #$Yp = $scoretab_r->[$last];
        $Yp = get_nth_score($scoretab_r, $n);
      }
      elsif ($i == 0) {
        #$Yp = $scoretab_r->[0];
        print "1st: CAN'T HAPPEN\n";
        $Yp = get_nth_score($scoretab_r, 1);
      }
      else {
         $d = $r - $i;		# decimal part
	 #p = Y[i] + d(Y[i+1] - Y[i]), but since we're 0 based, use i=i-1
         my $ithvalprev = get_nth_score($scoretab_r, $i);
         my $ithval     = get_nth_score($scoretab_r, $i+1);
         $Yp = $ithvalprev + ($d * ($ithval - $ithvalprev));
      }
      #printf "p(%6.2f), r: %6.2f, i: %6d, d: %6.2f, Yp: %6d\n", $p, $r, $i, $d, $Yp;
      push @vals, $Yp;
   }

   return @vals;
}



# Returns a list of frequency distributions given an incrementally sorted
# set of sorted scores, and an incrementally sorted list of buckets
#
# Arg1 is an array ref to the sorted series
# Arg2 is a list of frequency buckets to use
sub get_frequencies(\@ @) { 
   my ($aref,@blist) = @_;

   my @vals = ( 0 ) x (@blist);
   my @sorted_blist = sort { $a <=> $b } @blist;
   my $bucket_index = 0;

OUTER: foreach my $score (@$aref) {
      #print "Score: $score\n";
      for my $i ($bucket_index .. @sorted_blist - 1) {
         #print "\tTrying Bucket[$i]: $sorted_blist[$i]\n";
         if ($score > $sorted_blist[$i]) {
            $bucket_index++;
         }
         else {
            #printf "\t\tinto Bucket[%d]\n", $bucket_index;
            $vals[$bucket_index]++;
            next OUTER;
         }
      }
      #printf "\t\tinto Bucket[%d]\n", $bucket_index - 1;
      $vals[$bucket_index - 1]++;
   }

   return @vals;
}

# Inserts commas in numbers for easier readability
#
sub commify ($) {
    return undef if ! defined ($_[0]);

    my $text = reverse $_[0];
    $text =~ s/(\d\d\d)(?=\d)(?!\d*\.)/$1,/g;
    return scalar reverse $text;
}

# Unitize a number, and return appropriate printf formatting string
#
sub unitize($ $) {
   my ($num, $fmt) = @_;
   my $kilobyte = 2**10;
   my $megabyte = 2**20;
   my $gigabyte = 2**30;
   my $terabyte = 2**40;

   if ($num >= $terabyte) {
      $num /= $terabyte;
      $fmt .= '.3fT';
   } elsif ($num >= $gigabyte) {
      $num /= $gigabyte;
      $fmt .= '.3fG';
   } elsif ($num >= $megabyte) {
      $num /= $megabyte;
      $fmt .= '.3fM';
   } elsif ($num >= $kilobyte) {
      $num /= $kilobyte;
      $fmt .= '.3fK';
   } else {
      $fmt .= 'd ';
   }

   return ($num, $fmt);
}

# Returns a sublist of the supplied list of elements in an unchanged order,
# where only the first occurrence of each defined element is retained
# and duplicates removed
#
# Borrowed from amavis 2.6.2
#
sub unique_list(@) {
   my ($r) = @_ == 1 && ref($_[0]) ? $_[0] : \@_;  # accept list, or a list ref
   my (%seen);
   my (@unique) = grep { defined($_) && !$seen{$_}++ } @$r;

   return @unique;
}

# Generate a test maillog file from the '#TD' test data lines
# The test data file is placed in /var/tmp/maillog.autogen
#
# arg1: "postfix" or "amavis"
# arg2: path to postfix-logwatch or amavis-logwatch from which to read '#TD' data
#
# Postfix TD syntax:
#    TD<service><QID>(<count>) log entry
#
sub gen_test_log($) {
   my $scriptpath = shift;

   my $toolname = $Logreporters::progname_prefix;
   my $datafile = "/var/tmp/maillog-${toolname}.autogen";

   die "gen_test_log: invalid toolname $toolname"  if ($toolname !~ /^(postfix|amavis)$/);

   eval {
      require Sys::Hostname;
      require Fcntl;
   } or die "Unable to create test data file: required module(s) not found\n$@";

   my $syslogtime = localtime;
   $syslogtime =~ s/^....(.*) \d{4}$/$1/;

   my ($hostname) = split /\./, Sys::Hostname::hostname();

  # # avoid -T issues
  # delete @ENV{'IFS', 'CDPATH', 'ENV', 'BASH_ENV'};

   my $flags = &Fcntl::O_CREAT|&Fcntl::O_WRONLY|&Fcntl::O_TRUNC;
   sysopen(FH, $datafile, $flags) or die "Can't create test data file: $!";
   print "Generating test log data file from $scriptpath: $datafile\n";

   my $id;
   @ARGV = ($scriptpath);
   if ($toolname eq 'postfix') {
      my %services = (
          DEF   => 'smtpd',
          bQ    => 'bounce',
          cN    => 'cleanup',
          cQ    => 'cleanup',
          lQ    => 'local',
          m     => 'master',
          p     => 'pickup',
          pQ    => 'pickup',
          ppQ   => 'pipe',
          pfw   => 'postfwd',
          pg    => 'postgrey',
          pgQ   => 'postgrey',
          ps    => 'postsuper',
          qQ    => 'qmgr',
          s     => 'smtp',
          sQ    => 'smtp',
          sd    => 'smtpd',
          sdN   => 'smtpd',
          sdQ   => 'smtpd',
          spf   => 'policy-spf',
          vN    => 'virtual',
          vQ    => 'virtual',
      );
      $id = 'postfix/smtp[12345]';

      while (<>) {
         if (/^\s*#TD([a-zA-Z]*[NQ]?)(\d+)?(?:\(([^)]+)\))? (.*)$/) {
            my ($service,$count,$qid,$line) = ($1, $2, $3, $4);

            #print "SERVICE: %s, QID: %s, COUNT: %s, line: %s\n", $service, $qid, $count, $line;

            if ($service eq '') {
               $service = 'DEF';
            }
            die ("No such service: \"$service\": line \"$_\"")  if (!exists $services{$service});

            $id = $services{$service} . '[123]';
            $id = 'postfix/' . $id    unless $services{$service} eq 'postgrey';
            #print "searching for service: \"$service\"\n\tFound $id\n";
            if    ($service =~ /N$/) { $id .= ': NOQUEUE'; }
            elsif ($service =~ /Q$/) { $id .= $qid ? $qid : ': DEADBEEF'; }

            $line =~ s/ +/ /g;
            $line =~ s/^ //g;
            #print "$syslogtime $hostname $id: \"$line\"\n" x ($count ? $count : 1);
            print FH "$syslogtime $hostname $id: $line\n" x ($count ? $count : 1);
         }
      }
   }
   else { #amavis
      my %services = (
          DEF   => 'amavis',
          dcc   => 'dccproc',
      );
      while (<>) {
         if (/^\s*#TD([a-z]*)(\d+)? (.*)$/) {
            my ($service,$count,$line) = ($1, $2, $3);
            if ($service eq '') {
               $service = 'DEF';
            }
            die ("No such service: \"$service\": line \"$_\"")  if (!exists $services{$service});
            $id = $services{$service} . '[123]:';
            if ($services{$service} eq 'amavis') {
               $id .= ' (9999-99)';
            }
            print FH "$syslogtime $hostname $id $line\n" x ($count ? $count : 1)
         }
      }
   }

   close FH or die "Can't close $datafile: $!";
}

1;

#MODULE: ../Logreporters/Config.pm
package Logreporters::Config;

use 5.008;
use strict;
use re 'taint';
use warnings;


BEGIN {
   use Exporter ();
   use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
   $VERSION = '1.002';
   @ISA = qw(Exporter);
   @EXPORT = qw(&init_run_mode &add_option &get_options &init_cmdline &get_vars_from_file
                &process_limiters &process_debug_opts &init_getopts_table_common &zero_opts
                @Optspec %Opts %Configvars @Limiters %line_styles $fw1 $fw2 $sep1 $sep2
                &D_CONFIG &D_ARGS &D_VARS &D_TREE &D_SECT &D_UNMATCHED &D_TEST &D_ALL
             );
}

use subs @EXPORT;

our  @Optspec = ();      # options table used by Getopts

our %Opts = ();         # program-wide options
our %Configvars = ();   # configuration file variables
our @Limiters;

# Report separator characters and widths
our ($fw1,$fw2)   = (22, 10);
our ($sep1,$sep2) = ('=', '-');

use Getopt::Long;


BEGIN {
   import Logreporters::Utils qw(&get_usable_sectvars);
}

our %line_styles = (
   truncate => 0,
   wrap     => 1,
   full     => 2,
);

sub init_run_mode($);
sub confighash_to_cmdline(\%);
sub get_vars_from_file(\% $);
sub process_limiters(\@);
sub add_option(@);
sub get_options($);
sub init_getopts_table_common(@);
sub set_supplemental_reports($$);
# debug constants
sub D_CONFIG ()    { 1<<0 }
sub D_ARGS ()      { 1<<1 }
sub D_VARS ()      { 1<<2 }
sub D_TREE ()      { 1<<3 }
sub D_SECT ()      { 1<<4 }
sub D_UNMATCHED () { 1<<5 }

sub D_TEST ()      { 1<<30 }
sub D_ALL ()       { 1<<31 }

my %debug_words = (
   config     => D_CONFIG,
   args       => D_ARGS,
   vars       => D_VARS,
   tree       => D_TREE,
   sect       => D_SECT,
   unmatched  => D_UNMATCHED,

   test       => D_TEST,
   all        => 0xffffffff,
);

# Clears %Opts hash and initializes basic running mode options in
# %Opts hash by setting keys: 'standalone', 'detail', and 'debug'.
# Call early.
#
sub init_run_mode($) {
   my $config_file = shift;
   $Opts{'debug'} = 0;

   # Logwatch passes a filter's options via environment variables.
   # When running standalone (w/out logwatch), use command line options
   $Opts{'standalone'} = exists ($ENV{LOGWATCH_DETAIL_LEVEL}) ? 0 : 1;

   # Show summary section by default
   $Opts{'summary'} = 1;

   if ($Opts{'standalone'}) {
      process_debug_opts($ENV{'LOGREPORTERS_DEBUG'}) if exists ($ENV{'LOGREPORTERS_DEBUG'});
   }
   else {
      $Opts{'detail'} = $ENV{'LOGWATCH_DETAIL_LEVEL'};
      # XXX
      #process_debug_opts($ENV{'LOGWATCH_DEBUG'}) if exists ($ENV{'LOGWATCH_DEBUG'});
   }

   # first process --debug, --help, and --version options
   add_option ('debug=s',                   sub { process_debug_opts($_[1]); 1});
   add_option ('version',                   sub { &Logreporters::Utils::get_version(); 1;});
   get_options(1);

   # now process --config_file, so that all config file vars are read first
   add_option ('config_file|f=s',           sub { get_vars_from_file(%Configvars, $_[1]); 1;});
   get_options(1);

   # if no config file vars were read
   if ($Opts{'standalone'} and ! keys(%Configvars) and -f $config_file) {
      print "Using default config file: $config_file\n" if $Opts{'debug'} & D_CONFIG;
      get_vars_from_file(%Configvars, $config_file);
   }
}

sub get_options($) {
   my $pass_through = shift;
   #$SIG{__WARN__} = sub { print "*** $_[0]*** options error\n" };
   # ensure we're called after %Opts is initialized
   die "get_options: program error: %Opts is emtpy" unless exists $Opts{'debug'};

   my $p = new Getopt::Long::Parser;

   if ($pass_through) {
      $p->configure(qw(pass_through permute));
   }
   else {
      $p->configure(qw(no_pass_through no_permute));
   }
   #$p->configure(qw(debug));

   if ($Opts{'debug'} & D_ARGS) {
      print "\nget_options($pass_through): enter\n";
      printf "\tARGV(%d): ", scalar @ARGV;
      print @ARGV, "\n";
      print "\t$_ ", defined $Opts{$_} ? "=> $Opts{$_}\n" : "\n"  foreach sort keys %Opts;
   }

   if ($p->getoptions(\%Opts, @Optspec) == 0) {
      print STDERR "Use ${Logreporters::progname} --help for options\n";
      exit 1;
   }
   if ($Opts{'debug'} & D_ARGS) {
      print "\t$_ ", defined $Opts{$_} ? "=> $Opts{$_}\n" : "\n"  foreach sort keys %Opts;
      printf "\tARGV(%d): ", scalar @ARGV;
      print @ARGV, "\n";
      print "get_options: exit\n";
   }
}

sub add_option(@) {
   push @Optspec, @_;
}

# untaint string, borrowed from amavisd-new
sub untaint($) {
   no re 'taint';

   my ($str);
   if (defined($_[0])) {
      local($1);            # avoid Perl taint bug: tainted global $1 propagates taintedness
      $str = $1  if $_[0] =~ /^(.*)$/;
   }

   return $str;
}

sub init_getopts_table_common(@) {
   my @supplemental_reports = @_;

   print "init_getopts_table_common: enter\n"   if $Opts{'debug'} & D_ARGS;

   add_option ('help',                       sub { print STDOUT Logreporters::usage(undef); exit 0 });
   add_option ('gen_test_log=s',             sub { Logreporters::Utils::gen_test_log($_[1]); exit 0; });
   add_option ('detail=i');
   add_option ('nodetail',                   sub { 
      # __none__ will set all limiters to 0 in process_limiters
      # since they are not known (Sections table is not yet built).
      push @Limiters, '__none__';
      # 0 = disable supplemental_reports
      set_supplemental_reports(0, \@supplemental_reports);
   });
   add_option ('max_report_width=i');
   add_option ('summary!');
   add_option ('show_summary=i',             sub { $Opts{'summary'} = $_[1]; 1; });
   # untaint ipaddr_width for use w/sprintf() in Perl v5.10
   add_option ('ipaddr_width=i',             sub { $Opts{'ipaddr_width'} = untaint ($_[1]); 1; });

   add_option ('sect_vars!');
   add_option ('show_sect_vars=i',           sub { $Opts{'sect_vars'} = $_[1]; 1; });

   add_option ('syslog_name=s');
   add_option ('wrap',                       sub { $Opts{'line_style'} = $line_styles{$_[0]}; 1; });
   add_option ('full',                       sub { $Opts{'line_style'} = $line_styles{$_[0]}; 1; });
   add_option ('truncate',                   sub { $Opts{'line_style'} = $line_styles{$_[0]}; 1; });
   add_option ('line_style=s',               sub {
      my $style = lc($_[1]);
      my @list = grep (/^$style/, keys %line_styles);
      if (! @list) {
         print STDERR "Invalid line_style argument \"$_[1]\"\n";
         print STDERR "Option line_style argument must be one of \"wrap\", \"full\", or \"truncate\".\n";
         print STDERR "Use $Logreporters::progname --help for options\n";
         exit 1;
      }
      $Opts{'line_style'} = $line_styles{lc($list[0])};
      1;
   });

   add_option ('limit|l=s',                 sub {
      my ($limiter,$lspec) = split(/=/, $_[1]);
      if (!defined $lspec) {
         printf STDERR "Limiter \"%s\" requires value (ex. --limit %s=10)\n", $_[1],$_[1];
         exit 2;
      }
      foreach my $val (split(/(?:\s+|\s*,\s*)/, $lspec)) {
         if ($val !~ /^\d+$/ and 
             $val !~ /^(\d*)\.(\d+)$/ and
             $val !~ /^::(\d+)$/ and
             $val !~ /^:(\d+):(\d+)?$/ and
             $val !~ /^(\d+):(\d+)?:(\d+)?$/)
         {
            printf STDERR "Limiter value \"$val\" invalid in \"$limiter=$lspec\"\n";
            exit 2;
         }
      }
      push @Limiters, lc $_[1];
   });

   print "init_getopts_table_common: exit\n"   if $Opts{'debug'} & D_ARGS;
}

sub get_option_names() {
   my (@ret, @tmp);
   foreach (@Optspec) {
      if (ref($_) eq '') {       # process only the option names
         my $spec = $_;
         $spec =~ s/=.*$//;
         $spec =~ s/([^|]+)\!$/$1|no$1/g;
         @tmp = split /[|]/, $spec;
         #print "PUSHING: @tmp\n";
         push @ret, @tmp;
      }
   }
   return @ret;
}

# Set values for the configuration variables passed via hashref.
# Variables are of the form ${progname_prefix}_KEYNAME.
#
# Because logwatch lowercases all config file entries, KEYNAME is
# case-insensitive.
#
sub init_cmdline() {
   my ($href, $configvar, $value, $var);

   # logwatch passes all config vars via environment variables
   $href = $Opts{'standalone'} ? \%Configvars : \%ENV;

   # XXX: this is cheeze: need a list of valid limiters, but since
   # the Sections table is not built yet, we don't know what is
   # a limiter and what is an option, as there is no distinction in
   # variable names in the config file (perhaps this should be changed).
   my @valid_option_names = get_option_names();
   die "Options table not yet set" if ! scalar @valid_option_names;

   print "confighash_to_cmdline: @valid_option_names\n"  if $Opts{'debug'} & D_ARGS;
   my @cmdline = ();
   while (($configvar, $value) = each %$href) {
      if ($configvar =~ s/^${Logreporters::progname_prefix}_//o) {
         # distinguish level limiters from general options
         # would be easier if limiters had a unique prefix
         $configvar = lc $configvar;
         my $ret = grep (/^$configvar$/i, @valid_option_names);
         if ($ret == 0) {
            print "\tLIMITER($ret): $configvar = $value\n"  if $Opts{'debug'} & D_ARGS;
            push @cmdline, '-l', "$configvar" . "=$value";
         }
         else {
            print "\tOPTION($ret): $configvar = $value\n"  if $Opts{'debug'} & D_ARGS;
            unshift @cmdline, $value  if defined ($value);
            unshift @cmdline, "--$configvar";
         }
      }
   }
   unshift @ARGV, @cmdline;
}

# Obtains the variables from a logwatch-style .conf file, for use
# in standalone mode.  Returns an ENV-style hash of key/value pairs.
#
sub get_vars_from_file(\% $) {
   my ($href, $file) = @_;
   my ($var, $val);

   print "get_vars_from_file: enter: processing file: $file\n" if $Opts{'debug'} & D_CONFIG;

   my  $message = undef;
   my $ret = stat ($file);
   if ($ret == 0) { $message = $!; }
   elsif (! -r _) { $message = "Permission denied"; }
   elsif (  -d _) { $message = "Is a directory"; }
   elsif (! -f _) { $message = "Not a regular file"; }

   if ($message) {
      print STDERR "Configuration file \"$file\": $message\n";
      exit 2;
   }

   my $prog = $Logreporters::progname_prefix;
   open FILE, '<', "$file" or die "unable to open configuration file $file: $!";
   while (<FILE>) {
      chomp;
      next if (/^\s*$/);   # ignore all whitespace lines
      next if (/^\*/);     # ignore logwatch's *Service lines
      next if (/^\s*#/);   # ignore comment lines
      if (/^\s*\$(${prog}_[^=\s]+)\s*=\s*"?([^"]+)"?$/o) {
         ($var,$val) = ($1,$2);
         if    ($val =~ /^(?:no|false)$/i) { $val = 0; }
         elsif ($val =~ /^(?:yes|true)$/i) { $val = 1; }
         elsif ($val eq '')                { $var =~ s/${prog}_/${prog}_no/; $val = undef; }

         print "\t\"$var\" => \"$val\"\n"  if $Opts{'debug'} & D_CONFIG;

         $href->{$var} = $val;
      }
   }
   close FILE         or die "failed to close configuration handle for $file: $!";
   print "get_vars_from_file: exit\n" if $Opts{'debug'} & D_CONFIG;
}

sub process_limiters(\@) {
   my ($sectref) = @_;

   my ($limiter, $var, $val, @errors);
   my @l = get_usable_sectvars(@$sectref, 1);

   if ($Opts{'debug'} & D_VARS) {
      print "process_limiters: enter\n";
      print "\tLIMITERS: @Limiters\n";
   }
   while ($limiter = shift @Limiters) {
      my @matched = ();

      printf "\t%-30s  ",$limiter   if $Opts{'debug'} & D_VARS;
      # disable all limiters when limiter is __none__: see 'nodetail' cmdline option
      if ($limiter eq '__none__') {
         $Opts{$_} = 0 foreach @l;
         next;
      }

      ($var,$val) = split /=/, $limiter;

      if ($val eq '') {
         push @errors, "Limiter \"$var\" requires value (ex. --limit limiter=10)";
         next;
      }

      # try exact match first, then abbreviated match next
      if (scalar (@matched = grep(/^$var$/, @l)) == 1 or scalar (@matched = grep(/^$var/, @l)) == 1) {
         $limiter = $matched[0];    # unabbreviate limiter
         print "MATCH: $var: $limiter => $val\n" if $Opts{'debug'} & D_VARS;
         # XXX move limiters into section hash entry...
         $Opts{$limiter} = $val;
         next;
      }
      print "matched=", scalar @matched, ": @matched\n" if $Opts{'debug'} & D_VARS;

      push @errors, "Limiter \"$var\" is " . (scalar @matched == 0 ? "invalid" : "ambiguous: @matched");
   }
   print "\n" if $Opts{'debug'} & D_VARS;

   if (@errors) {
      print STDERR "$_\n" foreach @errors;
      exit 2;
   }

   # Set the default value of 10 for each section if no limiter exists.
   # This allows output for each section should there be no configuration
   # file or missing limiter within the configuration file. 
   foreach (@l) {
      $Opts{$_} = 10 unless exists $Opts{$_};
   }

   # Enable collection for each section if a limiter is non-zero.
   foreach (@l) {
      #print "L is: $_\n";
      #print "DETAIL: $Opts{'detail'}, OPTS: $Opts{$_}\n";
      $Logreporters::TreeData::Collecting{$_} = (($Opts{'detail'} >= 5) && $Opts{$_}) ? 1 : 0;
   }
   #print "OPTS: \n"; map { print "$_ => $Opts{$_}\n"} keys %Opts;
   #print "COLLECTING: \n"; map { print "$_ => $Logreporters::TreeData::Collecting{$_}\n"} keys %Logreporters::TreeData::Collecting;
}

# Enable/disable supplemental reports
# arg1:     0=off, 1=on
# arg2,...: list of supplemental report keywords
sub set_supplemental_reports($$) {
   my ($onoff,$aref) = @_;

   $Opts{$_} = $onoff foreach (@$aref);
}

sub process_debug_opts($) {
   my $optstring = shift;

   my @errors = ();
   foreach (split(/\s*,\s*/, $optstring)) {
      my $word = lc $_;
      my @matched = grep (/^$word/, keys %debug_words);

      if (scalar @matched == 1) {
         $Opts{'debug'} |= $debug_words{$matched[0]};
         next;
      }

      if (scalar @matched == 0) {
         push @errors, "Unknown debug keyword \"$word\"";
      }
      else {  # > 1
         push @errors, "Ambiguous debug keyword abbreviation \"$word\": (matches: @matched)";
      }
   }
   if (@errors) {
      print STDERR "$_\n" foreach @errors;
      print STDERR "Debug keywords: ", join (' ', sort keys %debug_words), "\n";
      exit 2;
   }
}

# Zero the options controlling level specs and those
# any others passed via Opts key.
#
# Zero the options controlling level specs in the
# Detailed section, and set all other report options
# to disabled. This makes it easy via command line to
# disable the entire summary section, and then re-enable
# one or more sections for specific reports.
#
#   eg. progname --nodetail --limit forwarded=2
#
sub zero_opts ($ @) {
   my $sectref = shift;
   # remaining args: list of Opts keys to zero

   map { $Opts{$_} = 0; print "zero_opts: $_ => 0\n" if $Opts{'debug'} & D_VARS;} @_;
   map { $Opts{$_} = 0 } get_usable_sectvars(@$sectref, 1);
}

1;

#MODULE: ../Logreporters/TreeData.pm
package Logreporters::TreeData;

use 5.008;
use strict;
use re 'taint';
use warnings;
no warnings "uninitialized";

BEGIN {
   use Exporter ();
   use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
   $VERSION = '1.001';
   @ISA = qw(Exporter);
   @EXPORT = qw(%Totals %Counts %Collecting $END_KEY);
   @EXPORT_OK = qw(&printTree &buildTree);

}

use subs @EXPORT_OK;

BEGIN {
   import Logreporters::Config qw(%line_styles);
}

# Totals and Counts are the log line accumulator hashes.
# Totals: maintains per-section grand total tallies for use in Summary section
# Counts: is a multi-level hash, which maintains per-level key totals.
our (%Totals, %Counts);

# The Collecting hash determines which sections will be captured in
# the Counts hash.  Counts are collected only if a section is enabled,
# and this hash obviates the need to test both existence and
# non-zero-ness of the Opts{'keyname'} (either of which cause capture).
# XXX The Opts hash could be used ....
our %Collecting = ();

sub buildTree(\% $ $ $ $ $);
sub printTree($ $ $ $ $);
=pod
[ a:b:c, ... ]

which would be interpreted as follows:

a = show level a detail
b = show at most b items at this level
c = minimun count that will be shown
=cut

sub printTree($ $ $ $ $) {
   my ($treeref, $lspecsref, $line_style, $max_report_width, $debug) = @_;
   my ($entry, $line);
   my $cutlength = $max_report_width - 3;

   my $topn = 0;
   foreach $entry (sort bycount @$treeref) {
      ref($entry) ne "HASH" and die "Unexpected entry in tree: $entry\n";

      #print "LEVEL: $entry->{LEVEL}, TOTAL: $entry->{TOTAL}, HASH: $entry, DATA: $entry->{DATA}\n";

      # Once the top N lines have been printed, we're done
      if ($lspecsref->[$entry->{LEVEL}]{topn}) {
         if ($topn++ >= $lspecsref->[$entry->{LEVEL}]{topn} ) {
            print '     ', '   ' x ($entry->{LEVEL} + 3), "...\n"
               unless ($debug) and do {
                     $line = '     ' . '   ' x ($entry->{LEVEL} + 3) . '...';
                     printf "%-130s L%d: topn reached(%d)\n", $line, $entry->{LEVEL} + 1, $lspecsref->[$entry->{LEVEL}]{topn};
               };
            last;
         }
      }

      # Once the item's count falls below the given threshold, we're done at this level
      # unless a top N is specified, as threshold has lower priority than top N
      elsif ($lspecsref->[$entry->{LEVEL}]{threshold}) {
         if ($entry->{TOTAL} <= $lspecsref->[$entry->{LEVEL}]{threshold}) {
            print '     ', '   ' x ($entry->{LEVEL} + 3), "...\n"
               unless ($debug) and do {
                  $line = '     ' . ('   ' x ($entry->{LEVEL} + 3)) . '...';
                  printf "%-130s L%d: threshold reached(%d)\n", $line, $entry->{LEVEL} + 1, $lspecsref->[$entry->{LEVEL}]{threshold};
               };
            last;
         }
      }

      $line = sprintf "%8d%s%s", $entry->{TOTAL}, '   ' x ($entry->{LEVEL} + 2),  $entry->{DATA};

      if ($debug) {
         printf "%-130s %-60s\n", $line, $entry->{DEBUG};
      }

      # line_style full, or lines < max_report_width

      #printf "MAX: $max_report_width, LEN: %d, CUTLEN $cutlength\n", length($line);
      if ($line_style == $line_styles{'full'} or length($line) <= $max_report_width) {
         print $line, "\n";
      }
      elsif ($line_style == $line_styles{'truncate'}) {
         print substr ($line,0,$cutlength), '...', "\n";
      }
      elsif ($line_style == $line_styles{'wrap'}) {
         my $leader = ' ' x 8 . '   ' x ($entry->{LEVEL} + 2);
         print substr ($line, 0, $max_report_width, ''), "\n";
         while (length($line)) {
            print $leader, substr ($line, 0, $max_report_width - length($leader), ''), "\n";
         }
      }
      else {
         die ('unexpected line style');
      }

      printTree ($entry->{CHILDREF}, $lspecsref, $line_style, $max_report_width, $debug)   if (exists $entry->{CHILDREF});
   }
}

my $re_IP_strict = qr/\b(25[0-5]|2[0-4]\d|[01]?\d{1,2})\.(25[0-5]|2[0-4]\d|[01]?\d{1,2})\.(25[0-5]|2[0-4]\d|[01]?\d{1,2})\.(25[0-5]|2[0-4]\d|[01]?\d{1,2})\b/;
# XXX optimize this using packed default sorting.  Analysis shows speed isn't an issue though
sub bycount {
   # Sort by totals, then IP address if one exists, and finally by data as a string

   local $SIG{__WARN__} = sub { print "*** PLEASE REPORT:\n*** $_[0]*** Unexpected: \"$a->{DATA}\", \"$b->{DATA}\"\n" };

   $b->{TOTAL} <=> $a->{TOTAL}

      ||

   pack('C4' => $a->{DATA} =~ /^$re_IP_strict/o) cmp pack('C4' => $b->{DATA} =~ /^$re_IP_strict/o)

      ||

   $a->{DATA} cmp $b->{DATA}
}

#
# Builds a tree of REC structures from the multi-key %Counts hashes
#
# Parameters:
#    Hash:  A multi-key hash, with keys being used as category headings, and leaf data
#           being tallies for that set of keys
#    Level: This current recursion level.  Call with 0.
#
# Returns:
#    Listref: A listref, where each item in the list is a rec record, described as:
#           DATA:      a string: a heading, or log data
#           TOTAL:     an integer: which is the subtotal of this item's children
#           LEVEL:     an integer > 0: representing this entry's level in the tree
#           CHILDREF:  a listref: references a list consisting of this node's children
#    Total: The cummulative total of items found for a given invocation
#
# Use the special key variable $END_KEY, which is "\a\a" (two ASCII bell's) to end a,
# nested hash early, or the empty string '' may be used as the last key.

our $END_KEY = "\a\a";

sub buildTree(\% $ $ $ $ $) {
   my ($href, $max_level_section, $levspecref, $max_level_global, $recurs_level, $show_unique, $debug) = @_;
   my ($subtotal, $childList, $rec);

   my @treeList = ();
   my $total = 0;

   foreach my $item (sort keys %$href) {
      if (ref($href->{$item}) eq "HASH") {
         #print " " x ($recurs_level * 4), "HASH: LEVEL $recurs_level: Item: $item, type: \"", ref($href->{$item}), "\"\n";

         ($subtotal, $childList) = buildTree (%{$href->{$item}}, $max_level_section, $levspecref, $max_level_global, $recurs_level + 1, $debug);

         if ($recurs_level < $max_level_global and $recurs_level < $max_level_section) {
            # me + children
            $rec = {
               DATA     => $item,
               TOTAL    => $subtotal,
               LEVEL    => $recurs_level,
               CHILDREF => $childList,
            };

            if ($debug) {
               $rec->{DEBUG} = sprintf "L%d: levelspecs: %2d/%2d/%2d/%2d, Count: %10d",
                     $recurs_level + 1, $max_level_global, $max_level_section,
                     $levspecref->[$recurs_level]{topn}, $levspecref->[$recurs_level]{threshold}, $subtotal;
            }
            push (@treeList, $rec);
         }
      }
      else {
         if ($item ne '' and $item ne $END_KEY and $recurs_level < $max_level_global and $recurs_level < $max_level_section) {
            $rec = {
               DATA  => $item,
               TOTAL => $href->{$item},
               LEVEL => $recurs_level,
               #CHILDREF => undef,
            };
            if ($debug) {
               $rec->{DEBUG} = sprintf "L%d: levelspecs: %2d/%2d/%2d/%2d, Count: %10d",
                     $recurs_level, $max_level_global, $max_level_section,
                     $levspecref->[$recurs_level]{topn}, $levspecref->[$recurs_level]{threshold}, $href->{$item};
            }
            push (@treeList,  $rec);
         }
         $subtotal = $href->{$item};
      }

      $total += $subtotal;
   }

   #print " " x ($recurs_level * 4), "LEVEL $recurs_level: Returning from recurs_level $recurs_level\n";

   return ($total, \@treeList);
}

1;

#MODULE: ../Logreporters/Reports.pm
package Logreporters::Reports;

use 5.008;
use strict;
use re 'taint';
use warnings;
no warnings "uninitialized";

BEGIN {
   use Exporter ();
   use vars qw(@ISA @EXPORT @EXPORT_OK %EXPORT_TAGS $VERSION);
   $VERSION = '1.002';
   @ISA = qw(Exporter);
   @EXPORT = qw(&inc_unmatched &print_unmatched_report &print_percentiles_report2
                &print_summary_report &print_detail_report);
   @EXPORT_OK = qw();
}

use subs @EXPORT_OK;

BEGIN {
   import Logreporters::Config qw(%Opts $fw1 $fw2 $sep1 $sep2 &D_UNMATCHED &D_TREE);
   import Logreporters::Utils qw(&commify &unitize &get_percentiles &get_percentiles2);
   import Logreporters::TreeData qw(%Totals %Counts &buildTree &printTree);
}

my (%unmatched_list);

our $origline;       # unmodified log line, for error reporting and debug

sub inc_unmatched($) {
   my ($id) = @_;
   $unmatched_list{$origline}++;
   print "UNMATCHED($id): \"$origline\"\n"  if $Opts{'debug'} & D_UNMATCHED;
}

# Print unmatched lines
#
sub print_unmatched_report() {
   return unless (keys %unmatched_list);

   print "\n\n**Unmatched Entries**\n";
   foreach my $line (sort {$unmatched_list{$b}<=>$unmatched_list{$a} } keys %unmatched_list) {
      printf "%8d   %s\n", $unmatched_list{$line}, $line;
   }
}

=pod
   ****** Summary ********************************************************
          2   Miscellaneous warnings 

      20621   Total messages scanned ----------------  100.00%
    662.993M  Total bytes scanned                  695,198,092
   ========   ================================================

      19664   Ham -----------------------------------   95.36%
      19630     Clean passed                            95.19%
         34     Bad header passed                        0.16%

        942   Spam ----------------------------------    4.57%
        514     Spam blocked                             2.49%
        428     Spam discarded (no quarantine)           2.08%

         15   Malware -------------------------------    0.07%
         15     Malware blocked                          0.07%


       1978   SpamAssassin bypassed 
         18   Released from quarantine 
       1982   Whitelisted           
          3   Blacklisted           
         12   MIME error            
         51   Bad header (debug supplemental) 
         28   Extra code modules loaded at runtime 
=cut
# Prints the Summary report section
#
sub print_summary_report (\@) {
   my ($sections) = @_;
   my ($keyname,$cur_level);
   my @lines;

   my $expand_header_footer = sub {
      my $line = undef;

      foreach my $horf (@_) {
         # print blank line if keyname is newline
         if ($horf eq "\n") {
            $line .= "\n";
         }
         elsif (my ($sepchar) = ($horf =~ /^(.)$/o)) {
            $line .= sprintf "%s   %s\n", $sepchar x 8, $sepchar x 50;
         }
         else {
            die "print_summary_report: unsupported header or footer type \"$horf\"";
         }
      }
      return $line;
   };

   if ($Opts{'detail'} >= 5) {
      my $header = "****** Summary ";
      print $header, '*' x ($Opts{'max_report_width'} - length $header), "\n\n";
   }

   my @headers;
   foreach my $sref (@$sections) {
      # headers and separators
      die "Unexpected Section $sref"  if (ref($sref) ne 'HASH');

      # Start of a new section group.
      # Expand and save headers to output at end of section group.
      if ($sref->{CLASS} eq 'GROUP_BEGIN') {
         $cur_level = $sref->{LEVEL};
         $headers[$cur_level] = &$expand_header_footer(@{$sref->{HEADERS}});
      }

      elsif ($sref->{CLASS} eq 'GROUP_END') {
         my $prev_level = $sref->{LEVEL};

         # If this section had lines to output, tack on headers and footers,
         # removing extraneous newlines.
         if ($lines[$cur_level]) {
            # squish multiple blank lines
            if ($headers[$cur_level] and substr($headers[$cur_level],0,1) eq "\n") {
               if ( ! defined $lines[$prev_level][-1] or $lines[$prev_level][-1] eq "\n") {
                  $headers[$cur_level] =~ s/^\n+//;
               }
            }

            push @{$lines[$prev_level]}, $headers[$cur_level]  if $headers[$cur_level];
            push @{$lines[$prev_level]}, @{$lines[$cur_level]};
            my $f = &$expand_header_footer(@{$sref->{FOOTERS}});
            push @{$lines[$prev_level]}, $f   if $f;
            $lines[$cur_level] = undef;
         }

         $headers[$cur_level] = undef;
         $cur_level = $prev_level;
      }

      elsif ($sref->{CLASS} eq 'DATA') {
         # Totals data
         $keyname = $sref->{NAME};
         if ($Totals{$keyname} > 0) {
            my ($numfmt, $desc, $divisor) = ($sref->{FMT}, $sref->{TITLE}, $sref->{DIVISOR});

            my $fmt   = '%8';
            my $extra = ' %25s';
            my $total = $Totals{$keyname};

            # Z format provides  unitized or unaltered totals, as appropriate
            if ($numfmt eq 'Z') {
               ($total, $fmt) = unitize ($total, $fmt);
            }
            else {
               $fmt .= "$numfmt ";
               $extra = '';
            }

            if ($divisor and $$divisor) {
               # XXX generalize this
               if (ref ($desc) eq 'ARRAY') {
                  $desc = @$desc[0] . ' ' . @$desc[1] x (42 - 2 - length(@$desc[0]));
               }

               push @{$lines[$cur_level]}, 
                  sprintf "$fmt  %-42s %6.2f%%\n", $total, $desc,
                     $$divisor == $Totals{$keyname} ? 100.00 : $Totals{$keyname} * 100 / $$divisor;
            }
            else {
               push @{$lines[$cur_level]}, 
                  sprintf "$fmt  %-23s $extra\n", $total, $desc, commify ($Totals{$keyname});
            }
         }
      }
      else {
         die "print_summary_report: unexpected control...";
      }
   }
   print @{$lines[0]};
   print "\n";
}

# Prints the Detail report section
# 
# Note: side affect; deletes each key in Totals/Counts 
# after printout.  Only the first instance of a key in
# the Section table will result in Detail output.
sub print_detail_report (\@) {
   my ($sections) = @_;
   my $header_printed = 0;

   return unless (keys %Counts);

#use Devel::Size qw(size total_size);

   foreach my $sref ( @$sections ) {
      next unless $sref->{CLASS} eq 'DATA';
      # only print detail for this section if DETAIL is enabled
      # and there is something in $Counts{$keyname}
      next unless $sref->{DETAIL};
      next unless exists $Counts{$sref->{NAME}};

      my $keyname = $sref->{NAME};
      my $max_level = undef;
      my $print_this_key = 0;

      my @levelspecs = ();
      clear_level_specs($max_level, \@levelspecs);
      if (exists $Opts{$keyname}) {
         $max_level = create_level_specs($Opts{$keyname}, $Opts{'detail'}, \@levelspecs);
         $print_this_key = 1  if ($max_level);
      }
      else {
         $print_this_key = 1;
      }
      #print_level_specs($max_level,\@levelspecs);

      # at detail 5, print level 1, detail 6: level 2, ...

#print STDERR "building: $keyname\n";
      my ($count, $treeref) = 
            buildTree (%{$Counts{$keyname}}, defined ($max_level) ? $max_level : 11,
                       \@levelspecs, $Opts{'detail'} - 4, 0, $Opts{'debug'} & D_TREE);

      if ($count > 0) {
         if ($print_this_key) {
            my $desc = $sref->{TITLE};
            $desc =~ s/^\s+//;

            if (! $header_printed) {
               my $header = "****** Detail ($max_level) ";
               print $header, '*' x ($Opts{'max_report_width'} - length $header), "\n";
               $header_printed = 1;
            }
            printf "\n%8d   %s %s\n", $count, $desc,
                     $Opts{'sect_vars'} ?
                       ('-' x ($Opts{'max_report_width'} - 18 - length($desc) - length($keyname))) . " [ $keyname ] -" :
                        '-' x ($Opts{'max_report_width'} - 12 - length($desc))
         }

         printTree ($treeref, \@levelspecs, $Opts{'line_style'}, $Opts{'max_report_width'},
                    $Opts{'debug'} & D_TREE);
      }
#print STDERR "Total size Counts: ", total_size(\%Counts), "\n";
#print STDERR "Total size Totals: ", total_size(\%Totals), "\n";
      $treeref = undef;
      $Totals{$keyname} = undef;
      delete $Totals{$keyname};
      delete $Counts{$keyname};
   }
   #print "\n";
}

=pod

Print out a standard percentiles report

   === Delivery Delays Percentiles ===============================================================
                          0%       25%       50%       75%       90%       95%       98%      100%
   -----------------------------------------------------------------------------------------------
   Before qmgr          0.01      0.70      1.40  45483.70  72773.08  81869.54  87327.42  90966.00
   In qmgr              0.00      0.00      0.00      0.01      0.01      0.01      0.01      0.01
   Conn setup           0.00      0.00      0.00      0.85      1.36      1.53      1.63      1.70
   Transmission         0.03      0.47      0.92      1.61      2.02      2.16      2.24      2.30
   Total                0.05      1.18      2.30  45486.15  72776.46  81873.23  87331.29  90970.00
   ===============================================================================================

   === Postgrey Delays Percentiles ===========================================================
                      0%       25%       50%       75%       90%       95%       98%      100%
   -------------------------------------------------------------------------------------------
   Postgrey       727.00    727.00    727.00    727.00    727.00    727.00    727.00    727.00
   ===========================================================================================
 
 tableref: 
   data table: ref to array of arrays, first cell is label, subsequent cells are data
 title:
   table's title
 percentiles_str:
   string of space or comma separated integers, which are the percentiles
   calculated and output as table column data
=cut
sub print_percentiles_report2($$$) {
   my ($tableref, $title, $percentiles_str) = @_;

   return unless @$tableref;

   my $myfw2 = $fw2 - 1;
   my @percents = split /[ ,]/, $percentiles_str;

   # Calc y label width from the hash's keys. Each key is padded with the 
   # string "#: ", # where # is a single-digit sort index.
   my $y_label_max_width = 0;
   for (@$tableref) {
      $y_label_max_width = length($_->[0])   if (length($_->[0]) > $y_label_max_width);
   }

   # Titles row
   my $col_titles_str = sprintf "%-${y_label_max_width}s" . "%${myfw2}s%%" x @percents , ' ', @percents;
   my $table_width = length($col_titles_str);

   # Table header row
   my $table_header_str = sprintf "%s %s ", $sep1 x 3, $title;
   $table_header_str .= $sep1 x ($table_width - length($table_header_str));

   print "\n", $table_header_str;
   print "\n", $col_titles_str;
   print "\n", $sep2 x $table_width;

   my (@p, @coldata, @xformed);
   foreach (@$tableref) {
      my ($title, $ref) = ($_->[0], $_->[1]);
      #xxx my @sorted = sort { $a <=> $b } @{$_->[1]};

      my @byscore = ();

      for my $bucket (sort { $a <=> $b } keys %$ref) {
      #print "Key: $title: Bucket: $bucket = $ref->{$bucket}\n";
      # pairs: bucket (i.e. key), tally
         push @byscore, $bucket, $ref->{$bucket};
      }


      my @p = get_percentiles2 (@byscore, @percents);
      printf "\n%-${y_label_max_width}s" . "%${fw2}.2f" x scalar (@p), $title, @p;
   }

=pod
   foreach (@percents) {
      #printf "\n%-${y_label_max_width}s" . "%${fw2}.2f" x scalar (@p), substr($title,3), @p;
      printf "\n%3d%%", $title;
      foreach my $val (@{shift @xformed}) {
         my $unit;
         if ($val > 1000) {
            $unit = 's';
            $val /= 1000;
         }
         else {
            $unit = '';
         }
         printf "%${fw3}.2f%-2s", $val, $unit;
      }
   }
=cut

   print "\n", $sep1 x $table_width, "\n";
}

sub clear_level_specs($ $) {
   my ($max_level,$lspecsref) = @_;
   #print "Zeroing $max_level rows of levelspecs\n";
   $max_level = 0 if (not defined $max_level);
   for my $x (0..$max_level) {
      $lspecsref->[$x]{topn}      = undef;
      $lspecsref->[$x]{threshold} = undef;
   }
}

# topn      = 0 means don't limit
# threshold = 0 means no min threshold
sub create_level_specs($ $ $) {
   my ($optkey,$gdetail,$lspecref) = @_;

   return 0 if ($optkey eq "0");

   my $max_level = $gdetail;       	# default to global detail level
   my (@specsP1, @specsP2, @specsP3);

   #printf "create_level_specs: key: %s => \"%s\", max_level: %d\n", $optkey, $max_level;

   foreach my $sp (split /[\s,]+/, $optkey) {
      #print "create_level_specs:  SP: \"$sp\"\n";
      # original level specifier
      if ($sp =~ /^\d+$/) {
         $max_level = $sp;
         #print "create_level_specs:  max_level set: $max_level\n";
      }
      # original level specifier + topn at level 1
      elsif ($sp =~ /^(\d*)\.(\d+)$/) {
         if ($1) { $max_level = $1; }
         else    { $max_level = $gdetail; }	      # top n specified, but no max level

         # force top N at level 1 (zero based)
         push @specsP1, { level => 0, topn => $2, threshold => 0 };
      }
      # newer level specs 
      elsif ($sp =~ /^::(\d+)$/) {
         push @specsP3, { level => undef, topn => 0, threshold => $1 };
      }
      elsif ($sp =~ /^:(\d+):(\d+)?$/) {
         push @specsP2, { level => undef, topn => $1, threshold => defined $2 ? $2 : 0 };
      }
      elsif ($sp =~ /^(\d+):(\d+)?:(\d+)?$/) {
         push @specsP1, { level => ($1 > 0 ? $1 - 1 : 0), topn => $2 ? $2 : 0, threshold => $3 ? $3 : 0 };
      }
      else {
         print STDERR "create_level_specs: unexpected levelspec ignored: \"$sp\"\n";
      }
   }

   #foreach my $sp (@specsP3, @specsP2, @specsP1) {
   #   printf "Sorted specs: L%d, topn: %3d, threshold: %3d\n", $sp->{level}, $sp->{topn}, $sp->{threshold};
   #}

   my ($min, $max);
   foreach my $sp ( @specsP3, @specsP2, @specsP1) {
      ($min, $max) = (0, $max_level);
      
      if (defined $sp->{level}) {
         $min = $max = $sp->{level};
      }
      for my $level ($min..$max) {
         #printf "create_level_specs: setting L%d, topn: %s, threshold: %s\n", $level, $sp->{topn}, $sp->{threshold};
         $lspecref->[$level]{topn}      = $sp->{topn}          if ($sp->{topn});
         $lspecref->[$level]{threshold} = $sp->{threshold}     if ($sp->{threshold});
      }
   }

   return $max_level;
}

sub print_level_specs($ $) {
   my ($max_level,$lspecref) = @_;
   for my $level (0..$max_level) {
      printf "LevelSpec Row %d: %3d %3d\n", $level, $lspecref->[$level]{topn}, $lspecref->[$level]{threshold};
   }
}


1;


package Logreporters;

BEGIN {
   import Logreporters::Utils;
   import Logreporters::Config;
   import Logreporters::TreeData qw(%Totals %Counts %Collecting printTree buildTree);
   import Logreporters::Reports;
}
use 5.008;
use strict;
use warnings;
no warnings "uninitialized";
use re 'taint';

use Getopt::Long;
use File::Basename;

our $progname        =  fileparse($0);

# the list of supplemental reports available in the Detail section
#p0f 
my @supplemental_reports = qw(
   autolearn score_percentiles score_frequencies sarules timings sa_timings startinfo
);

# Default values for various options, used if no config file exists,
# or some option is not set.
# 
# These are used to reset default values after an option has been
# disabled (via undef'ing its value).  This allows a report to be
# disabled via config file or --nodetail, but reenabled via subsequent
# command line option
my %Defaults = (
   detail                 => 10,                      # report level detail
   max_report_width       => 100,                     # maximum line width for report output
   line_style             => undef,                   # lines > max_report_width, 0=truncate,1=wrap,2=full
   syslog_name            => $progname_prefix,        # amavis' syslog service name
   sect_vars              => 0,                       # show section vars in detail report hdrs
   ipaddr_width           => 15,                      # width for printing ip addresses
   first_recip_only       => 0,                       # Show only the first recipient, or all

   autolearn              => 1,                       # show Autolearn report
   bayes                  => 1,                       # show hit Bayesian buckets
   #p0f                    => 'all all',               # p0f hits report
   sarules                => '20 20',                 # show SpamAssassin rules hit
   score_frequencies      => '-10 -5 0 5 10 20 30',   # buckets shown in spam scores report
   score_percentiles      => '0 50 90 95 98 100',     # percentiles shown in spam scores report
   startinfo              => 1,                       # show amavis startup info
   timings                => 95,                      # show top N% of the timings report
   timings_percentiles    => '0 5 25 50 75 95 100',   # percentiles shown in timing report
   sa_timings             => 95,                      # show top N% of the SA timings report
   sa_timings_percentiles => '0 5 25 50 75 95 100',   # percentiles shown in SA timing report
);

my $usage_str = <<"END_USAGE";
Usage: $progname [ ARGUMENTS ] [logfile ...]

   ARGUMENTS can be one or more of options listed below.  Later options override earlier ones.
   Any argument may be abbreviated to an unambiguous length.  Input comes from named logfiles,
   or STDIN.

   --debug AREAS                       provide debug output for AREAS
   --help                              print usage information
   --version                           print program version

   --config_file FILE, -f FILE         use alternate configuration file FILE
   --syslog_name PATTERN               only consider log lines that match
                                       syslog service name PATTERN

   --detail LEVEL                      print LEVEL levels of detail
                                       (default: 10)
   --nodetail                          set all detail levels to 0
   --[no]summary                       display the summary section

   --ipaddr_width WIDTH                use WIDTH chars for IP addresses in
                                       address/hostname pairs
   --line_style wrap|full|truncate     disposition of lines > max_report_width
                                       (default: truncate)
   --full                              same as --line_style=full
   --truncate                          same as --line_style=truncate
   --wrap                              same as --line_style=wrap
   --max_report_width WIDTH            limit report width to WIDTH chars
                                       (default: 100)
   --limit L=V, -l L=V                 set level limiter L with value V
   --[no]sect_vars                     [do not] show config file var/cmd line
                                       option names in section titles

   --[no]autolearn                     show autolearn report
   --[no]by_ccat_summary               include by contents category grouping in summary
   --[no]first_recip_only              show first recipient only, or all recipients
   --nosarules                         disable SpamAssassin spam and ham rules hit reports
   --sarules "S,H"                     enable SpamAssassin spam and ham rules reports, showing
   --sarules "default"                 showing the top S spam and top H ham rules hit (range:
                                       0..., "all", or the keyword "default").
   --noscore_frequencies               disable spam score frequency report
   --score_frequencies "B1 [B2 ...]"   enable spam score frequency report, using buckets
   --score_frequencies "default"       specified with B1 [B2 ...] (range: real numbers), or using their
                                       internal default values when the keyword "default" is given
   --noscore_percentiles               disable spam score percentiles report
   --score_percentiles "P1 [P2 ...]"   enable spam score percentiles report, using percentiles
   --score_percentiles "default"       specified with P1 [P2 ...] (range: 0...100), or using their
                                       internal default values when the keyword "default" is given
   --[no]startinfo                     show latest amavis startup details, if available

   --nosa_timings                      disable the SA timings report (same as --sa_timings 0)
   --sa_timings PERCENT                show top PERCENT percent of the SA timings report (range: 0...100)
   --sa_timings_percentiles "P1 [P2 ...]"
                                       set SA timings report percentiles to P1 [P2 ...]  (range: 0...100)

   --notimings                         disable the timings report (same as --timings 0)
   --timings PERCENT                   show top PERCENT percent of the timings report (range: 0...100)
   --timings_percentiles "P1 [P2 ...]" set timings report percentiles to P1 [P2 ...]  (range: 0...100)
END_USAGE

# local prototypes
sub usage($);
sub init_getopts_table();
sub init_defaults();
sub build_sect_table();

sub parse_vals($$);
sub triway_opts($$);

sub printSpamScorePercentilesReport;
sub printSpamScoreFrequencyReport;
sub printAutolearnReport;
sub printSARulesReport;
sub printTimingsReport($$$$);
sub printStartupInfoReport;
sub strip_trace($);
sub prioritize_cmdline(@);

sub create_ignore_list();
sub check_ignore_list($ \@);

# lines that match any RE in this list will be ignored.
# see create_ignore_list();
my @ignore_list_final = ();

# The Sections table drives Summary and Detail reports.  For each entry in the
# table, if there is data avaialable, a line will be output in the Summary report.
# Additionally, a sub-section will be output in the Detail report if both the
# global --detail, and the section's limiter variable, are sufficiently high (a
# non-existent section limiter variable is considered to be sufficiently high).
#
my @Sections;

# Initialize main running mode and basic opts
init_run_mode($config_file);

# Configure the Getopts options table
init_getopts_table();

# Place configuration file/environment variables onto command line
init_cmdline();

# Initialize default values
init_defaults();

# Process command line arguments, 0=no_permute,no_pass_through
get_options(0);

# Build the Section table
build_sect_table();

# Run through the list of Limiters, setting the limiters in %Opts.
process_limiters(@Sections);

# Set collection for any enabled supplemental sections
foreach (@supplemental_reports) {
   $Logreporters::TreeData::Collecting{$_} = (($Opts{'detail'} >= 5) && $Opts{$_}) ? 1 : 0;
}

# Don't collect SpamScores when not necessary
$Collecting{'spamscores'} = ($Opts{'detail'} >= 5 && ($Opts{'score_percentiles'} || $Opts{'score_frequencies'})) ? 1 : 0;

if (! defined $Opts{'line_style'}) {
   # default line style to full if detail >= 11, or truncate otherwise
   $Opts{'line_style'} =
      ($Opts{'detail'} > 10) ? $line_styles{'full'} : $line_styles{'truncate'};
}

# Create the list of REs used to match against log lines
create_ignore_list();

my (%Timings, %TimingsSA, @TimingsTotals, @TimingsSATotals);
my (%SaveLine, %StartInfo);
my (%SpamScores, %spamtags, %p0ftags);

# Priority: VIRUS BANNED UNCHECKED SPAM SPAMMY BADH OVERSIZED MTA CLEAN 
my %ccatmajor_to_sectkey = (
   'INFECTED'    => 'malware',
   'BANNED'      => 'bannedname',
   'UNCHECKED'   => 'unchecked',
   'SPAM'        => 'spam',
   'SPAMMY'      => 'spammy',
   'BAD-HEADER'  => 'badheader',
   'OVERSIZED'   => 'oversized',
   'MTA-BLOCKED' => 'mta',
   'CLEAN'       => 'clean',
   'TEMPFAIL'    => 'tempfail',
   'OTHER'       => 'other',
);

my %ccatmajor_to_priority = (
   'INFECTED'    => 9,
   'BANNED'      => 8,
   'UNCHECKED'   => 7,
   'SPAM'        => 6,
   'SPAMMY'      => 5,
   'BAD-HEADER'  => 4,
   'OVERSIZED'   => 3,
   'MTA-BLOCKED' => 2,
   'CLEAN'       => 1,
   'TEMPFAIL'    => 0,
   'OTHER'       => 0,
);

# for reports 
my %ccatmajor_to_spamham = (
   'INFECTED'    => 'malware',
   'BANNED'      => 'bannedname',
   'UNCHECKED'   => 'unchecked',
   'SPAM'        => 'spam',
   'SPAMMY'      => 'spam',
   'BAD-HEADER'  => 'ham',
   'OVERSIZED'   => 'ham',
   'MTA-BLOCKED' => 'ham',
   'CLEAN'       => 'ham',
   'TEMPFAIL'    => 'ham',
   'OTHER'       => 'ham',
);

my $logline_maxlen = 980;

# Create the list of REs against which log lines are matched.                                                                                         
# Lines that match any of the patterns in this list are ignored.                                                                                      
#                                                                                                                                                     
# Note: This table is created at runtime, due to a Perl bug which                                                                                     
# I reported as perl bug #56202:                                                                                                                      
#                                                                                                                                                     
#    http://rt.perl.org/rt3/Public/Bug/Display.html?id=56202                                                                                          
#                                                                                                                                                     

sub create_ignore_list() {
   push @ignore_list_final, qr/^lookup_ip_acl/;
   push @ignore_list_final, qr/^lookup_acl/;
   push @ignore_list_final, qr/^lookup_hash/;
   push @ignore_list_final, qr/^lookup_re/;
   push @ignore_list_final, qr/^lookup_ldap/;
   push @ignore_list_final, qr/^lookup_sql_field.* result=[YN]$/;
   push @ignore_list_final, qr/^lookup .* does not match$/;
   push @ignore_list_final, qr/^lookup [[(]/;
   push @ignore_list_final, qr/^lookup => /;
   push @ignore_list_final, qr/^lookup: /;
   push @ignore_list_final, qr/^save_info_preliminary/; # log level 4
   push @ignore_list_final, qr/^save_info_final/;       # log level 4
   push @ignore_list_final, qr/^sql: /;
   push @ignore_list_final, qr/^sql_storage: retrying/;
   push @ignore_list_final, qr/^sql flush: /;
   push @ignore_list_final, qr/^sql print/;
   push @ignore_list_final, qr/^sql begin transaction/;
   push @ignore_list_final, qr/^sql rollback/;
   push @ignore_list_final, qr/^mail_via_sql: /;
   push @ignore_list_final, qr/^CALLING SA check$/;
   push @ignore_list_final, qr/^calling SA parse,/;
   push @ignore_list_final, qr/^timer set to \d+/;
   push @ignore_list_final, qr/^query_keys/;
   push @ignore_list_final, qr/^find_or_save_addr: /;
   push @ignore_list_final, qr/^header: /;
   push @ignore_list_final, qr/^DO_QUARANTINE, /;
   push @ignore_list_final, qr/^DEBUG_ONESHOT: /;
   push @ignore_list_final, qr/^TempDir::/;
   push @ignore_list_final, qr/^check_mail_begin_task: /;
   push @ignore_list_final, qr/^program: .*?(anomy|altermime|disclaimer).*? said: /; # log_level 2
   push @ignore_list_final, qr/^body (?:type|hash): /;
   push @ignore_list_final, qr/^\d+\.From: <.*>, \d+.Mail_From:/;
   push @ignore_list_final, qr/^The amavisd daemon is (?:apparently )?not running/;
   push @ignore_list_final, qr/^rw_loop/;
   push @ignore_list_final, qr/^[SL]MTP[><]/;
   push @ignore_list_final, qr/^[SL]MTP response for/;
   push @ignore_list_final, qr/^dsn:/i,   # DSN or dsn
   push @ignore_list_final, qr/^enqueue: /;
   push @ignore_list_final, qr/^write_header: /;
   push @ignore_list_final, qr/^banned check: /;
   push @ignore_list_final, qr/^child_finish_hook/;
   push @ignore_list_final, qr/^inspect_dsn:/;
   push @ignore_list_final, qr/^client IP address unknown/;
   push @ignore_list_final, qr/^final_destiny/;
   push @ignore_list_final, qr/^one_response_for_all/;
   push @ignore_list_final, qr/^headers CLUSTERING/;
   push @ignore_list_final, qr/^notif=/;
   push @ignore_list_final, qr/^\(about to connect/;
   push @ignore_list_final, qr/^Original mail size/;
   push @ignore_list_final, qr/^TempDir removal/;
   push @ignore_list_final, qr/^Issued a new file name/;
   push @ignore_list_final, qr/^starting banned checks/;
   push @ignore_list_final, qr/^skip admin notification/;
   push @ignore_list_final, qr/^do_notify_and_quarantine - done/;
   push @ignore_list_final, qr/^do_[a-zA-Z]+.* done$/i;
   push @ignore_list_final, qr/^Remote host presents itself as:/;
   push @ignore_list_final, qr/^connect_to_ldap/;
   push @ignore_list_final, qr/^connect_to_sql: trying /;
   push @ignore_list_final, qr/^ldap begin_work/;
   push @ignore_list_final, qr/^Connecting to LDAP server/;
   push @ignore_list_final, qr/^loaded base policy bank/;
   push @ignore_list_final, qr/^\d+\.From:/;
   push @ignore_list_final, qr/^Syslog (retries|warnings)/;
   push @ignore_list_final, qr/^smtp connection cache/;
   push @ignore_list_final, qr/^smtp cmd> /;
   push @ignore_list_final, qr/^smtp session/;
   push @ignore_list_final, qr/^Ignoring stale PID file/;
   push @ignore_list_final, qr/^mime_decode_preamble/;
   push @ignore_list_final, qr/^doing banned check for/;
   push @ignore_list_final, qr/^open_on_specific_fd/;
   push @ignore_list_final, qr/^reparenting /;
   push @ignore_list_final, qr/^Issued a new pseudo part: /;
   push @ignore_list_final, qr/^run_command: /;
   push @ignore_list_final, qr/^result line from file/;
   push @ignore_list_final, qr/^Charging /;
   push @ignore_list_final, qr/^check_for_banned /;
   push @ignore_list_final, qr/^Extracting mime components$/;
   push @ignore_list_final, qr/^response to /;
   push @ignore_list_final, qr/^File-type of /;
   push @ignore_list_final, qr/^Skip admin notification, /;
   push @ignore_list_final, qr/^run_av: /;
   push @ignore_list_final, qr/^string_to_mime_entity /;
   push @ignore_list_final, qr/^ndn_needed=/;
   push @ignore_list_final, qr/^sending RCPT TO:/;
   push @ignore_list_final, qr/^decode_parts: /;
   push @ignore_list_final, qr/^decompose_part: /;
   push @ignore_list_final, qr/^setting body type: /;
   push @ignore_list_final, qr/^mime_decode_epilogue: /;
   push @ignore_list_final, qr/^string_to_mime_entity: /;
   push @ignore_list_final, qr/^at the END handler: /;
   push @ignore_list_final, qr/^Amavis::.* called$/;
   push @ignore_list_final, qr/^Amavis::.* close,/;
   push @ignore_list_final, qr/^dkim: /;         # XXX provide stats
   push @ignore_list_final, qr/^collect banned table/;
   push @ignore_list_final, qr/^collect_results from/;
   push @ignore_list_final, qr/^blocking contents category is/;
   push @ignore_list_final, qr/^running file\(/;
   push @ignore_list_final, qr/^Found av scanner/;
   push @ignore_list_final, qr/^Found myself/;
   push @ignore_list_final, qr/^mail_via_smtp/;
   push @ignore_list_final, qr/^switch_to_client_time/;
   push @ignore_list_final, qr/^parse_message_id/;
   push @ignore_list_final, qr/^parse_received: /;
   push @ignore_list_final, qr/^parse_ip_address_from_received: /;
   push @ignore_list_final, qr/^fish_out_ip_from_received: /;
   push @ignore_list_final, qr/^Waiting for the process \S+ to terminate/;
   push @ignore_list_final, qr/^Valid PID file \(younger than sys uptime/;
   push @ignore_list_final, qr/^Sending SIG\S+ to amavisd/;
   push @ignore_list_final, qr/^Can't send SIG\S+ to process/;
   push @ignore_list_final, qr/^killing process/;
   push @ignore_list_final, qr/^no need to kill process/;
   push @ignore_list_final, qr/^process .* is still alive/;
   push @ignore_list_final, qr/^Daemon \[\d+\] terminated by SIG/;
   push @ignore_list_final, qr/^storage and lookups will use .* to SQL/;
   push @ignore_list_final, qr/^idle_proc, /;
   push @ignore_list_final, qr/^switch_to_my_time/;
   push @ignore_list_final, qr/^TempDir::strip: /;
   push @ignore_list_final, qr/^rmdir_recursively/;
   push @ignore_list_final, qr/^sending [SL]MTP response/;
   push @ignore_list_final, qr/^prolong_timer/;
   push @ignore_list_final, qr/^process_request:/;
   push @ignore_list_final, qr/^exiting process_request/;
   push @ignore_list_final, qr/^post_process_request_hook: /;
   push @ignore_list_final, qr/^SMTP session over/;
   push @ignore_list_final, qr/^updating snmp variables/;
   push @ignore_list_final, qr/^best_try_originator_ip/;
   push @ignore_list_final, qr/^mail checking ended: /; # log level 2
   push @ignore_list_final, qr/^The amavisd daemon is already running/;
   push @ignore_list_final, qr/^AUTH not needed/;
   push @ignore_list_final, qr/^load: \d+ %, total idle/;
   push @ignore_list_final, qr/^policy protocol: [^=]+=\S+(?:,\S+)*$/;   # allow "policy protocol: INVALID ..." later
   push @ignore_list_final, qr/^penpals: /;
   push @ignore_list_final, qr/^Not calling virus scanners, no files to scan in/;
   push @ignore_list_final, qr/^local delivery: /;
   push @ignore_list_final, qr/^run_as_subprocess: child process \S*: Broken pipe/;
   push @ignore_list_final, qr/^initializing Mail::SpamAssassin/;
   push @ignore_list_final, qr/^Error reading mail header section/;   # seems to occur gen. due to perl getline() bug
   push @ignore_list_final, qr/^flatten_and_tidy_dir/;
   push @ignore_list_final, qr/^do_7zip: member/;
   push @ignore_list_final, qr/^Expanding \S+ archive/;
   push @ignore_list_final, qr/^files_to_scan:/;
   push @ignore_list_final, qr/^Unzipping p\d+/;
   push @ignore_list_final, qr/^writing mail text to SQL/;
   push @ignore_list_final, qr/^strip_tempdir/;
   push @ignore_list_final, qr/^no parts, file/;
   push @ignore_list_final, qr/^warnsender_with_pass/;
   push @ignore_list_final, qr/^RETURNED FROM SA check/;
   push @ignore_list_final, qr/^mime_traverse: /;
   push @ignore_list_final, qr/^do_spam: /;
   push @ignore_list_final, qr/^prepare_tempdir: /;
   push @ignore_list_final, qr/^check_header: /;
   push @ignore_list_final, qr/^skip admin notification/;
   push @ignore_list_final, qr/^do_executable: not a/;
   push @ignore_list_final, qr/^Skip spam admin notification, no administrators$/;
   push @ignore_list_final, qr/^skip banned check for/;
   push @ignore_list_final, qr/^is_outgoing /;
   push @ignore_list_final, qr/^NO Disclaimer/;
   push @ignore_list_final, qr/^Using \(\S+\) on file/;
   push @ignore_list_final, qr/^no anti-spam code loaded/;
   push @ignore_list_final, qr/^entered child_init_hook/;
   push @ignore_list_final, qr/^body type/;
   push @ignore_list_final, qr/^establish_or_refresh/;
   push @ignore_list_final, qr/^get_body_digest/;
   push @ignore_list_final, qr/^ask_daemon_internal/;
   push @ignore_list_final, qr/^Turning AV infection into a spam report, name already accounted for/;
   push @ignore_list_final, qr/^Calling virus scanners/;
   push @ignore_list_final, qr/^timer stopped after /;
   push @ignore_list_final, qr/^virus_presence /;
   push @ignore_list_final, qr/^cache entry /;
   push @ignore_list_final, qr/^generate_mail_id /;
   push @ignore_list_final, qr/^Load low precedence policybank/;
   push @ignore_list_final, qr/^warm restart on /;		# XXX could be placed instartup info
   push @ignore_list_final, qr/^Signalling a SIGHUP to a running daemon/;
   push @ignore_list_final, qr/^Deleting db files /;
   push @ignore_list_final, qr/^address modified \(/;
   push @ignore_list_final, qr/^Request: AM\.PDP /;
   push @ignore_list_final, qr/^DSPAM result: /;
   push @ignore_list_final, qr/^bind to \//;
   push @ignore_list_final, qr/^ZMQ enabled: /;

   push @ignore_list_final, qr/^Inserting header field: X-Amavis-Hold: /;
   push @ignore_list_final, qr/^Decoding of .* failed, leaving it unpacked: /;

      # various forms of "Using ..."
      # more specific, interesting variants already captured: search "Using"
   push @ignore_list_final, qr/^Using \(.*\) on dir:/;
   push @ignore_list_final, qr/^Using [^:]+: \(built-in interface\)/;
   push @ignore_list_final, qr/^Using \(.*\): /;
   push @ignore_list_final, qr/: sleeping for /;
   push @ignore_list_final, qr/creating socket by /;

        # unanchored
   push @ignore_list_final, qr/\bRUSAGE\b/;
   push @ignore_list_final, qr/: Sending .* to UNIX socket/;
}

# Notes:
#
#   - IN REs, always use /o flag or qr// at end of RE when RE uses unchanging interpolated vars
#   - In REs, email addresses may be empty "<>" - capture using *, not + ( eg. from=<[^>]*> )
#   - See additional notes below, search for "Note:".
#   - XXX indicates change, fix or more thought required

# Main processing loop
#
while (<>) {
   chomp;
   s/ +$//;
   next if $_ eq '';

   $Logreporters::Reports::origline = $_;

   if ($Opts{'standalone'}) {
      next unless s/^[A-Z][a-z]{2} [ \d]\d \d{2}:\d{2}:\d{2} (?:<[^>]+> )?\S+ $Opts{'syslog_name'}(?:\[\d+\])?: (?:\[ID \d+ \w+\.\w+\] )?//o;
   }

   my $p1 = $_;
   my ($p2, $pid);
   my $action = "blocked";    # default action is blocked if not present in log

   # For now, ignore the amavis startup timing lines.  Need to do this
   # before stripping out the amavis pid to differentiate these from the
   # scan timing reports
   next if ($p1 =~ /^TIMING/);

   my $linelen = length $p1;
   # Strip amavis process id-instance id, or release id
   if (($pid,$p2) = ($p1 =~ /^\(([^)]+)\) (.*)$/ )) {
      $p1 = $p2;
   }

   # Handle continuation lines.  Continuation lines should be in order per PID, meaning line1, line2, line3,
   # but never line3, line1, line2.
   #
   # amavis log lines as chopped by sub write_log are exactly 980 characters long starting with '(' as in:
   #  amavis[47061]: (47061-15) SPAM, etc  ...
   #                 ^ <-----980------------->
   # but this can be changed in amavis via $logline_maxlen.
   # There may also be the alert markers (!) and (!!) preceeding any continuation ellipsis.
   #

   # ... a continued line ...
   if ($p1 =~ s/^(\([!]{1,2}\))?\.\.\.//) {
      if (!exists($SaveLine{$pid})) {
         my $alert = $1;
         #printf "Unexpected continue line: \"%s\"\n", $p1;
         $SaveLine{$pid} = $alert || '';
      }
      $SaveLine{$pid} .= $p1;
      next if $SaveLine{$pid} =~ s/\.\.\.$//;  # next if line has more pieces
   }

   # this line continues ...
   if ($p1 =~ /\.\.\.$/ and $linelen == $logline_maxlen) {
      $p1 =~ s/\.\.\.$//;
      $SaveLine{$pid} = $p1;
      next;
   }

   if (exists($SaveLine{$pid})) {
      # printf "END OF SaveLine: %s\n", $SaveLine{$pid};
      $p1 = delete $SaveLine{$pid};
   }

   #if (length($p1) > 10000) {
   #   printf "Long log entry %d chars: \"%s\"\n", length($p1), $p1;
   #   next;
   #}

   next if (
        # Place REs here that should ignore log lines otherwise caught below.
        # Some are located here historically, and need to be checked for candidates
        # to be relocated to ignore_list_final.
           ($p1 =~ /^do_ascii/) 
        or ($p1 =~ /^Checking/)
        or ($p1 =~ /^header_edits_for_quar: /)
        or ($p1 =~ /^Not-Delivered/)
        or ($p1 =~ /^SpamControl/)
        or ($p1 =~ /^Perl/)
        or ($p1 =~ /^ESMTP/)
        or ($p1 =~ /^(?:\(!+\))?(\S+ )?(?:FWD|SEND) from /)            # log level 4
        or ($p1 =~ /^(?:\(!+\))?(\S+ )?(?:ESMTP|FWD|SEND) via /)       # log level 4
        or ($p1 =~ /^tempdir being removed/)
        or ($p1 =~ /^do_notify_and_quar(?:antine)?: .*ccat/)
        or ($p1 =~ /^cached [a-zA-Z0-9]+ /)
        or ($p1 =~ /^loaded policy bank/)
        or ($p1 =~ /^p\.path/)
        or ($p1 =~ /^virus_scan: /)
        or ($p1 =~ /^Requesting (a |)process rundown after [0-9]+ tasks/)
        or ($p1 =~ /^Cached (virus|spam) check expired/)
        or ($p1 =~ /^pr(?:esent|ovid)ing full original message to scanners as/)  # log level 2
        or ($p1 =~ /^Actual message size [0-9]+ B(,| greater than the) declared [0-9]+ B/)
        or ($p1 =~ /^disabling DSN/)
        or ($p1 =~ /^Virus ([^,]+ )?matches [^,]+, sender addr ignored/)
        or ($p1 =~ /^release /)
        or ($p1 =~ /^adding SA score \S+ to existing/)
        or ($p1 =~ /^Maia:/)   # redundant
        or ($p1 =~ /^AM\.PDP  /)  # this appears to be always have two spaces
                                  # because in amavisd::preprocess_policy_query() when $ampdp is
                                  # set, it will pass an unset $attr_ref->{'mail_id'} to do_log(1
        or ($p1 =~ /^_(?:WARN|DIE):$/)  # bug: empty _WARN|_DIE: http://marc.info/?l=amavis-user&m=121725098111422&w=2

        # non-begin anchored
        or ($p1 =~ /result: clean$/)
        or ($p1 =~ /DESTROY called$/)
        or ($p1 =~ /email\.txt no longer exists, can't re-use it/)
        or ($p1 =~ /SPAM\.TAG2/)
        or ($p1 =~ /BAD-HEADER\.TAG2/)
        or ($p1 =~ /: Connecting to socket/)
        or ($p1 =~ /broken pipe \(don't worry\), retrying/)
        or ($p1 =~ /(?:Sending|on dir:) (?:CONT)?SCAN /)
   );

   my ($ip, $from, $to, $key,, $reason, $item,
       $decoder, $scanner, $stage, $sectkey);

   # Coerce older "INFECTED" quarantined lines into "Blocked INFECTED",
   # to be processed in the Passed/Blocked section.
   if ($p1 =~ /^INFECTED.*, quarantine/) {
      $p1 = 'Blocked ' . $p1;
   }

   # SPAM entry occurs at kill level
   # SPAM-TAG entry occurs at log level 2, when spam header is inserted
   # log_level >= 2 || (log_level > 2 && syslog_priority=debug)
   my ($tagtype,$fromto,$isspam,$tags,$tests,$autolearn);

   # amavisd-new 2.7.0 changes SPAM-TAG to Spam-tag and its log_level to 3
   if (($tagtype,$fromto,$isspam,$tags,$tests,$autolearn) = ($p1 =~ /^((?i:SPAM(?:-TAG)?)), (.*), (Yes|No), score=[-+x\d.]+(.*) tests=\[([^\]]*)](?:, autolearn=(\w+))?/) or
       ($tagtype,$fromto,$isspam,$tags,$tests) =            ($p1 =~ /^((?i:SPAM(?:-TAG)?)), (.*), (Yes|No), hits=[-+x\d.]+(.*) tests=(.*)(?:, quarantine )?/)) {

      #TD SPAM, <from@example.com> -> <to@sample.com>, Yes, score=17.709 tag=-10 tag2=6.31 kill=6.31 tests=[AWL=-0.678, BAYES_99=4], autolearn=spam, quarantine Cc4+GUJhgpqh (spam-quarantine)
      #TD SPAM, <from@example.com> -> <to@sample.net>, Yes, score=21.161 tag=x tag2=8.15 kill=8.15 tests=[BAYES_99=2.5, FORGED_RCVD_HELO=0.135], autolearn=no, quarantine m6lWPoTGJ2O (spam-quarantine)
      #TD SPAM, <from@example.com> -> <to@sample.net>, Yes, score=17.887 tag=-10 tag2=6.31 kill=6.31 tests=[BAYES_99=4], autolearn=spam, quarantine VFYjDOVTW4zd (spam-quarantine)
      #TD SPAM-TAG, <from@example.com> -> <to@sample.net>, No, score=-0.069 tagged_above=-10 required=6.31 tests=[BAYES_00=-2.599, FROM_ENDS_IN_NUMS=2.53]
      #TD SPAM-TAG, <from@example.com> -> <to@sample.net>, No, score=-1.294 required=8.15 tests=[BAYES_00=-2.599, FROM_LOCAL_HEX=1.305]
      # pre 2.3.3
      #TD SPAM-TAG, <from@example.com> -> <to@sample.net>, Yes, hits=6.159 tagged_above=-999 required=3.4 tests=BAYES_99=3.5, FUZZY_CPILL=0.518, HTML_MESSAGE=0.001, URIBL_WS_SURBL=2.14
      #TD SPAM, <from@example.com> -> <to@sample.net>, Yes, hits=8.1 tag1=-999.0 tag2=7.0 kill=7.0 tests=MANGLED_TAKE, UPPERCASE_25_50, quarantine spam-14156-09 (maia-spam-quarantine) 

      $Totals{'tagged'}++   if uc($tagtype) eq 'SPAM-TAG';

      if ($tests) {
         my $type = $isspam =~ /^Y/ ? 'Spam' : 'Ham';

         # Note: A SPAM line may be followed by an almost identical SPAM-TAG line.  To avoid double counting,
         # maintain a list of (abbreviated) SPAM tag lines keyed by pid.  Since pid's are recycled,
         # maintain an approximation of uniqueness by combining several components from the log
         # line (we can't use the date information, as in logwatch, it is not present).
         # XXX: It is safe to delete an entry when the final Passed/Block line occurs

         #TD SPAM, <from@example.com> -> <to@sample.net>, Yes, score=34.939 tag=x tag2=6.31 kill=6.31 tests=[DATE_IN_FUTURE_03_06=1.961], autolearn=disabled
         #TD SPAM-TAG, <from@example.com> -> <to@sample.net>, Yes, score=34.939 required=6.31 tests=[DATE_IN_FUTURE_03_06=1.961]
         #TD SPAM, <from@example.com> -> tod@sample.net>, Yes, score=31.565 tag=x tag2=6.9 kill=6.9 tests=[AV:Sanesecurity.Phishing.Bank.2666.UNOFFICIAL=4.1, AV:Sanesecurity.Phishing.Bank.2666.UNOFFICIAL=4.1, BAYES_99=4, DCC_CHECK=4, DIGEST_MULTIPLE=0.001, FORGED_MUA_OUTLOOK=3.116, FORGED_OUTLOOK_HTML=0.001, FORGED_OUTLOOK_TAGS=0.001, HTML_MESSAGE=0.001, L_AV_SS_Phish=5, MIME_HTML_ONLY=1.457, NORMAL_HTTP_TO_IP=0.001, RAZOR2_CF_RANGE_51_100=2, RAZOR2_CF_RANGE_E4_51_100=1.5, RAZOR2_CF_RANGE_E8_51_100=1.5, RAZOR2_CHECK=3, RDNS_NONE=0.1, URIBL_PH_SURBL=1.787] autolearn=spam


         my $tagstr = $fromto . '/' . $isspam . '/' . $tests;
         if (uc($tagtype) eq 'SPAM-TAG' and exists $spamtags{$pid}) {
            next if ($spamtags{$pid} eq $tagstr);
         }
         $spamtags{$pid} = $tagstr;

         #for (split /=[^,]+(?:, +|$)/, $tests) 
         # amavis < 2.6.2 would double list AV names when using
         # @virus_name_to_spam_score_maps.
         my @unique_tests = unique_list (split /, +/, $tests);
         for (@unique_tests) {
	    # skip possible trailing junk ("quarantine, ...") when older non-bracked tests=xxx is used
	    next if ! /[^=]+=[\-.\d]+/;
            my ($id,$val) = split /=/;
            if ($id =~ /^BAYES_\d+$/) {
               $Counts{'bayes'}{$id}++    if ($Collecting{'bayes'});
            }
            if ($Opts{'sarules'}) {
               if    ($id eq 'DKIM_POLICY_SIGNSOME') { $val = 0   }
               elsif ($id eq 'AWL')                  { $val = '-' }
               $Counts{'sarules'}{$type}{sprintf "%6s %s", $val, $id}++;
            }
         }
         # Handled below
         #autolearn= is available only at ll>=3 or SPAM messages; so ham doesn't naturally occur here
         # SA 2.5/2.6 : ham/spam/no
         # SA 3.0+    : ham/spam/no/disabled failed/unavailable
         #$Counts{'autolearn'}{$type}{$autolearn}++    if ($Opts{'autolearn'});
      }
   }

   # Passed or Blocked
   elsif ($p1 =~ /^(Passed|Blocked)(.*)/) {
      $action = lcfirst $1;
      ($p1 = $2) =~ s/^\s+//;

      $p1 =~ s/^,/CLEAN,/;      # canonicalize older log entries
      #print "P1: \"$p1\"\n";

      # amavis 20030616p10-5
      #TD Passed, <from@example.com> -> <to@sample.net>, Message-ID: <652.44494541@example.com>, Hits: 4.377
      #TD Passed, <from@example.com> -> <to@sample.net>, Message-ID: <B5C@example.com>, Hits: - 
      #TD Passed, <from@example.com> -> <to@sample.net>, quarantine IJHkgliCm2Ia, Message-ID: <20080307140552.16E127641E@example.com>, Hits: 0.633

      #TD Passed CLEAN, [10.0.0.1] [10.0.0.1] <from@example.com> -> <to@sample.net>, Message-ID: <2qxz191@example.com>, mail_id: w4DHD8, Hits: -2.599, size: 3045, queued_as: 2056, 2664 ms
      #TD Passed CLEAN, [10.0.0.1] [10.0.0.1] <from@example.com> -> <to@sample.net>, Message-ID: <2qxz191@example.com>, mail_id: w4DHD8, Hits: -2.541-3, size: 3045, queued_as: 2056, 2664 ms
      #TD Blocked SPAM, [10.0.0.1] [192.168.0.1] <bogus@example.com> -> <to@sample.net>, quarantine: spam-EzEbE9W, Message-ID: <117894@example.com>, mail_id: EzEbE9W, Hits: 6.364, size: 16493, 6292 ms
      #TD Blocked SPAM, LOCAL [10.0.0.1] [10.0.0.2] <bogus@example.com> -> <to@sample.net>, quarantine: spam-EzEbE9W, Message-ID: <110394@example.com>, mail_id: EzEbE9W, Hits: 6.364, size: 16493, 6292 ms
      #TD Blocked SPAM, [IPv6:2001:630:d0:f102:230:48ff:fe77:96e] [192.168.0.1] <joe@example.com> -> <user@sample.net>, quarantine: spam-EzEbE9W, Message-ID: <11780394@example.com>, mail_id: EzEbE9W, Hits: 6.364, size: 16493, 6292 ms
      #TD Passed SPAMMY, ORIGINATING/MYNETS LOCAL [10.0.0.1] [10.0.0.1] <from@example.com> -> <to1@sample.net>,<to2@sample.net>, quarantine: spam-EzEbE9W, Message-ID: <11780394@example.com>, mail_id: EzEbE9W, Hits: 6.364, size: 16493, 6292 ms
      #TD Blocked SPAM, B-BANK/C-BANK/B-BANK [10.0.0.1] [10.0.0.1] <from@sample.net> -> <to@example.com>, quarantine: spam-EzEbE9W, Message-ID: <11780394@example.com>, mail_id: EzEbE9W, Hits: 6.364, size: 16493, 6292 ms
      #TD Blocked SPAM, [10.0.0.1] [10.0.0.1] <from@example.com> -> <to@sample.net>, quarantine: spam-AV49p5, Message-ID: <1.007@sample.net>, mail_id: AV49p5, Hits: 7.487, size: 27174, 4406 ms
      #TD Passed SPAM, MYNETS <root@example.com> -> <root@example.com>, quarantine: spam-V3Wq, Message-ID: <220.1B@example.com>, mail_id: V3Wq, Hits: 7, size: 8838, queued_as: C63EC, 18 ms
      #TD Passed SPAM, <> -> <"fred).flintstone"@domain.tld>, Message-ID: <200801180104.CAA23669@aserver.sub.adomain.tld>, mail_id: 6AzQ1g0l5RgP, Hits: 9.061, size: 5555, queued_as: C1840506CB8, 8766 ms
      #TD Blocked INFECTED (HTML.Phishing.Bank-43), [198.168.0.1] [10.0.0.1] <bogus@example.com> -> <to@sample.net>, quarantine: virus-SCwJcs, Message-ID: <509@acm.org>, mail_id: SCwJcs, Hits: -, size: 4134, 3721 ms
      #TD Blocked INFECTED (Trojan.Downloader.Small-9993), LOCAL [10.0.0.2] [10.0.0.2] <bogus@example.net> -> <to@example.com>, quarantine: virus-SCwJcs, Message-ID: <9009@acm.org>, mail_id: SCwJcs, Hits: -, size: 4134, 3721 ms
      #TD Blocked BANNED (multipart/report | message/partial,.txt), [192.168.0.1] [10.0.0.2] <> -> <someuser@sample.net>, quarantine: virus-SCwJcs, Message-ID: <509@acm.org>, mail_id: SCwJcs, Hits: -, size: 4134, 3721 ms
      #TD Blocked BANNED (multipart/report | message/partial,.txt), LOCAL [192.168.0.1] [10.0.0.2] <> -> <someuser@sample.net>, quarantine: virus-SCwJcs, Message-ID: <509@acm.org>, mail_id: SCwJcs, Hits: -, size: 4134, 3721 ms
      #TD Blocked BANNED (multipart/mixed | application/octet-stream,.asc,=?iso-8859-1?Q?FTP=5FFile=5F (1)=File(1).reg), [192.168.0.0] [192.168.0.0] <from@example.com> -> <to@sample.us>, quarantine: virus-SCwJcs, Message-ID: <509@acm.org>, mail_id: SCwJcs, Hits: -, size: 4134, 3721 ms
      #TD Blocked BANNED (multipart/related | application/zip,.zip,card.zip | .exe,.exe-ms,Card.exe), [10.0.0.2] [10.0.0.2] <from@example.com> -> <to@sample.net>, quarantine: banned-9OXm4Q3ah, Message-ID: <08517$@from>, mail_id: 9OXm4Q3ah, Hits: -, size: 2366, 3803 ms
      #TD Passed BAD-HEADER, [192.168.0.1] [10.0.0.2] <bogus@example.com> -> <someuser@sample.net>, quarantine: virus-SCwJcs, Message-ID: <df@acm.org>, mail_id: SCwJcs, Hits: 2.54 size: 4134, 3721 ms
      #TD Passed BAD-HEADER, LOCAL [192.168.0.1] [10.0.0.2] <bogus@example.com> -> <someuser@sample.net>, quarantine: virus-SCwJcs, Message-ID: <df@acm.org>, mail_id: SCwJcs, Hits: 3.2 size: 4134, 3721 ms
      #TD Passed BAD-HEADER, MYNETS AM.PDP [127.0.0.1] [127.0.0.1] <bogus@example.com> -> <someuser@sample.net>, quarantine: virus-SCwJcs, Message-ID: <df@acm.org>, mail_id: SCwJcs, Hits: 1.2 size: 4134, 3721 ms
      #TD Passed BAD-HEADER, ORIGINATING/MYNETS LOCAL [10.0.0.1] [10.0.0.1] <from@sample.net> -> <to1@sample.net>,<to2@sample.net>,<to3@example.com>, quarantine: virus-SCwJcs, Message-ID: <df@acm.org>, mail_id: SCwJcs, Hits: -, size: 4134, 3721 ms
      #TD Passed BAD-HEADER, [10.0.0.1] [10.0.0.2] <from@example.com> -> <to@sample.net>, quarantine: badh-lxR, Message-ID: <7fm@example.com>, mail_id: lxR, Hits: -2.292, size: 422, queued_as: E3B, 981 ms
      #TD Passed UNCHECKED, MYNETS LOCAL [192.168.0.1] [192.168.0.1] <from@sample.net> -> <to@example.com> Message-ID: <002e01c759c7$5de437b0$0a02a8c0@somehost>, mail_id: 7vtR-7BAvHZV, Hits: -, queued_as: B5420C2E10, 6585 ms
      #TD Blocked MTA-BLOCKED, LOCAL [192.168.0.1] [192.168.0.2] <from@example.com> -> <to@sample.net>, Message-ID: <438548@example.com>, mail_id: tfgTCiyvFw, Hits: -2.54, size: 4895, 31758 ms
      #TD Blocked OVERSIZED, LOCAL [10.0.0.1] [10.0.0.1] <f@example.com> -> <t@sample.net>, Message-ID: <435@example.com>, mail_id: tfTivFw, Hits: -2.54, size: 444444895, 31758 ms
      #TD Blocked OTHER, LOCAL [10.0.0.1] [10.0.0.1] <f@example.com> -> <t@sample.net>, Message-ID: <435@example.com>, mail_id: tfTivFw, Hits: -2.54, size: 495, 31758 ms
      #TD Blocked TEMPFAIL, [10.0.0.2] [10.0.0.1] <user@example.com> -> <to@sample.net>, Message-ID: <200703302301.9f1899470@example.com>, mail_id: bgf52ZCNbPo, Hits: -2.586, 3908 ms

      #2.3.1
      #<>,<info@example.com>,Passed,Hits=-3.3,Message-ID=<200506440.1.sample.net>,Size=51458 
      #20030616p10-5
      #Not-Delivered, <from@example.com> -> <to@localhost>, quarantine spam-ea32770-03, Message-ID: <BAA618FE2CB585@localhost>, Hits: 9.687 

      # malwarepassed, malwareblocked
      # xxx very old
      # Virus found - quarantined|
      #amavisd-new-20030616
      # INFECTED (JS/IllWill-A), <from@[127.0.0.1]> -> <to@sample.net>, quarantine virus-20040811-207-0-03, Message-ID: <0440.5577-101@sample.net>, Hits: -
      # INFECTED (Exploit.HTML.IFrame, Worm.SomeFool.P), <from@sample.net> -> <to@example.com>,<to2@example.com>, quarantine qiO2ZG4K, Message-ID: <200608.5A5@mail.example.com>, Hits: -
      #XXX (?:(Passed|Blocked) )?INFECTED \(([^\)]+)\),[A-Z .]*(?: \[($re_IP)\])?(?: \[$re_IP\])* [<(]([^>)]*)[>)] -> [(<]([^(<]+)[)>]/o ))
      #XXX elsif (($action, $key, $ip, $from, $to) = ( $p1 =~ /^(?:Virus found - quarantined|(?:(Passed|Blocked) )?INFECTED) \(([^\)]+)\),[A-Z .]*(?: \[($re_IP)\])?(?: \[$re_IP\])* [<(]([^>)]*)[>)] -> [(<]([^(<]+)[(>]/o ))

      # the first IP is the envelope sender.
      if ($p1 !~ /^(CLEAN|SPAM(?:MY)?|INFECTED \(.*?\)|BANNED \(.*?\)|BAD-HEADER(?:-\d)?|UNCHECKED|MTA-BLOCKED|OVERSIZED|OTHER|TEMPFAIL)(?: \{[^}]+\})?, ([^[]+ )?(?:([^<]+) )?[<(](.*?)[>)] -> ([(<].*?[)>]), (?:.*Hits: ([-+.\d]+))(?:.* size: (\d+))?(?:.* autolearn=(\w+))?/) {
         inc_unmatched('passblock');
         next;
      }
      my $trigger;
      my ($ccatmajor, $pbanks, $ips, $from, $reciplist, $hits, $size, $autolearn) = ($1, $2, $3, $4, $5, $6, $7, $8);

      $Totals{'bytesscanned'} += $size  if defined $size;

      #print "ccatmajor: \"$ccatmajor\", pbanks: \"$pbanks\"\n";
      if ($ccatmajor =~ /^(INFECTED|BANNED) \((.*)\)$/) {
         ($ccatmajor, $trigger) = ($1, $2);
         #print "\tccatmajor: \"$ccatmajor\", trigger: \"$trigger\"\n";
      }

      $ccatmajor =~ s/(BAD-HEADER)-\d/$1/; # strip amavis 2.7's [:ccat|minor] BAD-HEADER sub-classification
      $sectkey = $ccatmajor_to_sectkey{$ccatmajor} . $action;
      $Totals{$sectkey}++;

      # Not checked by spamassassin, due to $sa_mail_body_size_limit or @bypass_spam_checks_maps
      if ($hits eq '-') {
         # Don't increment sabypassed for INFECTED (SA intentionally not called)
         unless ($ccatmajor eq 'INFECTED') {
            # The following order is used, the first condition met decides the outcome:
            #  1. a virus is detected: mail is considered infected;
            #  2. contains banned name or type: mail is considered banned;
            #  3. spam level is above kill level for at least one recipient, or a sender is blacklisted: mail is considered spam;
            #  4. bad (invalid) headers: mail is considered as having a bad header.
            # Priority: VIRUS BANNED UNCHECKED SPAM SPAMMY BADH OVERSIZED MTA CLEAN 
            $Totals{'sabypassed'}++;
         }
      } else {
         if ($Collecting{'spamscores'}) {
            no re 'taint';
            if ($hits =~ /^(-?[.\d]+)([-+])([.\d]+)$/) {
               $hits = eval $1.$2.$3;   # untaint $hits, to sum $1 and $3 values
            }
            # SA not called for ccats INFECTED and BANNED (Hits: -).
            # UNCHECKED may have a score, so we can't distinguish Ham from Spam
            push @{$SpamScores{$ccatmajor_to_spamham{$ccatmajor}}}, $hits;
         }
      }

      # autolearn is available here only if enabled in amavis template
      if ($autolearn ne '' and $Opts{'autolearn'}) {
      #if ($autolearn ne '' and ($ccatmajor eq 'SPAM' or $ccatmajor eq 'CLEAN')) {
         #  SA 2.5/2.6 : ham/spam/no
         #  SA 3.0+    : ham/spam/no/disabled/failed/unavailable
         # printf "INC: autolearn: %s, %s: %d\n", $ccatmajor eq 'SPAM' ? 'Spam' : 'Ham', $autolearn, $Opts{'autolearn'};;
         # Priorities other than SPAM will be considered HAM for autolearn stats
         $Counts{'autolearn'}{$ccatmajor eq 'SPAM' ? 'Spam' : 'Ham'}{$autolearn}++;
      }

      # p0f fingerprinting
      if (exists $p0ftags{$pid}) {
         my ($ip,$score,$os) = split(/\//, $p0ftags{$pid});
         $Counts{'p0f'}{ucfirst($ccatmajor_to_spamham{$ccatmajor})}{$os}{$ip}++;
         #print "Deleting p0ftag: $pid\n";
         delete $p0ftags{$pid};
      }

      next unless ($Collecting{$sectkey});
      # cleanpassed never gets here...

      # prefer xforward IP if it exists
      # $ip_a => %a  original SMTP session client IP address (empty if unknown, e.g. no XFORWARD)
      # $ip_e => %e  best guess of the originator IP address collected from the Received trace
      my ($ip_a, $ip_e) = split(/ /, $ips, 2);

      $ip = $ip_a ? $ip_a : $ip_e;
      $ip =~ s/[[\]]//g;
      #print "ip: \"$ip\", ip_a: \"$ip_a\", ip_e: \"$ip_e\", from: \"$from\", reciplist: \"$reciplist\"; hits: \"$hits\"\n";
      $ip   = '*unknown IP'  if ($ip   eq '');
      $from = '<>'           if ($from eq '');

      # Show first recipient only, or all
      my @recips = split /,/, $reciplist;
      @recips = map { /^<(.+)>$/ } @recips;
      # show only first recipient
      $to = lc ($Opts{'first_recip_only'} ? $recips[0] : "@recips");

      if ($ccatmajor eq 'INFECTED') {        # $ccatmajor: INFECTED  malwarepassed, malwareblocked
         $Counts{$sectkey}{$trigger}{$to}{$ip}{$from}++;
      }
      elsif ($ccatmajor eq 'BANNED') {       # $ccatmajor: BANNED  bannednamepassed, bannednameblocked
         $Counts{$sectkey}{$to}{$trigger}{$ip}{$from}++;
      } else {
         # $ccatmajor: CLEAN | SPAM{MY} | BAD-HEADER | UNCHECKED | MTA-BLOCKED | OVERSIZED | OTHER | TEMPFAIL
         # cleanpassed, cleanblocked, spampassed, spamblocked, badheaderpassed, badheaderblocked
         # uncheckedpassed, uncheckblocked, mtapassed, mtablocked, oversizedpassed, oversizedblocked
         # otherpassed, otherblocked, tempfailpassed, tempfailblocked
         $Counts{$sectkey}{$to}{$ip}{$from}++;
      }

      # old...
      #XXX elsif (($action, $item, $ip, $from, $to) = ( $p1 =~ /^(?:(Blocked|Passed) )?BANNED (?:name\/type )?\((.+)\),[^[]*(?: \[($re_IP)\])?(?: \[$re_IP\])* [<(]([^>)]*)[>)] -> [(<]([^(<]+)[(>]/o)) 
      #XXXX  elsif (($action, $ip, $from, $to) = ( $p1 =~ /^(?:(Passed|Blocked) )?UNCHECKED,[^[]*(?: \[($re_IP)\])?(?: \[$re_IP\])* [<(]([^>)]*)[>)] -> [(<]([^>)]*)[)>]/o ))
      #XXX elsif (($action, $ip, $from, $to) = ( $p1 =~ /^(?:(Passed|Blocked) )?TEMPFAIL,[^[]*(?: \[($re_IP)\])?(?: \[$re_IP\])* [<(]([^>)]*)[>)] -> [(<]([^>)]*)[)>]/o ))
      #XXX elsif (($action, $ip, $from, $to) = ( $p1 =~ /^(?:(Blocked|Passed) )?BAD-HEADER,[^[]*(?: \[($re_IP)\])?(?: \[$re_IP\])* [(<]([^>)]*)[)>](?: -> [(<]([^>)]+)[)>])[^:]*/o ))
      # amavis 2.3.1
      #BAD-HEADER, <> -> <info@example.com>, Message-ID: <200506440.1.sample.net>, Hits=-3.3 tag1=3.0 tag2=7.5 kill=7.5, tests=ALL_TRUSTED=-3.3, [10.0.0.1] 
   } # end Passed or Blocked

   # MAIA
   elsif ($p1 =~ /^FAKE SENDER, ([^:]+): ($[^,]+), (.*)$/o) {
      #TD FAKE SENDER, SPAM: 192.168.0.1, bogus@example.com
      $Totals{'fakesender'}++;            next unless ($Collecting{'fakesender'});
      $Counts{'fakesender'}{$1}{$2}{$3}++;
   }

   elsif ($p1 =~ /^p\d+ \d+(?:\/\d+)* Content-Type: ([^,]+)(?:, size: [^,]+, name: (.*))?/) {
      my ($ts, $name) = ($1, $2);
      #TD p006 1 Content-Type: multipart/mixed
      #TD p008 1/1 Content-Type: multipart/signed
      #TD p001 1/1/1 Content-Type: text/plain, size: 460 B, name: 
      #TD p002 1/1/2 Content-Type: application/pgp-signature, size: 189 B, name: 
      #TD p002 1/2 Content-Type: application/octet-stream, size: 3045836 B, name: abc.pdf
      next unless ($Collecting{'contenttype'});
      my ($type, $subtype) = $ts !~ '""' ? split /\//, $ts : ('unspecified', 'unspecified');

      $name = '' if !defined $name or $name =~ /^\s*$/;
      $Counts{'contenttype'}{$type}{$subtype}{$name}++;
   }

   # LMTP/SMTP connection
   # NOTE:  no longer used. size data now being obtained from Passed/Block line, as size info may not be available here
   #elsif (my ($size) = ($p1 =~ /^[LS]MTP:(?:\[$re_IP\])?:\d+ [^:]+: [<(](?:.*?)[>)] -> \S+ (?:SIZE=(\d+))?.*?Received: / )) {
   elsif ($p1 =~ /^[LS]MTP:/) {
     #TD LMTP::10024 /var/spool/amavis/tmp/amavis-20070119T144757-09086: <from@example.com> -> <to@sample.net> SIZE=1000 Received: from mail.sample.net ([127.0.0.1]) by localhost (mail.sample.net [127.0.0.1]) (amavisd-new, port 10024) with LMTP for <to@sample.net>; Fri, 19 Jan 2007 15:41:45 -0800 (PST)
     #TD SMTP:[127.0.0.1]:10024 /var/spool/amavis/tmp/amavis-20070119T144757-09086: <from@example.com> -> <to@sample.net>,<recip@sample.net> SIZE=2500000 Received: from mail.sample.net ([127.0.0.1]) by localhost (mail.sample.net [127.0.0.1]) (amavisd-new, port 10024) with LMTP for <to@sample.net>; Fri, 19 Jan 2007 15:41:45 -0800 (PST)
     #TD SMTP::10024 /var/lib/amavis/tmp/amavis-27-26927: <from@example.com> -> <to@example.net> Received: from localhost ([127.0.0.1]) by localhost (example.com [127.0.0.1]) (amavisd-new, port 10024) with SMTP for <to@example.net>; Sat,  7 Jun 2008 23:09:34 +0200 (CEST)
     #$Totals{'bytesscanned'} += $size  if defined $size;
   }

   #(\S+) ([^[(]+)(.*)$
   elsif ($p1 =~ /^OS_fingerprint: (\S+) ([-\d.]+) (\S+)(?: ([^[(]+|\[[^]]+\]))?/o) {
      #TD OS_fingerprint: 213.193.24.113 29.789 Linux 2.6 (newer, 1) (up: 1812 hrs), (distance 14, link: ethernet/modem)
      #TD OS_fingerprint: 10.47.2.155 -1.312 MYNETWORKS
      # Note: safe to delete entry when the final Passed/Block line occurs
      if ($Collecting{'p0f'}) {
         my ($genre,$vers) = ($3,$4);
         #print "p0f:\t$3\t\t$vers\n";
         if ($genre eq 'Windows') {
            local($1);
            $vers = $1  if $vers =~ /^(\S+) /;
            $genre .= ' ' . $vers;
         }
         elsif ($genre eq 'UNKNOWN') {
            $genre = 'Unknown';
         }
         $p0ftags{$pid} = join('/', $1,$2,$genre);
         #print "Added PID: $pid, $p0ftags{$pid}\n";
      }
   }

   elsif ( ($reason) = ( $p1 =~ /^BAD HEADER from [^:]+: (.+)$/) or
           ($reason) = ( $p1 =~ /check_header: \d, (.+)$/)) {
      # When log_level > 1, provide additional header or MIME violations

      # amavisd < 2.4.0, log_level >= 1
      #TD BAD HEADER from <bogus@example.com>: Improper use of control character (char 0D hex) in message header 'Received': Received: example.com[10.0.0.1\r]
      #TD BAD HEADER from <bogus@example.com>: Non-encoded 8-bit data (char F7 hex) in message header 'Subject': Subject: \367\345\370\361 \344\351\351\362\345\365\n    
      #TD BAD HEADER from <bogus@example.com>: MIME error: error: part did not end with expected boundary
      #TD BAD HEADER from (bulk ) <bogus@bounces@lists.example.com>: Non-encoded 8-bit data (char E6 hex) in message header 'Subject': Subject: spam\\346ham\\n 
      #TD BAD HEADER from (list) <bogus@bounces@lists.example.com>: MIME error: error: part did not end with expected boundary
      #  amavisd >= 2.4.3, log_level >= 2
      #TD check_header: 2, Non-encoded 8-bit data (char AE hex): Subject: RegionsNet\\256 Online Banking\\n
      #TD check_header: 2, Non-encoded 8-bit data (char E1 hex): From: "any user" <from\\341k@example.com>\\n
      #TD check_header: 3, Improper use of control character (char 0D hex): Content-type: text/html; charset=i...
      #TD check_header: 8, Duplicate header field: "Reply-To"
      #TD check_header: 8, Duplicate header field: "Subject"
      #TD check_header: 4, Improper folded header field made up entirely of whitespace (char 09 hex): X-Loop-Detect: 3\\n\\t\\n
      #TD check_header: 4, Improper folded header field made up entirely of whitespace: Received: ...8 ;         Thu, 10 Jan 2008 03:41:35 +0100\\n\\t \\n


      my $subreason;
      if ($reason =~ /^(.*?) \((char \S+ hex)\)(.*)$/) {
         $reason = $1;
         my ($char,$sub) = ($2,$3);

         $sub =~ s/^in message header '[^:]+': //;
         $sub =~ s/^: //;
         $subreason = "$char: $sub";
      }
      elsif ($reason =~ /^(Improper folded header field made up entirely of whitespace):? (.*)/) {
         $reason = $1;
         $subreason = $2;
      }
      elsif ($reason =~ /^(Duplicate header field): "(.+)"$/) {
         $reason = $1;
         $subreason = $2;
      }
      elsif ($reason =~ /^(MIME error): (?:error: )?(.+)$/) {
         $reason = $1;
         $subreason = $2;
      }

      $Totals{'badheadersupp'}++;            next unless ($Collecting{'badheadersupp'});
      $Counts{'badheadersupp'}{$reason}{$subreason}++;
   }

   elsif ($p1 =~ /^truncating a message passed to SA at/) {
      #TD truncating a message passed to SA at 431018 bytes, orig 1875912
      $Totals{'truncatedmsg'}++;
   }

   elsif ($p1 =~ /: spam level exceeds quarantine cutoff level/ or
          $p1 =~ /: cutoff, blacklisted/) {
      #TD do_notify_and_quarantine: spam level exceeds quarantine cutoff level 20
      #TD do_notify_and_quarantine: cutoff, blacklisted
      $Totals{'spamdiscarded'}++;
   }

   elsif ( $p1 =~ /^spam_scan: (.*)$/) {
      #if ($1 =~ /^not wasting time on SA, message longer than/ ) {
         #TD spam_scan: not wasting time on SA, message longer than 409600 bytes: 1326+4115601
         # this causes duplicate counts, and the subsequent Passed/Blocked log line
         # will have "Hits: -," whereby sabypassed is incremented.
         #$Totals{'sabypassed'}++;
      #}
      # ignore other spam_scan lines
   }

   # WARN:
   elsif ( ($reason) = ( $p1 =~ /^WARN: MIME::Parser error: (.*)$/ )) {
      # WARN: MIME::Parser error: unexpected end of header
      $Totals{'mimeerror'}++;                next unless ($Collecting{'mimeerror'});
      $Counts{'mimeerror'}{$reason}++;
   }

   elsif ($p1 =~ /^WARN: address modified \((\w+)\): <(.*?)> -> <(.*)>$/) {
      #TD WARN: address modified (sender): <root> -> <root@>
      #TD WARN: address modified (recip): <root> -> <root@>
      #TD WARN: address modified (recip): <postmaster> -> <postmaster@>
      #TD WARN: address modified (recip): <"test@example.com"@> -> <"teszt@example.com">
      #TD WARN: address modified (sender): <fr\344om@sample.net> -> <"fr\344om"@sample.net>
      $Totals{'warningaddressmodified'}++;   next unless ($Collecting{'warningaddressmodified'});
      $Counts{'warningaddressmodified'}{$1 eq 'sender' ? "Sender address" : "Recipient address"}{"$2 -> $3"}++;
   }

   # NOTICE:
   elsif ($p1 =~ /^NOTICE: (.*)$/) {
      # uninteresting
      #TD NOTICE: reconnecting in response to: err=2006, HY000, DBD::mysql::st execute failed: MySQL server has gone away at (eval 71) line 166, <GEN168> line 4.
      next if ($1 =~ /^Disconnected from SQL server/); # redundant
      next if ($1 =~ /^do_search: trying again: LDAP_OPERATIONS_ERROR/);
      next if ($1 =~ /^reconnecting in response to: /);


      if ($1 =~ /^Not sending DSN, spam level ([\d.]+ )?exceeds DSN cutoff level/) {
         #TD NOTICE: Not sending DSN, spam level exceeds DSN cutoff level for all recips, mail intentionally dropped
         $Totals{'dsnsuppressed'}++;
         $Counts{'dsnsuppressed'}{'DSN cutoff exceeded'}++;
      }
      elsif ($1 =~ /^Not sending DSN to believed-to-be-faked sender/) {
         #TD NOTICE: Not sending DSN to believed-to-be-faked sender <user@example.com>, mail containing VIRUS intentionally dropped 
         $Totals{'dsnsuppressed'}++;
         $Counts{'dsnsuppressed'}{'Sender likely faked'}++;
      }
      elsif ($1 =~ /^DSN contains [^;]+; bounce is not bounc[ai]ble, mail intentionally dropped/) {
         $Totals{'dsnsuppressed'}++;
         $Counts{'dsnsuppressed'}{'Not bounceable'}++;
      }
      elsif ($1 =~ /^UNABLE TO SEND DSN to /) {
         #TD NOTICE: UNABLE TO SEND DSN to <user@example.com>: 554 5.7.1 Failed, id=19838-01, from MTA([127.0.0.1]:10025): 554 5.7.1 <user@example.com>: Recipient address rejected: Access denied
         $Totals{'dsnsuppressed'}++;
         $Counts{'dsnsuppressed'}{'Unable to send'}++;
      }

      elsif ($1 =~ /^Skipping (?:bad|extra) output from file\(1\)/) {
         #TD NOTICE: Skipping extra output from file(1): blah
         #TD NOTICE: Skipping bad output from file(1) at [1, p002], got: blah
         $Totals{'fileoutputskipped'}++;
      }
      elsif (($p1) = ($1 =~ /^Virus scanning skipped: (.*)$/)) {
         #TD NOTICE: Virus scanning skipped: Maximum number of files (1500) exceeded at (eval 57) line 1283, <GEN212> line 1501.
         $Totals{'virusscanskipped'}++;      next unless ($Collecting{'virusscanskipped'});
         $Counts{'virusscanskipped'}{strip_trace($p1)}++;
      }
      else {
         inc_unmatched('NOTICE');
         next;
      }
   }

   # INFO:
   elsif ($p1 =~ /^INFO: (.*)$/) {
      next if ($1 =~ /^unfolded \d+ illegal all-whitespace continuation line/);
      next if ($1 =~ /^removed bare CR/);

      if ($1 =~ /^truncat(ed|ing)/) {
         #TD INFO: truncating long header field (len=2639): X-Spam-Report: =?iso-8859-1?Q?=0A=0A*__1=2E7_SUBJECT=5FENCODED=5FTWICE_Subject=3A_MIME_e?= =?iso-885...
         #TD INFO: truncated 1 header line(s) longer than 998 characters
         $Totals{'truncatedheader'}++;
      } elsif ( $1 =~ /^no existing header field 'Subject', inserting it/) {
         $Totals{'nosubject'}++;
      }
      elsif (my ($savers1, $savers2, $item) = ( $1 =~ /^(?:SA version: ([^,]+), ([^,]+), )?no optional modules: (.+)$/)) {
         #TD INFO: SA version: 3.1.8, 3.001008, no optional modules: DBD::mysql Mail::SpamAssassin::Plugin::DKIM Mail::SpamAssassin::Plugin::URIDetail Error
         next unless ($Opts{'startinfo'});
         if ($savers1 ne '') {
            $StartInfo{'sa_version'} = "$savers1 ($savers2)";
         }
         foreach my $code (split / /, $item) {
            $StartInfo{'Code'}{'Not loaded'}{$code} = "";
         }
      }
      elsif (my ($name) = ( $1 =~ /^(unknown banned table name \S+), .+$/)) {
         #TD INFO: unknown banned table name 1, recip=r@example.com
         $Totals{'warning'}++;      next unless ($Collecting{'warning'});
         $Counts{'warning'}{ucfirst $name}++;
      }
      else {
         inc_unmatched('INFO');
         next;
      }
   }

   elsif ( ($action,$reason,$from,$to) = ($p1 =~ /^DSN: NOTIFICATION: Action:([^,]+), ([^,]+), <(.*?)> -> <(.*?)>/)) {
      #TD DSN: NOTIFICATION: Action:failed, LOCAL 554 Banned, <from@example.net> -> <to@example.com>
      #TD DSN: NOTIFICATION: Action:delayed, LOCAL 454 Banned, <from@example.com> -> <to@example.net>

      $Totals{'dsnnotification'}++;    next unless ($Collecting{'dsnnotification'});
      $Counts{'dsnnotification'}{$action}{$reason}{"$from -> $to"}++;
   }

   elsif (($item, $from, $to) = ( $p1 =~ /^Quarantined message release(?: \([^)]+\))?: ([^ ]+) <(.*?)> -> (.+)$/) or
          ($item, $from, $to) = ( $p1 =~ /^Quarantine release ([^ ]+): overriding recips <([^>]*)> by (.+)$/)) {
      #TD Quarantine release arQcr95dNHaW: overriding recips <TO@EXAMPLE.COM> by <to@example.com>
      #TD Quarantined message release: hiyPJOsD2m9Z <from@sample.net> -> <to@example.com>
      #TD Quarantined message release: hiyPJOsD2m9Z <> -> <to@recipient.maildir>,<anyone@example.com>
      # 2.6+
      #TD Quarantined message release (miscategorized): Iu6+0u1voOA <from@example.com> -> <to@example.net>
      $Totals{'released'}++;           next unless ($Collecting{'released'});
      $from = '<>' if ($from eq '');
      $to =~ s/[<>]//g;
      $Counts{'released'}{"\L$from"}{$to}{$item}++;
   }
   elsif ($p1 =~ /^Quarantine release ([^:]+): missing X-Quarantine-ID$/) {
      #TD Quarantine release 7ejEBC7MThSc: missing X-Quarantine-ID
      $Totals{'warningnoquarantineid'}++; next unless ($Collecting{'warningnoquarantineid'});
      $Counts{'warningnoquarantineid'}{$1}++;
   }

   elsif ( ($stage,$reason) = ($p1 =~ /^Negative SMTP resp\S* +to ([^:]+): *(.*)$/)) {
      #TD Negative SMTP response to data-dot (<u@example.com>): 550 5.7.1 Header Spam Rule 4
      $Totals{'smtpresponse'}++;       next unless ($Collecting{'smtpresponse'});
      $Counts{'smtpresponse'}{'Negative response'}{$stage}{$reason}++;
   }
   elsif ( ($stage,$reason) = ($p1 =~ /^smtp resp to ([^:]+): *(.*)$/)) {
      #TD smtp resp to NOOP (idle 4799.4 s): 421 4.4.2 nops.overtops.org Error: timeout exceeded 
      #TD smtp resp to MAIL (pip): 250 2.1.0 Ok
      $Totals{'smtpresponse'}++;       next unless ($Collecting{'smtpresponse'});
      $stage =~ s/ [\d.]+ s//;
      $Counts{'smtpresponse'}{'Response'}{$stage}{$reason}++;
   }

   elsif ( ($item) = ($p1 =~ /^response to RCPT TO for <([^>]*)>: "501 Bad address syntax"/)) {
      #TD response to RCPT TO for <""@example.com>: "501 Bad address syntax"
      $Totals{'badaddress'}++;         next unless ($Collecting{'badaddress'});
      $Counts{'badaddress'}{$item}++;
   }

   # do_unip: archive extraction
   elsif ($p1 =~ s/^do_unzip: \S+, //) {
      $Totals{'archiveextract'}++;     next unless ($Collecting{'archiveextract'});

      if ( $p1 =~ s/^\d+ members are encrypted, //) {
         #TD do_unzip: p003, 4 members are encrypted, none extracted, archive retained
         $Counts{'archiveextract'}{'Encrypted'}{$p1}++;

      } elsif ( $p1 =~ /^zero length members, archive retained/) {
         #TD do_unzip: p002, zero length members, archive retained
         $Counts{'archiveextract'}{'Empty member'}{''}++;

      } elsif ($p1 =~ s/^unsupported compr\. method: //) {
         #TD do_unzip: p003, unsupported compr. method: 99
         $Counts{'archiveextract'}{'Unsupported compression'}{$p1}++;
      }
      else {
         $Counts{'archiveextract'}{'*unknown'}{$p1}++;
      }
   }

   # do_cabextract: archive extraction
   elsif ($p1 =~ s/^do_cabextract: //) {
      #TD do_cabextract: can't parse toc line:  File size | Date       Time     | Name
      #TD do_cabextract: can't parse toc line: All done, no errors.
      $Totals{'archiveextract'}++;     next unless ($Collecting{'archiveextract'});

      if ($p1 =~ /^([^:]+):\s*(.*)/) {
         $Counts{'archiveextract'}{"\u$1"}{$2}++;
      } else {
         $Counts{'archiveextract'}{$p1}{''}++;
      }
   }

   elsif ($p1 =~ /^(?:\(!\) *)?SA TIMED OUT,/) {
      $Totals{'satimeout'}++;
   }

   elsif ($p1 =~ /^mangling (.*)$/) {
      $p1 = $1;
      if ($p1 =~ /^by (.+?) failed: (.+?), mail will pass unmodified$/) {
         #TD mangling by altermine failed: SomeText, mail will pass unmodified
         $Totals{'defangerror'}++;        next unless ($Collecting{'defangerror'});
         $Counts{'defangerror'}{$1}{$2}++;
      }
      # other mangle message skipped
      else {
         #TD mangling YES: 1 (orig: 1), discl_allowed=0, <from@example.com> -> <to@sample.net>
         #TD mangling by built-in defanger: 1, <user@example.com>
         next;
      }
   }
   elsif ($p1 =~ /^DEFANGING MAIL: (.+)$/) {
      # log_level 1
      #TD DEFANGING MAIL: WARNING: possible mail bomb, NOT CHECKED FOR VIRUSES:\n  Exceeded storage quota 5961070 bytes by d... 
      #TD DEFANGING MAIL: WARNING: bad headers - Improper use of control character (char 0D hex): To: <to@example.com\\r>,\\n\\t<to@example.com>
      # could use instead...
      #do_log(1,"mangling by %s (%s) done, new size: %d, orig %d bytes", $actual_mail_mangle, $mail_mangle, $repl_size, $msginfo->msg_size);
      $Totals{'defanged'}++;        next unless ($Collecting{'defanged'});
      $Counts{'defanged'}{$1}++;
   }

   elsif ($p1 =~ /^PenPalsSavedFromKill [-.\d]+,/) {
      #TD PenPalsSavedFromKill 8.269-3.160, <ulyanov@steelpro.com.ua> -> <recipient1@recipientdomain.com>
      $Totals{'penpalsaved'}++;
   }

   # I don't know how many variants of time outs there are... I suppose we'll fix as we go
   elsif (($p1 =~ /^\(!+\)([^ ]*) is taking longer than \d+ s and will be killed/) or 
          ($p1 =~ /^\(!+\)(.*) av-scanner FAILED: timed out/) or
          ($p1 =~ /^(?:\(!+\))?(.*): timed out/))
   {
      #TD (!)/usr/local/bin/uvscan is taking longer than 10 s and will be killed
      #TD (!!)NAI McAfee AntiVirus (uvscan) av-scanner FAILED: timed out
      #TD ClamAV-clamd: timed out, retrying (1)
      #TD (!)Sophie: timed out, retrying (2)

      $Totals{'avtimeout'}++;                next unless ($Collecting{'avtimeout'});
      $Counts{'avtimeout'}{$1}++;
   }
   elsif (($p2) = ($p1 =~ /SMTP shutdown: (.*)$/)) {                      # log level -1
      #TD SMTP shutdown: Error writing a SMTP response to the socket: Broken pipe at (eval 49) line 836, <GEN232> line 51.
      #TD SMTP shutdown: tempdir is to be PRESERVED: /var/amavis/tmp/amavis-20070704T095350-13145
      strip_trace($p2);
      if ($p2 =~ /^tempdir is to be PRESERVED: (.*)\/([^\/]+)$/) {
         $Totals{'tmppreserved'}++;
         $Counts{'tmppreserved'}{$1}{$2}++   if ($Collecting{'tmppreserved'});
         $p2 = "Preserved tempdir in $1";
      }
      $Totals{'warningsmtpshutdown'}++;      next unless ($Collecting{'warningsmtpshutdown'});
      $Counts{'warningsmtpshutdown'}{ucfirst($p2)}++;
   }

   elsif (($p1 =~ /PRESERVING EVIDENCE in (.*)\/([^\/]+)$/) or
         ($p1 =~ /tempdir is to be PRESERVED: (.*)\/([^\/]+)$/)) {
      #TD (!)TempDir removal: tempdir is to be PRESERVED: /var/amavis/tmp/amavis-20080110T173606-05767
      # log level -1
      #TD PRESERVING EVIDENCE in /var/amavis/tmp/amavis-20070704T111558-14883
      $Totals{'tmppreserved'}++;             next unless ($Collecting{'tmppreserved'});
      $Counts{'tmppreserved'}{$1}{$2}++;
   }

   elsif ($p1 =~ /^Open relay\? Nonlocal recips but not originating/) {
      $Totals{'warningsecurity'}++;
      $Counts{'warningsecurity'}{$p1}++    if ($Collecting{'warningsecurity'});
   }

   # keep before general warnings below, so sadiag gets first crack at log
   # lines beginning with "(!) ...".
   elsif ($p1 =~ /^(?:\(!+\))?\!?SA (warn|info|error): (.*)$/) {
      #TD SA warn: FuzzyOcr: Cannot find executable for gocr
      my ($level,$msg) = ($1,$2);

      # XXX later, maybe break out stats on FuzzyOcr
      # skip "image too small" for now
      if ($msg =~ /^FuzzyOcr: Skipping .+, image too small$/) {
         #TD SA warn: FuzzyOcr: Skipping ocrad, image too small
         #TD SA warn: FuzzyOcr: Skipping ocrad-decolorize, image too small
         #$Counts{'sadiags'}{'fuzzyocr'}{'image too small'}++;
         next;
      }
      elsif ($msg =~ /dns: \[\.\.\.\]/) {
	 #TD SA info: dns: [...] ;; ADDITIONAL SECTION (1 record)
	 next;
      }
      # canonicalize some PIDs and IDs
      elsif ($msg =~ s/^pyzor: \[\d+\] error/pyzor: [<PID>] error/) {
	 #TD SA info: pyzor: [11550] error: TERMINATED, signal 15 (000f)
      }
      elsif ($msg =~ /dns: no likely matching queries for id \d+/) {
	 $msg =~ s/\d+/<ID>/;
      }
      elsif ($msg =~ /dns: no callback for id \d+/) {
	 $msg =~ s/\d+.*$/<ID>.../;
      }

      # report other SA warn's
      $Totals{'sadiags'}++;
      next unless ($Collecting{'sadiags'});
      $Counts{'sadiags'}{ucfirst($level)}{$msg}++;
   }

   # catchall for most other warnings
   elsif (($p1 =~ /^\(!+\)/) or
          ($p1 =~ /^TROUBLE/) or
          ($p1 =~ /Can't (?:connect to UNIX|send to) socket/) or
          ($p1 =~ /: Empty result from /) or
          ($p1 =~ /: Error reading from socket: Connection reset by peer/) or
          ($p1 =~ /open\(.*\): Permission denied/) or
          ($p1 =~ /^_?WARN: /) or
          ($p1 =~ /Can't send SIG \d+ to process \[\d+\]: Operation not permitted/) or
          ($p1 =~ /(policy protocol: INVALID(?: AM\.PDP)? ATTRIBUTE LINE: .*)$/) or
          ($p1 =~ /(DKIM signature verification disabled, corresponding features not available. If not intentional.*)$/)
         )
   {
      #TD (!)loading policy bank "AM.PDP-SOCK": unknown field "0"
      #TD (!!)policy_server FAILED: SQL quarantine code not enabled at (eval 37) line 306, <GEN6> line 4.
      #TD (!!)policy_server FAILED: Can't open file /var/spool/amavis/quarantine/spam-CFJYXmeS+FLy: Permission denied at (eval 37) line 330, <GEN28> line 5.
      #TD ClamAV-clamd: Empty result from /var/run/clamav/clamd, retrying (1)
      #TDdcc open(/var/dcc/map): Permission denied
      #TD TROUBLE in check_mail:  FAILED: Died at /usr/sbin/amavisd-maia line 2872, <GEN4> line 22.
      #TD TROUBLE in check_mail: spam_scan FAILED: DBD::mysql::st execute failed: MySQL server has gone away at /usr/sbin/amavisd-maia line 3786, <GEN4> line 3036.
      #TD TROUBLE in process_request: DBD::mysql::st execute failed: MySQL server has gone away at (eval 35) line 258, <GEN18> line 3.
      #TD TROUBLE in process_request: DBD::mysql::st execute failed: Lost connection to MySQL server during query at (eval 35) line 258, <GEN3> line 3.
      #TD TROUBLE in process_request: Can't call method "disconnect" on an undefined value at /usr/sbin/amavisd-maia line 2895, <GEN4> line 22.
      #TD TROUBLE: recipient not done: <to@example.com> smtp response ...
      #TD (!!)TROUBLE in process_request: Can't create file /var/amavis/tmp/amavis-98/email.txt: File exists at /usr/local/sbin/amavisd line 4774, <GEN12> line 4.
      #TD TROUBLE: lookup table is an unknown object: object ...
      #TD (!) policy protocol: INVALID ATTRIBUTE LINE: /var/spool/courier/tmp/114528/D967099\n 
      #TD (!) policy protocol: INVALID AM.PDP ATTRIBUTE LINE: /var/spool/courier/tmp/114528/D967099\n 
      #TD _WARN: bayes: cannot open bayes databases /var/spool/amavis/.spamassassin/bayes_* R/W: lock failed: Interrupted system call\n

      $p1 =~ s/^\(!+\)s*//;

      if ($p1 =~ /^WARN: (Using cpio instead of pax .*)$/) {
         #TD (!)WARN: Using cpio instead of pax can be a security risk; please add:  $pax='pax';  to amavisd.conf and check that the pax(1) utility is available on the system!
         $Totals{'warningsecurity'}++;
         $Counts{'warningsecurity'}{$1}++    if ($Collecting{'warningsecurity'});
         next;
      }

      $p1 =~ s/, retrying\s+\(\d+\)$//;
      strip_trace($p1);

      # canonicalize variations of the same message
      $p1 =~ s/^run_av \(([^,]+), built-in i\/f\)/$1/;
      $p1 =~ s/ av-scanner FAILED: CODE\(0x[^)]+\)/:/;
      $p1 =~ s/^(.+: Too many retries to talk to \S+) .*/$1/;

      if (($p1 =~ /(\S+): Can't (?:connect|send) to (?:UNIX )?(.*)$/) or
          ($p1 =~ /(\S+): (Too many retries to talk to .*)$/))
      {

         #TD (!)ClamAV-clamd: Can't connect to UNIX socket /var/run/clamav/clamd.socket: No such file or directory, retrying (2)
         #TD (!)ClamAV-clamd: Can't connect to UNIX socket /var/run/clamav/clamd: Connection refused, retrying (2)
         #TD ClamAV-clamd: Can't connect to UNIX socket /var/run/clamav/clamd: Connection refused, retrying (1)
         #TD ClamAV-clamd: Can't send to socket /var/run/clamav/clamd: Transport endpoint is not connected, retrying (1)
         #TD Sophie: Can't send to socket /var/run/sophie: Transport endpoint is not connected, retrying (1)
         #TD (!)run_av (Sophie, built-in i/f): Too many retries to talk to /var/run/sophie (timed out) at (eval 55) line 310, <GEN16> line 16.
         #TD (!)run_av (ClamAV-clamd, built-in i/f): Too many retries to talk to /var/run/clamav/clamd.socket (Can't connect to UNIX socket /var/run/clamav/clamd.socket: No such file or directory) at (eval 52) line 310.
         #TD (!!)ClamAV-clamd av-scanner FAILED: CODE(0x804fa08) Too many retries to talk to /var/run/clamav/clamd.socket (Can't connect to UNIX socket /var/run/clamav/clamd.socket: No such file or directory) at (eval 52) line 310. at (eval 52) line 511.
         #TD (!!)Sophie av-scanner FAILED: CODE(0x814fd24) Too many retries to talk to /var/run/sophie (timed out) at (eval 55) line 310, <GEN16> line 16. at (eval 55) line 511, <GEN16> line 16.

         $Totals{'avconnectfailure'}++;
         $Counts{'avconnectfailure'}{$1}{ucfirst($2)}++     if ($Collecting{'avconnectfailure'});
         next;
      }

      # simplify or canonicalize variations of the same message
      $p1 =~ s/^TROUBLE(:| in) //;
      $p1 =~ s/^_?WARN: //;
      $p1 =~ s/Can't create file \S+: (.+)$/Can't create file: $1/;
      $p1 =~ s/Can't send SIG \d+ to process \[\d+\]/Can't send SIG to process/;

      $Totals{'warning'}++;      next unless ($Collecting{'warning'});
      $Counts{'warning'}{$p1}++;
   }

   # Begin forced warnings: Keep this code below warning catchall
   elsif ($p1 =~ /^lookup_sql: /) {
      #TD lookup_sql: 2006, MySQL server has gone away
      $Totals{'warningsql'}++;   next unless ($Collecting{'warningsql'});
      $Counts{'warningsql'}{'SQL died'}++;

   } elsif (($reason,$item) = ($p1 =~ /^connect_to_sql: ([^']+) '\S+': (.*?)(?: \(\d+\))?$/) or
            ($item,$reason) = ($p1 =~ /^lookup_sql_field\((.*)\) \(WARN: (no such field in the SQL table)\)/)) {
      #TD connect_to_sql: unable to connect to DSN 'DBI:mysql:maia:sqlhost1.example.com': Lost connection to MySQL server during query
      #TD connect_to_sql: unable to connect to DSN 'DBI:mysql:maia:sqlhost2.example.com': Can't connect to MySQL server on 'sqlhost2.example.com' (111)
      #TD lookup_sql_field(id) (WARN: no such field in the SQL table), "from@example.com" result=undef
      $Totals{'warningsql'}++;   next unless ($Collecting{'warningsql'});
      $Counts{'warningsql'}{ucfirst("$reason: $item")}++;
   }
   # End forced warnings

   # panic
   elsif ( ($p2) = ($p1 =~ /^(?:\(!\)\s*)?PANIC, (.*)$/)) {
      #TD PANIC, PANIC, SA produced a clone process of [19122], TERMINATING CLONE [19123]

      $Totals{'panic'}++;        next unless ($Collecting{'panic'});
      $Counts{'panic'}{$p2}++;

   }

   # fatal
   elsif ( $p1 =~ /^Requesting process rundown after fatal error$/) {
      #TD Requesting process rundown after fatal error
      $Totals{'fatal'}++;        next unless ($Collecting{'fatal'});
      $Counts{'fatal'}{$p1}++;

   # DCC
   } elsif (($reason) = ($p1 =~ /^(missing message body; fatal error)/) or
            ($reason) = ($p1 =~ /^(try to start dccifd)/)) {
      $Totals{'dccerror'}++;     next unless ($Collecting{'dccerror'});
      $Counts{'dccerror'}{ucfirst($reason)}++;
   }
   elsif ($p1 =~ /^continue not asking DCC \d+ seconds after failure/) {
      $Totals{'dccerror'}++;     next unless ($Collecting{'dccerror'});
      $Counts{'dccerror'}{'Continue not asking DCC after failure'}++;
   }
   elsif ($p1 =~ /^no DCC answer from (\S+) after \d+ ms$/) {
      $Totals{'dccerror'}++;     next unless ($Collecting{'dccerror'});
      $Counts{'dccerror'}{"No answer from $1"}++;
   }

   elsif ( ($reason, $from, $to) = ($p1 =~ /^skip local delivery\((\d+)\): <(.*?)> -> <(.*?)>$/)) {
      $Totals{'localdeliveryskipped'}++;   next unless ($Collecting{'localdeliveryskipped'});
      $from = '<>' if ($from eq '');
      $reason = $reason == 1 ? "No localpart" : $reason == 2 ? "Local alias is null" : "Other";
      $Counts{'localdeliveryskipped'}{$reason}{$from}{$to}++;
   }

   # hard and soft whitelisted/blacklisted
   elsif ($p1 =~ /^wbl: (.*)$/) {
      # ignore wbl entries, can't think of good way to reliably summarize.
      # and 'black or whitelisted by all' makes using by-white or -black list
      # groupings impossible
      next;
=cut
      $p1 = $1;

      # TD wbl: black or whitelisted by all recips
      next if ($p1 =~ /^black or whitelisted/); # not clear how to report this, so skip
      next if ($p1 =~ /^checking sender/);                    # ll 4
      next if ($p1 =~ /^(LDAP) query keys/);                  # ll 5
      next if ($p1 =~ /^(LDAP) recip/);                       # ll 5
      next if ($p1 =~ /^recip <[^>]*> (?:black|white)listed sender/);  # ll 5

      # lookup order: SQL, LDAP, static
      if ($p1 =~ s/^\(SQL\) recip <[^>]*>//) {
         next if ($p1 =~ /^, \S+ matches$/);                  # ll 5
         next if ($p1 =~ /^, rid=/);                          # ll 4
         next if ($p1 =~ /^ is neutral to sender/);           # ll 5
         next if ($p1 =~ /^ (?:white|black)listed sender </); # ll 5
         # ll -1
         #wbl: (SQL) recip <%s> whitelisted sender <%s>, '.  unexpected wb field value
      }
      #ll2
      # wbl: (SQL) soft-(white|black)listed (%s) sender <%s> => <%s> (rid=%s)',  $val, $sender, $recip, $user_id);
      # multiple senders: message sender, then "from", etc.
      #ll2
      # wbl: soft-(white|black)listed (%s) sender <%s> => <%s>,

      #TD wbl: whitelisted sender <sender@example.com>
      #TD wbl: soft-whitelisted (-3) sender <from@example.com> => <to@sample.net>, recip_key="."
      #TD wbl: whitelisted by user@example.com, but not by all, sender <bounces@example.net>, <user@example.org>
      # wbl: (whitelisted|blacklisted|black or whitelisted by all recips|(white|black)listed by xxx,yyy,... but not by all) sender %s

      if ($p1 =~ /^(?:\(SQL\) )?(?:(soft)-)?((?:white|black)listed)(?: \([^)]+\))? sender <([^>]*)>/) {
         my ($type,$list,$sender) = ($1,$2,$3);
         $Totals{$list}++;       next unless ($Collecting{$list});
         $type = $type ? 'Soft' : 'Hard' ;
         my ($localpart, $domainpart) = split (/@/, lc $sender);
         ($localpart, $domainpart) = ($sender, '*unspecified')   if ($domainpart eq '');
         $Counts{$list}{$type}{$domainpart}{$localpart}++;
      }
      else {
         inc_unmatched('wbl');
         next;
      }
=cut
   }

   # XXX: WHITELISTED or BLACKLISTED should be caught in SPAM tag above
   elsif (($p1 =~ /^white_black_list: whitelisted sender/) or
          ($p1 =~ /.* WHITELISTED/) ) {
      $Totals{'whitelisted'}++;

   } elsif (($p1 =~ /^white_black_list: blacklisted sender/) or
	        ( $p1 =~ /.* BLACKLISTED/) ) {
      $Totals{'blacklisted'}++;

   } elsif ($p1 =~ /^Turning AV infection into a spam report: score=([^,]+), (.+)$/) {
      #TD Turning AV infection into a spam report: score=4.1, AV:Sanesecurity.ScamL.375.UNOFFICIAL=4.1
      #TD Turning AV infection into a spam report: score=3.4, AV:Sanesecurity.Phishing.Cur.180.UNOFFICIAL=3.1,AV:Sanesecurity.Phishing.Cur.180.UNOFFICIAL=3.4
      #BAT.Backdoor.Poisonivy.E178-SecuriteInfo.com

      next unless ($Collecting{'malwaretospam'});
      #my $score_max = $1;
      my @list = split (/,/, $2);
      @list = unique_list(\@list);
      foreach (@list) {
         my ($name,$score) = split (/=/,$_);
         $name =~ s/^AV://;
         my $type = $name =~ s/\.UNOFFICIAL$// ? 'Unofficial' : 'Official';
         # strip trailing numeric variant (...Phishing.Cur.863)
         my $variant = $name =~ s/([.-]\d+)$// ?  $1 : '*invariant';
         $Counts{'malwaretospam'}{$type}{$name}{$variant}{$score}++
      }

   # The virus_scan line reports only the one virus name when more than one scanner detects a virus.
   # Use instead the ask_av and run_av lines (see below)
   #
   #} elsif ( my ($malware, $scanners) = ($p1 =~ /virus_scan: \(([^)]+)\), detected by \d+ scanners: (.*)$/ )) {
      #TD virus_scan: (HTML.Phishing.Bank-43), detected by 1 scanners: ClamAV-clamd
      #TD virus_scan: (Worm.SomeFool.D, Worm.SomeFool.D), detected by 1 scanners: ClamAV-clamd
      #TD virus_scan: (Trojan.Downloader.Small-9993), detected by 2 scanners: ClamAV-clamd, NAI McAfee AntiVirus (uvscan)
   #   foreach (split /, /, $scanners) {
   #      #$Totals{'malwarebyscanner'}++;       # No summary output: redundant w/malwarepassed,malwareblocked}
   #      $Counts{'malwarebyscanner'}{"$_"}{$malware}++;
   #   }

   } elsif ($p1 =~ /^(?:ask_av|run_av) (.*)$/) {
      next unless ($Collecting{'malwarebyscanner'});

      if (my ($scanner, $name) = ($1 =~ /^\((.+)\):(?: [^:]+)? INFECTED: ([^,]+)/)) {
         #TD ask_av (ClamAV-clamd): /var/amavis/tmp/amavis-20070830T070403-13776/parts INFECTED: Email.Malware.Sanesecurity.07082700
         #TD run_av (NAI McAfee AntiVirus (uvscan)): INFECTED: W32/Zhelatin.gen!eml, W32/Zhelatin.gen!eml
         my $type = $name =~ s/\.UNOFFICIAL$// ? 'Unofficial' : 'Official';
         my $variant = '';
         if ($name =~ s/([.-]\d+)$//) {     # strip trailing numeric variant (...Phishing.Cur.863)
            $variant = $1;
         }
         $Counts{'malwarebyscanner'}{$scanner}{$type}{$name}{$variant}++;
      }
      # currently ignoring other ask_av or run_av lines
   }

   # Extra Modules loaded at runtime
   #TD extra modules loaded after daemonizing/chrooting: Mail/SPF/Query.pm
   elsif (($item) = ( $p1 =~ /^extra modules loaded(?: after daemonizing(?:\/chrooting)?)?: (.+)$/)) {
      #TD extra modules loaded: PerlIO.pm, PerlIO/scalar.pm
      foreach my $code (split /, /, $item) {
         #TD extra modules loaded: unicore/lib/gc_sc/Digit.pl, unicore/lib/gc_sc/SpacePer.pl
         # avoid useless reporting of pseudo-modules which can't be pre-loaded once
         unless ($code =~ m#^unicore/lib/#) {
            $Totals{'extramodules'}++;
            $Counts{'extramodules'}{$code}++    if ($Collecting{'extramodules'});
         }
      }

   # Timing report
   } elsif (my ($total,$report) = ( $p1 =~ /^(?:size: \d+, )?TIMING \[total (\d+) ms(?:, [^]]+)?\] - (.+)$/)) {
     next if ($report =~ /^got data/);    # skip amavis release timing
      #TD TIMING [total 5808 ms] - SMTP greeting: 5 (0%)0, SMTP LHLO: 1 (0%)0, SMTP pre-MAIL: 2 (0%)0, SMTP pre-DATA-flush: 5 (0%)0, SMTP DATA: 34 (1%)1, check_init: 1 (0%)1
      # older format, maia mailguard
      #TD TIMING [total 3795 ms] - SMTP EHLO: 1 (0%), SMTP pre-MAIL: 0 (0%), maia_read_system_config: 1 (0%), maia_get_mysql_size_limit: 0 (0%), SA check: 3556 (94%), rundown: 0 (0%)
      # v2.8.1
      # .... size: 3815, TIMING [total 1901 ms, cpu 657 ms] - ...


      # Timing line is incomplete - let's report it
      if ($p1 !~ /\d+ \(\d+%\)\d+$/ and $p1 !~ /\d+ \(\d+%\)$/) {
         inc_unmatched('timing');
         next;
      }

      if ($Opts{'timings'}) {
         my @pairs = split(/[,:] /, $report);
         while (my ($key,$value) = @pairs) {
            #4 (0%)0
            my ($ms) = ($value =~ /^([\d.]+) /);
            # maintain a per-test list of timings
            push @{$Timings{$key}}, $ms;
            shift @pairs; shift @pairs;
         }
         push @TimingsTotals, $total;
      }

   } elsif ((($total,$report) = ( $p1 =~ /^TIMING-SA total (\d+) ms - (.+)$/ )) or
            (($total,$report) = ( $p1 =~ /^TIMING-SA \[total (\d+) ms, cpu \d+ ms\] - (.+)$/ ))) {
         #TIMING-SA [total 3219 ms, cpu 432 ms] - parse: 6 (0.2%), ext
      #TD TIMING-SA total 5478 ms - parse: 1.69 (0.0%), extract_message_metadata: 16 (0.3%), get_uri_detail_list: 2 (0.0%), tests_pri_-1000: 25 (0.4%), tests_pri_-950: 0.67 (0.0%), tests_pri_-900: 0.83 (0.0%), tests_pri_-400: 19 (0.3%), check_bayes: 17 (0.3%), tests_pri_0: 5323 (97.2%), check_spf: 12 (0.2%), poll_dns_idle: 0.81 (0.0%), check_dkim_signature: 1.50 (0.0%), check_razo r2: 5022 (91.7%), check_dcc: 192 (3.5%), check_pyzor: 0.02 (0.0%), tests_pri_500: 9 (0.2%), tests_pri_1000: 24 (0.4%), total_awl: 23 (0.4%), check_awl: 10 (0.2%), update_awl: 8 (0.1%), learn: 36 (0.7%), get_report: 1.77 (0.0%)

      # Timing line is incomplete - let's report it
      if ($p1 !~ /[\d.]+ \([\d.]+%\)[\d.]+$/ and $p1 !~ /[\d.]+ \([\d.]+%\)$/) {
         inc_unmatched('timing-sa');
         next;
      }
      if ($Opts{'sa_timings'}) {
         my @pairs = split(/[,:] /, $report);
         while (my ($key,$value) = @pairs) {
            #4 (0%)0
            my ($ms) = ($value =~ /^([\d.]+) /);
            # maintain a per-SA test list of timings
            push @{$TimingsSA{$key}}, $ms;
            shift @pairs; shift @pairs;
         }
         push @TimingsSATotals, $total;
      }

   # Bounce killer: 2.6+
   } elsif ($p1 =~ /^bounce (.*)$/) {
      #TD bounce killed, <user@example.com> -> <to@example.net>, from: user@example.com, message-id: <CA8E335-CC-2EFB@example.com>, return-path: <user@example.com>
      #TD bounce rescued by domain, <user@example.com> -> <to@example.net>, from: user@example.com, message-id: <CA8E335-CC-2EFB@example.com>, return-path: <user@example.com>
      #TD bounce rescued by originating, <user@example.com> -> <to@example.net>, from: user@example.com, message-id: <CA8E335-CC-2EFB@example.com>, return-path: <user@example.com>
      #TD bounce rescued by: pen pals disabled, <user@example.com> -> <to@example.net>, from: user@example.com, message-id: <CA8E335-CC-2EFB@example.com>, return-path: <user@example.com>
      $p2 = $1;

      if ($p2 =~ /^killed, <(.+?)> -> /) {
         $Totals{'bouncekilled'}++;
         $Counts{'bouncekilled'}{$1 eq '' ? '<>' : $1}++    if ($Collecting{'bouncekilled'});
      }
      elsif ($p2 =~ /^rescued by ([^,]+), <(.+?)> -> /) {
         # note: ignores "rescued by: pen pals disabled"
         $Totals{'bouncerescued'}++;
         $Counts{'bouncerescued'}{'By ' . $1}{$2 eq '' ? '<>' : $2}++    if ($Collecting{'bouncerescued'});
      }
      elsif ($p2 =~ /^unverifiable, <(.+?)> -> /) {
         # note: ignores "rescued by: pen pals disabled"
         $Totals{'bounceunverifiable'}++;
         $Counts{'bounceunverifiable'}{$1 eq '' ? '<>' : $1}++    if ($Collecting{'bounceunverifiable'});
      }
      #TD bounce unverifiable, <postmaster@nurturegood.com> -> <dave@davewolloch.com>
      #TD bounce unverifiable, <> -> <Dave@davewolloch.com>
   }

   # Decoders
   elsif (my ($suffix, $info) = ( $p1 =~ /^Internal decoder for (\.\S*)\s*(?:\(([^)]*)\))?$/)) {
      #TD Internal decoder for .gz   (backup, not used)
      #TD Internal decoder for .zip 
      next unless ($Opts{'startinfo'});
      $StartInfo{'Decoders'}{'Internal'}{$suffix} = $info;
   }

   elsif (($suffix, $decoder) = ( $p1 =~ /^No decoder for\s+(\.\S*)\s*(?:tried:\s+(.*))?$/)) {
      #TD No decoder for       .tnef tried: tnef
      # older
      #TD No decoder for       .doc
      next unless ($Opts{'startinfo'});
      $StartInfo{'Decoders'}{'None'}{$suffix} = "tried: " . ($decoder ? $decoder : "unknown");
   }

   elsif (($suffix, $decoder) = ( $p1 =~ /^Found decoder for\s+(\.\S*)\s+at\s+(.*)$/)) {
      #TD Found decoder for    .bz2  at /usr/bin/bzip2 -d
      #TD Found decoder for    .bz2  at /usr/bin/7za (backup, not used)
      next unless ($Opts{'startinfo'});
      $StartInfo{'Decoders'}{'External'}{$suffix} = exists $StartInfo{'Decoders'}{'External'}{$suffix} ?
              join '; ', $StartInfo{'Decoders'}{'External'}{$suffix}, $decoder : $decoder;
   }

   # AV Scanners
   elsif (my ($tier, $scanner, $location) = ( $p1 =~ /^Found (primary|secondary) av scanner (.+) at (.+)$/)) {
      #TD Found primary av scanner NAI McAfee AntiVirus (uvscan) at /usr/local/bin/uvscan
      #TD Found secondary av scanner ClamAV-clamscan at /usr/local/bin/clamscan
      next unless ($Opts{'startinfo'});
      $StartInfo{'AVScanner'}{"\u$tier"}{$scanner} = $location;

   } elsif (($tier, $scanner, $location) = ( $p1 =~ /^No (primary|secondary) av scanner: (.+)$/)) {
      #TD No primary av scanner: CyberSoft VFind
      next unless ($Opts{'startinfo'});
      $StartInfo{'AVScanner'}{"\u$tier (not found)"}{$scanner} = '';

   } elsif ( (($tier, $scanner) = ( $p1 =~ /^Using internal av scanner code for \(([^)]+)\) (.+)$/)) or
             (($tier, $scanner) = ( $p1 =~ /^Using (.*) internal av scanner code for (.+)$/))) {
      #TD Using internal av scanner code for (primary) ClamAV-clamd
      #TD Using primary internal av scanner code for ClamAV-clamd
      next unless ($Opts{'startinfo'});
      $StartInfo{'AVScanner'}{"\u$tier internal"}{$scanner} = '';

   # (Un)Loaded code, protocols, etc.
   } elsif (my ($code, $loaded) = ( $p1 =~ /^(\S+)\s+(?:proto? |base |protocol )?\s*(?:code)?\s+((?:NOT )?loaded)$/)) {
      next unless ($Opts{'startinfo'});
      $StartInfo{'Code'}{"\u\L$loaded"}{$code} = "";

   } elsif (my ($module, $vers) = ( $p1 =~ /^Module (\S+)\s+(.+)$/)) {
      #TD Module Amavis::Conf        2.086
      next unless ($Opts{'startinfo'});
      $StartInfo{'Code'}{'Loaded'}{$module} = $vers;

   } elsif (($module, my $families) = ( $p1 =~ /^socket module (\S+),\s+(.+)$/)) {
      #TD socket module IO::Socket::IP, protocol families available: INET, INET6
      next unless ($Opts{'startinfo'});
      $StartInfo{'Code'}{'Loaded'}{$module} = $families;

   } elsif (($code, $location) = ( $p1 =~ /^Found \$(\S+)\s+at\s+(.+)$/)) {
      #TD Found $file            at /usr/bin/file
      #TD Found $uncompress at /usr/bin/gzip -d
      next unless ($Opts{'startinfo'});
      $StartInfo{'Code'}{'Loaded'}{$code} = $location;

   } elsif (($code, $location) = ( $p1 =~ /^No \$(\S+),\s+not using it/)) {
      #TD No $dspam,             not using it
      next unless ($Opts{'startinfo'});
      $StartInfo{'Code'}{'Not loaded'}{$code} = $location;

   } elsif (($code, $location) = ( $p1 =~ /^No ext program for\s+([^,]+), (tried: .+)/)) {
      #TD No ext program for   .kmz, tried: 7za, 7z
      #TD No ext program for   .F, tried: unfreeze, freeze -d, melt, fcat
      next unless ($Opts{'startinfo'});
      $StartInfo{'Code'}{'Not found'}{$code} = $location;


   } elsif ( $p1 =~ /^starting\.\s+(.+) at \S+ (?:amavisd-new-|Maia Mailguard )([^,]+),/) {
      #TD starting.  /usr/local/sbin/amavisd at mailhost.example.com amavisd-new-2.5.0 (20070423), Unicode aware, LANG="C"
      #TD starting.  /usr/sbin/amavisd-maia at vwsw02.eon.no Maia Mailguard 1.0.2, Unicode aware, LANG=en_US.UTF-8
      next unless ($Opts{'startinfo'});
      %StartInfo = ()  if !exists $StartInfo{'Logging'};
      $StartInfo{'ampath'}    = $1;
      $StartInfo{'amversion'} = $2;

   } elsif ( $p1 =~ /^config files read: (.*)$/) {
      #TD config files read: /etc/amavisd.conf, /etc/amavisd-overrides.conf
      next unless ($Opts{'startinfo'});
      $StartInfo{'Configs'} = "$1";

   } elsif ($p1 =~ /^Creating db in ([^;]+); [^,]+, (.*)$/) {
      #TD Creating db in /var/spool/amavis/db/; BerkeleyDB 0.31, libdb 4.4
      next unless ($Opts{'startinfo'});
      $StartInfo{'db'} = "$1\t($2)";

   } elsif ($p1 =~ /^BerkeleyDB-based Amavis::Cache not available, using memory-based local cache$/) {
      #TD BerkeleyDB-based Amavis::Cache not available, using memory-based local cache
      next unless ($Opts{'startinfo'});
      $StartInfo{'db'} = "BerkeleyDB\t(memory-based cache: Amavis::Cache unavailable)";
 
   } elsif (my ($log) = ($p1 =~ /^logging initialized, log (level \d+, (?:STDERR|syslog: \S+))/)) {
      next unless ($Opts{'startinfo'});
      %StartInfo = ();        # first amavis log entry, clear out previous start info
      $StartInfo{'Logging'} = $log;

   } elsif (( $p1 =~ /^(:?perl=[^,]*, )?user=([^,]*), EUID: (\d+) [(](\d+)[)];\s+group=([^,]*), EGID: ([\d ]+)[(]([\d ]+)[)]/)) {
      # uninteresting...
      #next unless ($Opts{'startinfo'});
      #$StartInfo{'IDs'}{'user'} = $1;
      #$StartInfo{'IDs'}{'euid'} = $2;
      #$StartInfo{'IDs'}{'uid'} = $3;
      #$StartInfo{'IDs'}{'group'} = $4;
      #$StartInfo{'IDs'}{'egid'} = $5;
      #$StartInfo{'IDs'}{'gid'} = $6;
   } elsif ($p1 =~ /^after_chroot_init: EUID: (\d+) [(](\d+)[)]; +EGID: ([\d ]+)[(]([\d ]+)[)]/) {
      #TD after_chroot_init: EUID: 999 (999);  EGID: 54322 54322 54322 (54322 54322 54322)
      # uninteresting...

   } elsif ($p1 =~ /^SpamAssassin debug facilities: (.*)$/) {
      next unless ($Opts{'startinfo'});
      $StartInfo{'sa_debug'} = $1;

   # amavis >= 2.6.3
   } elsif ($p1 =~ /^SpamAssassin loaded plugins: (.*)$/) {
      #TD SpamAssassin loaded plugins: AWL, AutoLearnThreshold, Bayes, BodyEval, Check, DCC, DKIM, DNSEval, HTMLEval, HTTPSMismatch, Hashcash, HeaderEval, ImageInfo, MIMEEval, MIMEHeader, Pyzor, Razor2, RelayEval, ReplaceTags, SPF, SpamCop, URIDNSBL, URIDetail, URIEval, VBounce, WLBLEval, WhiteListSubject
      next unless ($Opts{'startinfo'});
      map { $StartInfo{'SAPlugins'}{'Loaded'}{$_} = '' } split(/, /, $1);

   } elsif (($p2) = ( $p1 =~ /^Net::Server: (.*)$/ )) {
      next unless ($Opts{'startinfo'});
      if ($p2 =~ /^.*starting! pid\((\d+)\)/) {
         #TD Net::Server: 2007/05/02-11:05:24 Amavis (type Net::Server::PreForkSimple) starting! pid(4405)
         $StartInfo{'Server'}{'pid'} = $1;
      } elsif ($p2 =~ /^Binding to UNIX socket file (.*) using/) {
         #TD Net::Server: Binding to UNIX socket file /var/spool/amavis/amavisd.sock using SOCK_STREAM
         $StartInfo{'Server'}{'socket'} = $1;
      } elsif ($p2 =~ /^Binding to TCP port (\d+) on host (.*)$/) {
         #TD Net::Server: Binding to TCP port 10024 on host 127.0.0.1
         $StartInfo{'Server'}{'ip'} = "$2:$1";
      } elsif ($p2 =~ /^Setting ([ug]id) to "([^"]+)"$/) {
         $StartInfo{'Server'}{$1} = $2;
         #TD Net::Server: Setting gid to "91 91"
         #TD Net::Server: Setting uid to "91"
      }
      # skip others
   }

   # higher debug level or rare messages skipped last
   elsif (! check_ignore_list ($p1, @ignore_list_final)) {
      inc_unmatched('final');
   }
}

########################################
# Final tabulations, and report printing


# spamblocked includes spamdiscarded; adjust here
$Totals{'spamblocked'} -= $Totals{'spamdiscarded'};


#Totals: Blocked/Passed totals
$Totals{'totalblocked'} += $Totals{$_} foreach (
   qw(
      malwareblocked
      bannednameblocked
      uncheckedblocked
      spamblocked
      spamdiscarded
      spammyblocked
      badheaderblocked
      oversizedblocked
      mtablocked
      cleanblocked
      tempfailblocked
      otherblocked
   ));

$Totals{'totalpassed'} += $Totals{$_} foreach (
   qw(
      malwarepassed
      bannednamepassed
      uncheckedpassed
      spampassed
      spammypassed
      badheaderpassed
      oversizedpassed
      mtapassed
      cleanpassed
      tempfailpassed
      otherpassed
   ));

# Priority: VIRUS BANNED UNCHECKED SPAM SPAMMY BADH OVERSIZED MTA CLEAN
#Totals: Ham/Spam

$Totals{'totalmalware'} += $Totals{$_} foreach (
   qw(malwarepassed malwareblocked));

$Totals{'totalbanned'} += $Totals{$_} foreach (
   qw(bannednamepassed bannednameblocked));

$Totals{'totalunchecked'} += $Totals{$_} foreach (
   qw(uncheckedpassed uncheckedblocked));

$Totals{'totalspammy'} += $Totals{$_} foreach (
   qw(spammypassed spammyblocked));

$Totals{'totalbadheader'} += $Totals{$_} foreach (
   qw(badheaderpassed badheaderblocked));

$Totals{'totaloversized'} += $Totals{$_} foreach (
   qw(oversizedpassed oversizedblocked));

$Totals{'totalmta'} += $Totals{$_} foreach (
   qw(mtapassed mtablocked));

$Totals{'totalclean'} += $Totals{$_} foreach (
   qw(cleanpassed cleanblocked));

$Totals{'totalother'} += $Totals{$_} foreach (
   qw(tempfailpassed tempfailblocked otherpassed otherblocked));

$Totals{'totalspam'} += $Totals{$_} foreach (
   qw(spampassed spamblocked spamdiscarded totalspammy));

# everything lower priority than SPAMMY is considered HAM
$Totals{'totalham'} += $Totals{$_} foreach (
   qw(totalbadheader totaloversized totalmta totalclean));

$Totals{'totalmsgs'} += $Totals{$_} foreach (
   qw(totalmalware totalbanned totalunchecked totalspam totalham totalother));

# Print the summary report if any key has non-zero data.
# Note: must explicitely check for any non-zero data,
# as Totals always has some keys extant.
#
if ($Opts{'summary'}) {
   for (keys %Totals) {
      if ($Totals{$_}) {
         print_summary_report (@Sections);
         last;
      }
   }
}

# Print the detailed report, if detail is sufficiently high
#
if ($Opts{'detail'} >= 5) {
   print_detail_report (@Sections);
   printAutolearnReport;
   printSpamScorePercentilesReport;
   printSpamScoreFrequencyReport;
   printSARulesReport;
   printTimingsReport("Scan Timing Percentiles", \%Timings, \@TimingsTotals, $Opts{'timings'});
   printTimingsReport("SA Timing Percentiles", \%TimingsSA, \@TimingsSATotals, 0-$Opts{'sa_timings'});
   printStartupInfoReport        if ($Opts{'detail'} >= 10);
}

#{
#use Data::Dumper;
#print Dumper(\%p0ftags);
#print Dumper($Counts{'p0f'});
#}

# Finally, print any unmatched lines
#
print_unmatched_report();

# Evaluates a given line against the list of ignore patterns.
#
sub check_ignore_list($ \@) {
   my ($line, $listref) = @_;

   foreach (@$listref) {
      return 1 if $line =~ /$_/;
   }

   return 0;
}


# Spam score percentiles report
#
=pod
   ==================================================================================
   Spam Score Percentiles        0%       50%       90%       95%       98%      100%
   ----------------------------------------------------------------------------------
   Score Spam (100)           6.650    21.906    34.225    36.664    38.196    42.218
   Score Ham (1276)         -17.979    -2.599     0.428     2.261     3.472     6.298
   ==================================================================================
=cut
sub printSpamScorePercentilesReport {
   return unless ($Opts{'score_percentiles'} and keys %SpamScores);

   #printf "Scores $_ (%d): @{$SpamScores{$_}}\n", scalar @{$SpamScores{$_}} foreach keys %SpamScores;
   my (@p, @sorted);
   my @percents = split /[\s,]+/, $Opts{'score_percentiles'};
   my $myfw2 = $fw2 - 1;

   print  "\n", $sep1 x $fw1, $sep1 x $fw2 x @percents;
   printf "\n%-${fw1}s" . "%${myfw2}s%%" x @percents ,   "Spam Score Percentiles", @percents;
   print  "\n", $sep2 x $fw1, $sep2 x $fw2 x @percents;

   foreach my $ccat (keys %SpamScores) {
      @sorted = sort { $a <=> $b } @{$SpamScores{$ccat}};
      @p = get_percentiles (@sorted, @percents);
      printf "\n%-${fw1}s" . "%${fw2}.3f" x scalar (@p), "Score \u$ccat (" . scalar (@sorted) . ')', @p;
   }

   print  "\n", $sep1 x $fw1, $sep1 x $fw2 x @percents, "\n";
}

# Spam score frequency report
#
=pod
   ======================================================================================================
   Spam Score Frequency      <= -10     <= -5      <= 0      <= 5     <= 10     <= 20     <= 30      > 30
   ------------------------------------------------------------------------------------------------------
   Hits (1376)                   29       168       921       170        29        33         1        25
   Percent of Hits            2.11%    12.21%    66.93%    12.35%     2.11%     2.40%     0.07%     1.82%
   ======================================================================================================
=cut
sub printSpamScoreFrequencyReport {
   return unless ($Opts{'score_frequencies'} and keys %SpamScores);

   my @scores = ();
   push @scores, @{$SpamScores{$_}}  foreach (keys %SpamScores);
   my $nscores = scalar @scores;

   my @sorted  = sort { $a <=> $b } @scores;
   my @buckets = sort { $a <=> $b } split /[\s,]+/, $Opts{'score_frequencies'};
   push @buckets, $buckets[-1] + 1;
   #print "Scores: @sorted\n";

   my @p = get_frequencies (@sorted, @buckets);

   my @ranges = ( 0 ) x @buckets;
   my $last = @buckets - 1;
   $ranges[0]   = sprintf "%${fw2}s", " <= $buckets[0]";
   $ranges[-1]  = sprintf "%${fw2}s", " > $buckets[-2]";
   for my $i (1 .. @buckets - 2) {
      $ranges[$i] = sprintf "%${fw2}s", " <= $buckets[$i]";
   }

   print  "\n", $sep1 x $fw1, $sep1 x $fw2 x @buckets;
   printf "\n%-${fw1}s" . "%-${fw2}s" x @buckets ,     "Spam Score Frequency", @ranges;
   print  "\n", $sep2 x $fw1, $sep2 x $fw2 x @buckets;
   printf "\n%-${fw1}s" . "%${fw2}d" x scalar (@p),    "Hits ($nscores)", @p;
   my $myfw2 = $fw2 - 1;
   printf "\n%-${fw1}s" . "%${myfw2}.2f%%" x scalar (@p),    "Percent of Hits", map {($_ / $nscores) * 100.0; } @p;
   print  "\n", $sep1 x $fw1, $sep1 x $fw2 x @buckets, "\n";
}

# SpamAssassin rules report
#
=pod
   ===========================================================================
   SpamAssassin Rule Hits: Spam
   ---------------------------------------------------------------------------
   Rank     Hits    % Msgs   % Spam    % Ham      Score Rule
   ----     ----    ------   ------    -----      ----- ----
      1       44    81.48%   93.62%    0.00%      1.961 URIBL_BLACK
      2       44    81.48%   93.62%   14.29%      0.001 HTML_MESSAGE
      3       42    77.78%   89.36%    0.00%      2.857 URIBL_JP_SURBL
      4       38    70.37%   80.85%   14.29%      2.896 RCVD_IN_XBL
      5       37    68.52%   78.72%    0.00%      2.188 RCVD_IN_BL_SPAMCOP_NET
   ...
   ===========================================================================

   ===========================================================================
   SpamAssassin Rule Hits: Ham
   ---------------------------------------------------------------------------
   Rank     Hits    % Msgs   % Spam    % Ham      Score Rule
   ----     ----    ------   ------    -----      ----- ----
      1        5     9.26%    2.13%   71.43%      0.001 STOX_REPLY_TYPE
      2        4     7.41%    0.00%   57.14%     -0.001 SPF_PASS
      3        4     7.41%    6.38%   57.14%          - AWL
      4        1     1.85%    0.00%   14.29%      0.303 TVD_RCVD_SINGLE
      5        1     1.85%   25.53%   14.29%        0.1 RDNS_DYNAMIC
   ...
   ===========================================================================
=cut
sub printSARulesReport {
   return unless (keys %{$Counts{'sarules'}});

   our $maxlen = 0;

   sub getSAHitsReport($ $) {
      my ($type, $topn) = @_;
      my $i = 1;
      my @report = ();

      return if ($topn eq '0');     # topn can be numeric, or the string "all"

      for (sort { $Counts{'sarules'}{$type}{$b} <=> $Counts{'sarules'}{$type}{$a} } keys %{$Counts{'sarules'}{$type}}) {

         # only show top n lines; all when topn is "all"
         if ($topn ne 'all' and $i > $topn) {
            push @report, "...\n";
            last;
         }
         my $n     = $Counts{'sarules'}{$type}{$_};
         my $nham  = $Counts{'sarules'}{'Ham'}{$_};
         my $nspam = $Counts{'sarules'}{'Spam'}{$_};
         # rank, count, % msgs, % spam, % ham
         push @report, sprintf "%4d %8d   %6.2f%%  %6.2f%%  %6.2f%%     %s\n",
            $i++,
            $n,
            $Totals{'totalmsgs'} == 0 ? 0 : 100.0 * $n     / $Totals{'totalmsgs'},
            $Totals{'totalspam'} == 0 ? 0 : 100.0 * $nspam / $Totals{'totalspam'},
            $Totals{'totalham'}  == 0 ? 0 : 100.0 * $nham  / $Totals{'totalham'},
            $_;
         my $len = length($report[-1]) - 1;
         $maxlen = $len  if ($len > $maxlen);
      }

      if (scalar @report) {
         print "\n", $sep1 x $maxlen, "\n";
         print "SpamAssassin Rule Hits: $type\n";
         print $sep2 x $maxlen, "\n";
         print "Rank     Hits    % Msgs   % Spam    % Ham      Score Rule\n";
         print "----     ----    ------   ------    -----      ----- ----\n";
         print @report;
         print $sep1 x $maxlen, "\n";
      }
   }
   
   my ($def_limit_spam, $def_limit_ham) = split /[\s,]+/, $Defaults{'sarules'};
   my ($limit_spam, $limit_ham)         = split /[\s,]+/, $Opts{'sarules'};
   $limit_spam = $def_limit_spam    if $limit_spam eq '';
   $limit_ham  = $def_limit_ham     if $limit_ham  eq '';

   getSAHitsReport('Spam', $limit_spam);
   getSAHitsReport('Ham',  $limit_ham);
}

# Autolearn report, only available if enabled in amavis $log_templ template
#
=pod
   ======================================================================
   Autolearn          Msgs      Spam       Ham   % Msgs   % Spam    % Ham
   ----------------------------------------------------------------------
   Spam                 36        36         0   66.67%   76.60%    0.00%
   Ham                   2         0         2    3.70%    0.00%   28.57%
   No                    7         4         3   12.96%    8.51%   42.86%
   Disabled              6         6         0   11.11%   12.77%    0.00%
   Failed                2         1         1    3.70%    2.13%   14.29%
   ----------------------------------------------------------------------
   Totals               53        47         6   98.15%  100.00%   85.71%
   ======================================================================
=cut
sub printAutolearnReport {
   #print "printAutolearnReport:\n"    if ($Opts{'debug'});
   return unless (keys %{$Counts{'autolearn'}});

   our $maxlen = 0;
   our ($nhamtotal, $nspamtotal);

   sub getAutolearnReport($) {
      my ($type) = @_;
      my @report = ();

      #  SA 2.5/2.6 : ham/spam/no
      #  SA 3.0+    : ham/spam/no/disabled/failed/unavailable
      for (qw(spam ham no disabled failed unavailable)) {

         next unless (exists $Counts{'autolearn'}{'Spam'}{$_} or exists $Counts{'autolearn'}{'Ham'}{$_});
         #print "printAutolearnReport: type: $_\n"    if ($Opts{'debug'});

         my $nham  = exists $Counts{'autolearn'}{'Ham'}{$_}  ? $Counts{'autolearn'}{'Ham'}{$_}  : 0;
         my $nspam = exists $Counts{'autolearn'}{'Spam'}{$_} ? $Counts{'autolearn'}{'Spam'}{$_} : 0;
         my $nboth = $nham + $nspam;
         $nhamtotal += $nham; $nspamtotal += $nspam;
         # type, nspam, nham, % msgs, % spam, % ham
         push @report, sprintf "%-13s %9d %9d %9d  %6.2f%%  %6.2f%%  %6.2f%%\n",
            ucfirst $_,
            $nspam + $nham,
            $nspam,
            $nham,
            $Totals{'totalmsgs'} == 0 ? 0 : 100.0 * $nboth / $Totals{'totalmsgs'},
            $Totals{'totalspam'} == 0 ? 0 : 100.0 * $nspam / $Totals{'totalspam'},
            $Totals{'totalham'}  == 0 ? 0 : 100.0 * $nham  / $Totals{'totalham'};

         my $len = length($report[-1]) - 1;
         $maxlen = $len  if ($len > $maxlen);
      }
      return @report;
   }
   
   my @report_spam = getAutolearnReport('Spam');

   if (scalar @report_spam) {
      print "\n", $sep1 x $maxlen, "\n";
      print "Autolearn          Msgs      Spam       Ham   % Msgs   % Spam    % Ham\n";
      print $sep2 x $maxlen, "\n";
      print @report_spam;
      print $sep2 x $maxlen, "\n";

      printf "%-13s %9d %9d %9d  %6.2f%%  %6.2f%%  %6.2f%%\n",
            'Totals',
            $nspamtotal + $nhamtotal,
            $nspamtotal,
            $nhamtotal,
            $Totals{'totalmsgs'} == 0 ? 0 : 100.0 * ($nspamtotal + $nhamtotal) / $Totals{'totalmsgs'},
            $Totals{'totalspam'} == 0 ? 0 : 100.0 * $nspamtotal / $Totals{'totalspam'},
            $Totals{'totalham'}  == 0 ? 0 : 100.0 * $nhamtotal  / $Totals{'totalham'};
      print $sep1 x $maxlen, "\n";
   }
}


# Timings percentiles report, used for amavis message scanning and spamassassin timings 
=pod
   ========================================================================================================================
   Scan Timing Percentiles       % Time    Total (ms)        0%        5%       25%       50%       75%       95%      100%
   ------------------------------------------------------------------------------------------------------------------------
   AV-scan-2 (3)                 69.23%       7209.00   2392.00   2393.50   2399.50   2407.00   2408.50   2409.70   2410.00
   SA check (2)                  19.74%       2056.00    942.00    950.60    985.00   1028.00   1071.00   1105.40   1114.00
   SMTP DATA (3)                  5.49%        572.00    189.00    189.20    190.00    191.00    191.50    191.90    192.00
   AV-scan-1 (3)                  0.82%         85.00     11.00     12.60     19.00     27.00     37.00     45.00     47.00
   ...
   ------------------------------------------------------------------------------------------------------------------------
   Total                                     10413.00   2771.00   2867.10   3251.50   3732.00   3821.00   3892.20   3910.00
   ========================================================================================================================

   ========================================================================================================================
   SA Timing Percentiles         % Time    Total (ms)        0%        5%       25%       50%       75%       95%      100%
   ------------------------------------------------------------------------------------------------------------------------
   tests_pri_0 (1)               97.17%       5323.00   5323.00   5323.00   5323.00   5323.00   5323.00   5323.00   5323.00
   check_razor2 (1)              91.68%       5022.00   5022.00   5022.00   5022.00   5022.00   5022.00   5022.00   5022.00
   check_dcc (1)                  3.50%        192.00    192.00    192.00    192.00    192.00    192.00    192.00    192.00
   learn (1)                      0.66%         36.00     36.00     36.00     36.00     36.00     36.00     36.00     36.00
   tests_pri_-1000 (1)            0.46%         25.00     25.00     25.00     25.00     25.00     25.00     25.00     25.00
   ...
   ------------------------------------------------------------------------------------------------------------------------
   Total                                      5478.00   5478.00   5478.00   5478.00   5478.00   5478.00   5478.00   5478.00
   ========================================================================================================================
=cut
sub printTimingsReport($$$$) {
   my ($title, $timingsref, $totalsref, $cutoff) = @_;
   my @tkeys = keys %$timingsref;
   return unless scalar @tkeys;

   my (@p, @sorted, %perkey_totals, @col_subtotals);
   my ($pcnt,$max_pcnt,$max_rows,$time_total_actual,$time_total_hypo,$subtotal_pcnt);
   my @percents = split /[\s,]+/, $Opts{'timings_percentiles'};
   my $header_footer = $sep1 x 50 . ($sep1 x 10) x @percents;
   my $header_end    = $sep2 x 50 . ($sep2 x 10) x @percents;
   my $title_width = '-28';

   print "\n$header_footer\n";
   printf "%${title_width}s  %6s %13s" ." %8s%%" x @percents , $title, "% Time", "Total (ms)", @percents;
   print "\n$header_end\n";

   # Sum the total time for each timing key
   foreach my $key (@tkeys) {
      foreach my $timeval (@{$$timingsref{$key}}) {
         $perkey_totals{$key} += $timeval;
      }
   }

   # Sum total time spent scanning
   map {$time_total_actual += $_} @$totalsref;

   # cutoff value used to limit the number of rows of output
   #   positive cutoff is a percentage of cummulative time
   #   negative cutoff limits number of rows
   if ($cutoff >= 0) {
      $max_pcnt = $cutoff != 100 ? $cutoff : 150;  # 150% avoids roundoff errors
   }
   else {
      $max_rows = -$cutoff;
   }
   my $rows = 0;
   # sort each timing key's values, required to compute the list of percentiles 
   for (sort { $perkey_totals{$b} <=> $perkey_totals{$a} } @tkeys) {
      last if (($max_rows and $rows >= $max_rows) or ($max_pcnt and $subtotal_pcnt >= $max_pcnt));

      $pcnt = ($perkey_totals{$_} / $time_total_actual) * 100,
      @sorted = sort { $a <=> $b } @{$$timingsref{$_}};
      @p = get_percentiles (@sorted, @percents);

      $subtotal_pcnt += $pcnt;
      printf "%${title_width}s %6.2f%% %13.2f" . " %9.2f" x scalar (@p) . "\n",
               $_ .  ' (' . scalar(@{$$timingsref{$_}}) . ')', # key ( number of elements )
               $pcnt,                                          # percent of total time
               #$perkey_totals{$_} / 1000,                     # total time for this test
               $perkey_totals{$_},                             # total time for this test
               #map {$_ / 1000} @p;                            # list of percentiles
               @p;                                             # list of percentiles
      $rows++;
   }
   print "...\n"  if ($rows != scalar @tkeys);

   print "$header_end\n";
   # actual total time as reported by amavis
   @sorted = sort { $a <=> $b } @$totalsref;
   @p = get_percentiles (@sorted, @percents);
   printf "%${title_width}s         %13.2f" . " %9.2f" x scalar (@p) . "\n",
            'Total',
            #$time_total_actual / 1000,
            $time_total_actual,
            #map {$_ / 1000} @p;
             @p;

   print "$header_footer\n";
}

# Most recent startup info report
#
sub printStartupInfoReport {

   return unless (keys %StartInfo);

   sub print2col($ $) {
      my ($label,$val) = @_;
      printf "%-50s %s\n", $label, $val;
   }

   print "\nAmavis Startup\n";

   print2col ("    Amavis",       $StartInfo{'ampath'})             if (exists $StartInfo{'ampath'});
   print2col ("        Version",  $StartInfo{'amversion'})          if (exists $StartInfo{'amversion'});
   print2col ("        PID",      $StartInfo{'Server'}{'pid'})      if (exists $StartInfo{'Server'}{'pid'});
   print2col ("        Socket",   $StartInfo{'Server'}{'socket'})   if (exists $StartInfo{'Server'}{'socket'});
   print2col ("        TCP port", $StartInfo{'Server'}{'ip'})       if (exists $StartInfo{'Server'}{'ip'});
   print2col ("        UID",      $StartInfo{'Server'}{'uid'})      if (exists $StartInfo{'Server'}{'uid'});
   print2col ("        GID",      $StartInfo{'Server'}{'gid'})      if (exists $StartInfo{'Server'}{'gid'});
   print2col ("        Logging",  $StartInfo{'Logging'})            if (exists $StartInfo{'Logging'});
   print2col ("        Configuration Files",  $StartInfo{'Configs'})            if (exists $StartInfo{'Configs'});
   print2col ("    SpamAssassin", $StartInfo{'sa_version'})         if (exists $StartInfo{'sa_version'});
   print2col ("    SpamAssassin Debug Facilities", $StartInfo{'sa_debug'})     if (exists $StartInfo{'sa_debug'});
   print2col ("    Database",     $StartInfo{'db'})                 if (exists $StartInfo{'db'});
   #if (keys %{$StartInfo{'IDs'}}) {
   #   print "    Process startup user/group:\n";
   #   print "        User:  $StartInfo{'IDs'}{'user'}, EUID: $StartInfo{'IDs'}{'euid'}, UID: $StartInfo{'IDs'}{'uid'}\n";
   #   print "        Group: $StartInfo{'IDs'}{'group'}, EGID: $StartInfo{'IDs'}{'egid'}, GID: $StartInfo{'IDs'}{'gid'}\n";
   #}

   sub print_modules ($ $) {
      my ($key, $label) = @_;
      print "    $label\n";
      foreach (sort keys %{$StartInfo{$key}}) {
         print "        $_\n";
         foreach my $module (sort keys %{$StartInfo{$key}{$_}}) {
            if ($StartInfo{$key}{$_}{$module}) {
               print2col ("            " . $module, $StartInfo{$key}{$_}{$module});
            }
            else {
               print2col ("            " . $module, "");
            }
         }
      }
   };
   print_modules('AVScanner', 'Antivirus scanners');
   print_modules('Code',      'Code, modules and external programs');
   print_modules('Decoders',  'Decoders');
   print_modules('SAPlugins', 'SpamAssassin plugins');
}

# Initialize the Getopts option list.  Requires the Section table to
# be built already.
#
sub init_getopts_table() {
   print "init_getopts_table: enter\n"  if $Opts{'debug'} & D_ARGS;

   init_getopts_table_common(@supplemental_reports);

   add_option ('first_recip_only!');
   add_option ('show_first_recip_only=i',   sub { $Opts{'first_recip_only'} = $_[1]; 1;});
   add_option ('startinfo!');
   add_option ('show_startinfo=i',          sub { $Opts{'startinfo'} = $_[1]; 1; });
   add_option ('by_ccat_summary!');
   add_option ('show_by_ccat_summary=i',    sub { $Opts{'by_ccat_summary'} = $_[1]; 1; });
   add_option ('noscore_percentiles',       \&triway_opts);
   add_option ('score_percentiles=s',       \&triway_opts);
   add_option ('noscore_frequencies',       \&triway_opts);
   add_option ('score_frequencies=s',       \&triway_opts);
   add_option ('nosa_timings',              sub { $Opts{'sa_timings'} = 0; 1; });
   add_option ('sa_timings=i');
   add_option ('sa_timings_percentiles=s');
   add_option ('notimings',                 sub { $Opts{'timings'} = 0; 1; });
   add_option ('timings=i');
   add_option ('timings_percentiles=s');
   add_option ('nosarules',                 \&triway_opts);
   add_option ('sarules=s',                 \&triway_opts);
   #add_option ('nop0f',                     \&triway_opts);
   #add_option ('p0f=s',                     \&triway_opts);
   add_option ('autolearn!');
   add_option ('show_autolearn=i',          sub { $Opts{'autolearn'} = $_[1]; 1; });
}

# Builds the entire @Section table used for data collection
#
# Each Section entry has as many as six fields:
#
#   1. Section array reference
#   2. Key to %Counts, %Totals accumulator hashes, and %Collecting hash
#   3. Output in Detail report? (must also a %Counts accumulator)
#   4. Numeric output format specifier for Summary report
#   5. Section title for Summary and Detail reports
#   6. A hash to a divisor used to calculate the percentage of a total for that key
#
# Use begin_section_group/end_section_group to create groupings around sections.
# 
# Sections can be freely reordered if desired, but maintain proper group nesting.
#
sub build_sect_table() {
   print "build_sect_table: enter\n"  if $Opts{'debug'} & D_SECT;
   my $S = \@Sections;

   # References to these are used in the Sections table below; we'll predeclare them.
   $Totals{'totalmsgs'} = 0;

   # Place configuration and critical errors first

   #    SECTIONREF, NAME,                 DETAIL, FMT, TITLE,                             DIVISOR
   begin_section_group ($S, 'warnings');
   add_section ($S, 'fatal',                   1, 'd', '*Fatal');
   add_section ($S, 'panic',                   1, 'd', '*Panic');
   add_section ($S, 'warningsecurity',         1, 'd', '*Warning: Security risk');
   add_section ($S, 'avtimeout',               1, 'd', '*Warning: Virus scanner timeout');
   add_section ($S, 'avconnectfailure',        1, 'd', '*Warning: Virus scanner connection failure');
   add_section ($S, 'warningsmtpshutdown',     1, 'd', '*Warning: SMTP shutdown');
   add_section ($S, 'warningsql',              1, 'd', '*Warning: SQL problem');
   add_section ($S, 'warningaddressmodified',  1, 'd', '*Warning: Email address modified');
   add_section ($S, 'warningnoquarantineid',   1, 'd', '*Warning: Message missing X-Quarantine-ID header');
   add_section ($S, 'warning',                 1, 'd', 'Miscellaneous warnings');
   end_section_group ($S, 'warnings');

   begin_section_group ($S, 'scanned', "\n");
   add_section ($S, 'totalmsgs',               0, 'd', [ 'Total messages scanned', '-' ],  \$Totals{'totalmsgs'});
   add_section ($S, 'bytesscanned',            0, 'Z', 'Total bytes scanned');     # Z means print scaled as in 1k, 1m, etc.
   end_section_group ($S, 'scanned', $sep1);

   # Blocked / Passed
   # Priority: VIRUS BANNED UNCHECKED SPAM SPAMMY BADH OVERSIZED MTA CLEAN
   begin_section_group ($S, 'passblock', "\n");
   begin_section_group ($S, 'blocked', "\n");
   add_section ($S, 'totalblocked',            0, 'd', [ 'Blocked', '-' ],                 \$Totals{'totalmsgs'});
   add_section ($S, 'malwareblocked',          1, 'd', '  Malware blocked',                \$Totals{'totalmsgs'});
   add_section ($S, 'bannednameblocked',       1, 'd', '  Banned name blocked',            \$Totals{'totalmsgs'});
   add_section ($S, 'uncheckedblocked',        1, 'd', '  Unchecked blocked',              \$Totals{'totalmsgs'});
   add_section ($S, 'spamblocked',             1, 'd', '  Spam blocked',                   \$Totals{'totalmsgs'});
   add_section ($S, 'spamdiscarded',           0, 'd', '  Spam discarded (no quarantine)', \$Totals{'totalmsgs'});
   add_section ($S, 'spammyblocked',           1, 'd', '  Spammy blocked',                 \$Totals{'totalmsgs'});
   add_section ($S, 'badheaderblocked',        1, 'd', '  Bad header blocked',             \$Totals{'totalmsgs'});
   add_section ($S, 'oversizedblocked',        1, 'd', '  Oversized blocked',              \$Totals{'totalmsgs'});
   add_section ($S, 'mtablocked',              1, 'd', '  MTA blocked',                    \$Totals{'totalmsgs'});
   add_section ($S, 'cleanblocked',            1, 'd', '  Clean blocked',                  \$Totals{'totalmsgs'});
   add_section ($S, 'tempfailblocked',         1, 'd', '  Tempfail blocked',               \$Totals{'totalmsgs'});
   add_section ($S, 'otherblocked',            1, 'd', '  Other blocked',                  \$Totals{'totalmsgs'});
   end_section_group ($S, 'blocked');

   begin_section_group ($S, 'passed', "\n");
   add_section ($S, 'totalpassed',             0, 'd', [ 'Passed', '-' ],                  \$Totals{'totalmsgs'});
   add_section ($S, 'malwarepassed',           1, 'd', '  Malware passed',                 \$Totals{'totalmsgs'});
   add_section ($S, 'bannednamepassed',        1, 'd', '  Banned name passed',             \$Totals{'totalmsgs'});
   add_section ($S, 'uncheckedpassed',         1, 'd', '  Unchecked passed',               \$Totals{'totalmsgs'});
   add_section ($S, 'spampassed',              1, 'd', '  Spam passed',                    \$Totals{'totalmsgs'});
   add_section ($S, 'spammypassed',            1, 'd', '  Spammy passed',                  \$Totals{'totalmsgs'});
   add_section ($S, 'badheaderpassed',         1, 'd', '  Bad header passed',              \$Totals{'totalmsgs'});
   add_section ($S, 'oversizedpassed',         1, 'd', '  Oversized passed',               \$Totals{'totalmsgs'});
   add_section ($S, 'mtapassed',               1, 'd', '  MTA passed',                     \$Totals{'totalmsgs'});
   add_section ($S, 'cleanpassed',             1, 'd', '  Clean passed',                   \$Totals{'totalmsgs'});
   add_section ($S, 'tempfailpassed',          1, 'd', '  Tempfail passed',                \$Totals{'totalmsgs'});
   add_section ($S, 'otherpassed',             1, 'd', '  Other passed',                   \$Totals{'totalmsgs'});
   end_section_group ($S, 'passed');
   end_section_group ($S, 'passblock', $sep1);

   if ($Opts{'by_ccat_summary'}) {
      # begin level 1 group
      begin_section_group ($S, 'by_ccat', "\n");

      # begin level 2 groupings
      begin_section_group ($S, 'malware', "\n");     # level 2
      add_section ($S, 'totalmalware',            0, 'd', [ 'Malware', '-' ],                 \$Totals{'totalmsgs'});
      add_section ($S, 'malwarepassed',           0, 'd', '  Malware passed',                 \$Totals{'totalmsgs'});
      add_section ($S, 'malwareblocked',          0, 'd', '  Malware blocked',                \$Totals{'totalmsgs'});
      end_section_group ($S, 'malware');

      begin_section_group ($S, 'banned', "\n");
      add_section ($S, 'totalbanned',             0, 'd', [ 'Banned', '-' ],                  \$Totals{'totalmsgs'});
      add_section ($S, 'bannednamepassed',        0, 'd', '  Banned file passed',             \$Totals{'totalmsgs'});
      add_section ($S, 'bannednameblocked',       0, 'd', '  Banned file blocked',            \$Totals{'totalmsgs'});
      end_section_group ($S, 'banned');

      begin_section_group ($S, 'unchecked', "\n");
      add_section ($S, 'totalunchecked',          0, 'd', [ 'Unchecked', '-' ],               \$Totals{'totalmsgs'});
      add_section ($S, 'uncheckedpassed',         0, 'd', '  Unchecked passed',               \$Totals{'totalmsgs'});
      add_section ($S, 'uncheckedblocked',        0, 'd', '  Unchecked blocked',              \$Totals{'totalmsgs'});
      end_section_group ($S, 'unchecked');

      begin_section_group ($S, 'spam', "\n");
      add_section ($S, 'totalspam',               0, 'd', [ 'Spam', '-' ],                    \$Totals{'totalmsgs'});
      add_section ($S, 'spammypassed',            0, 'd', '  Spammy passed',                  \$Totals{'totalmsgs'});
      add_section ($S, 'spammyblocked',           0, 'd', '  Spammy blocked',                 \$Totals{'totalmsgs'});
      add_section ($S, 'spampassed',              0, 'd', '  Spam passed',                    \$Totals{'totalmsgs'});
      add_section ($S, 'spamblocked',             0, 'd', '  Spam blocked',                   \$Totals{'totalmsgs'});
      add_section ($S, 'spamdiscarded',           0, 'd', '  Spam discarded (no quarantine)', \$Totals{'totalmsgs'});
      end_section_group ($S, 'spam');

      begin_section_group ($S, 'ham', "\n");
      add_section ($S, 'totalham',                0, 'd', [ 'Ham', '-' ],                     \$Totals{'totalmsgs'});
      add_section ($S, 'badheaderpassed',         0, 'd', '  Bad header passed',              \$Totals{'totalmsgs'});
      add_section ($S, 'badheaderblocked',        0, 'd', '  Bad header blocked',             \$Totals{'totalmsgs'});
      add_section ($S, 'oversizedpassed',         0, 'd', '  Oversized passed',               \$Totals{'totalmsgs'});
      add_section ($S, 'oversizedblocked',        0, 'd', '  Oversized blocked',              \$Totals{'totalmsgs'});
      add_section ($S, 'mtapassed',               0, 'd', '  MTA passed',                     \$Totals{'totalmsgs'});
      add_section ($S, 'mtablocked',              0, 'd', '  MTA blocked',                    \$Totals{'totalmsgs'});
      add_section ($S, 'cleanpassed',             0, 'd', '  Clean passed',                   \$Totals{'totalmsgs'});
      add_section ($S, 'cleanblocked',            0, 'd', '  Clean blocked',                  \$Totals{'totalmsgs'});
      end_section_group ($S, 'ham');

      begin_section_group ($S, 'other', "\n");
      add_section ($S, 'totalother',              0, 'd', [ 'Other', '-' ],                   \$Totals{'totalmsgs'});
      add_section ($S, 'tempfailpassed',          0, 'd', '  Tempfail passed',                \$Totals{'totalmsgs'});
      add_section ($S, 'tempfailblocked',         0, 'd', '  Tempfail blocked',               \$Totals{'totalmsgs'});
      add_section ($S, 'otherpassed',             0, 'd', '  Other passed',                   \$Totals{'totalmsgs'});
      add_section ($S, 'otherblocked',            0, 'd', '  Other blocked',                  \$Totals{'totalmsgs'});
      end_section_group ($S, 'other');
      # end level 2 groupings

      # end level 1 group
      end_section_group ($S, 'by_ccat', $sep1);
   }

   begin_section_group ($S, 'misc', "\n");
   add_section ($S, 'virusscanskipped',        1, 'd', 'Virus scan skipped');
   add_section ($S, 'sabypassed',              0, 'd', 'SpamAssassin bypassed');
   add_section ($S, 'satimeout',               0, 'd', 'SpamAssassin timeout');
   add_section ($S, 'released',                1, 'd', 'Released from quarantine');
   add_section ($S, 'defanged',                1, 'd', 'Defanged');
   add_section ($S, 'truncatedheader',         0, 'd', 'Truncated headers > 998 characters');
   add_section ($S, 'truncatedmsg',            0, 'd', 'Truncated message passed to SpamAssassin');
   add_section ($S, 'tagged',                  0, 'd', 'Spam tagged');
   add_section ($S, 'smtpresponse',            1, 'd', 'SMTP response');
   add_section ($S, 'badaddress',              1, 'd', 'Bad address syntax');
   add_section ($S, 'fakesender',              1, 'd', 'Fake sender');
   add_section ($S, 'archiveextract',          1, 'd', 'Archive extraction problem');
   add_section ($S, 'dsnsuppressed',           1, 'd', 'DSN suppressed');
   add_section ($S, 'dsnnotification',         1, 'd', 'DSN notification (debug supplemental)');
   add_section ($S, 'bouncekilled',            1, 'd', 'Bounce killed');
   add_section ($S, 'bouncerescued',           1, 'd', 'Bounce rescued');
   add_section ($S, 'bounceunverifiable',      1, 'd', 'Bounce unverifiable');
   add_section ($S, 'nosubject',               0, 'd', 'Subject header inserted');
   add_section ($S, 'whitelisted',             1, 'd', 'Whitelisted');
   add_section ($S, 'blacklisted',             1, 'd', 'Blacklisted');
   add_section ($S, 'penpalsaved',             1, 'd', 'Penpals saved from kill');
   add_section ($S, 'tmppreserved',            1, 'd', 'Preserved temporary directory');
   add_section ($S, 'dccerror',                1, 'd', 'DCC error');
   add_section ($S, 'mimeerror',               1, 'd', 'MIME error');
   add_section ($S, 'defangerror',             1, 'd', 'Defang error');
   add_section ($S, 'badheadersupp',           1, 'd', 'Bad header (debug supplemental)');
   add_section ($S, 'fileoutputskipped',       0, 'd', 'File(1) output skipped');
   add_section ($S, 'localdeliveryskipped',    1, 'd', 'Local delivery skipped');
   add_section ($S, 'extramodules',            1, 'd', 'Extra code modules loaded at runtime');
   add_section ($S, 'malwarebyscanner',        1, 'd', 'Malware by scanner');
   add_section ($S, 'malwaretospam',           1, 'd', 'Malware to spam conversion');
   add_section ($S, 'contenttype',             1, 'd', 'Content types');
   add_section ($S, 'bayes',                   1, 'd', 'Bayes probability');
   add_section ($S, 'p0f',                     1, 'd', 'p0f fingerprint');
   add_section ($S, 'sadiags',                 1, 'd', 'SpamAssassin diagnostics');
   end_section_group ($S, 'misc');

   print "build_sect_table: exit\n"  if $Opts{'debug'} & D_SECT;
}

# XXX create array of defaults for detail <5, 5-9, >10
sub init_defaults() {
   map { $Opts{$_} = $Defaults{$_} unless exists $Opts{$_} } keys %Defaults;
   if (! $Opts{'standalone'}) {
      # LOGWATCH these take affect if no env present (eg. nothing in conf file)
      #  0 to 4 nostartinfo, notimings,   nosarules,        score_frequencies=0,        score_percentiles=0,      noautolearn
      #  5 to 9 nostartinfo, timings=95,  sarules = 20 20,  score_frequencies=defaults, score_percentiles=defaults, autolearn
      # 10 +    startinfo,   timings=100, sarules = all all score_frequencies=defaults, score_percentiles=defaults, autolearn

      if ($Opts{'detail'} < 5) {          # detail 0 to 4, disable all supplimental reports
         $Opts{'autolearn'}         = 0;
         #$Opts{'p0f'}               = 0;
         $Opts{'timings'}           = 0;
         $Opts{'sa_timings'}        = 0;
         $Opts{'sarules'}           = 0;
         $Opts{'startinfo'}         = 0;
         $Opts{'score_frequencies'} = '';
         $Opts{'score_percentiles'} = '';
      }
      elsif ($Opts{'detail'} < 10) {      # detail 5 to 9, disable startinfo report
         $Opts{'startinfo'}         = 0;
      }
      else {                              # detail 10 and up, full reports
         #$Opts{'p0f'}              = 'all all';
         $Opts{'timings'}          = 100;    
         $Opts{'sa_timings'}       = 100;    
         $Opts{'sarules'}          = 'all all';
      }
   }
}

# Return a usage string,  built from:
#  arg1 +
#  $usage_str +
#  a string built from each usable entry in the @Sections table.
#
sub usage($) {
   my $ret = "";
   $ret = "@_\n"  if ($_[0]);
   $ret .= $usage_str;
   my ($name, $desc);
   foreach my $sect (get_usable_sectvars(@Sections, 0)) {
      $name = lc $sect->{NAME};
      $desc = $sect->{TITLE};
      $ret .= sprintf "   --%-38s%s\n", "$name" . ' LEVEL', "$desc";
   }
   $ret .= "\n";
   return $ret;
}

sub strip_trace($) {
   # at (eval 37) line 306, <GEN6> line 4.
   # at /usr/sbin/amavisd-maia line 2895, <GEN4> line 22.
   #$_[0] =~ s/ at \(.+\) line \d+(?:, \<GEN\d+\> line \d+)?\.$//;
   #$_[0] =~ s/ at (\S+) line \d+(?:, \<GEN\d+\> line \d+)?\.$/: $1/;
   while ($_[0] =~ s/ at (?:\(eval \d+\)|\S+) line \d+(?:, \<GEN\d+\> line \d+)?\.//) {
      ;
   }
   #print "strip_trace: \"$_[0]\"\n";
   return $_[0];
}

# Getopt helper, sets an option in Opts hash to one of three
# values: its default, the specified value, or 0 if the option
# was the "no" prefixed variant.
#
sub triway_opts ($ $) {
   my ($opt,$val) = @_;

   print "triway_opts: OPT: $opt, VAL: $val\n"    if $Opts{'debug'} & D_ARGS;
   die "Option \"--${opt}\" requires an argument" if ($val =~ /^--/);

   if ($opt =~ s/^no//i) {
      $Opts{$opt} = 0;
   } elsif ('default' =~ /^${val}$/i) {
      $Opts{$opt} = $Defaults{$opt};
   }
   else {
      $Opts{$opt} = $val;
   }
}

exit(0);

# vi: shiftwidth=3 tabstop=3 syntax=perl et
