#!/usr/bin/perl -w

# mailgraph -- an rrdtool frontend for mail statistics
# copyright (c) 2000-2007 ETH Zurich
# copyright (c) 2000-2007 David Schweikert <david@schweikert.ch>
# released under the GNU General Public License

# modifications to handle rsyslog high precision format
# copyright (c) 2018 by M.D. Klapwijk
# released under the GNU General Public License

######## Parse::Syslog 1.09 (automatically embedded) ########
package Parse::Syslog;
use Carp;
use Symbol;
use Time::Local;
use IO::File;
use strict;
use vars qw($VERSION);
my %months_map = (
    'Jan' => 0, 'Feb' => 1, 'Mar' => 2,
    'Apr' => 3, 'May' => 4, 'Jun' => 5,
    'Jul' => 6, 'Aug' => 7, 'Sep' => 8,
    'Oct' => 9, 'Nov' =>10, 'Dec' =>11,
    'jan' => 0, 'feb' => 1, 'mar' => 2,
    'apr' => 3, 'may' => 4, 'jun' => 5,
    'jul' => 6, 'aug' => 7, 'sep' => 8,
    'oct' => 9, 'nov' =>10, 'dec' =>11,
);
sub is_dst_switch($$$)
{
    my ($self, $t, $time) = @_;
    # calculate the time in one hour and see if the difference is 3600 seconds.
    # if not, we are in a dst-switch hour
    # note that right now we only support 1-hour dst offsets
    # cache the result
    if(defined $self->{is_dst_switch_last_hour} and
        $self->{is_dst_switch_last_hour} == $t->[3]<<5+$t->[2]) {
        return @{$self->{is_dst_switch_result}};
    }
    # calculate a number out of the day and hour to identify the hour
    $self->{is_dst_switch_last_hour} = $t->[3]<<5+$t->[2];
    # calculating hour+1 (below) is a problem if the hour is 23. as far as I
    # know, nobody does the DST switch at this time, so just assume it isn't
    # DST switch if the hour is 23.
    if($t->[2]==23) {
        @{$self->{is_dst_switch_result}} = (0, undef);
        return @{$self->{is_dst_switch_result}};
    }
    # let's see the timestamp in one hour
    # 0: sec, 1: min, 2: h, 3: day, 4: month, 5: year
    my $time_plus_1h = timelocal($t->[0], $t->[1], $t->[2]+1, $t->[3], $t->[4], $t->[5]);
    if($time_plus_1h - $time > 4000) {
        @{$self->{is_dst_switch_result}} = (3600, $time-$time%3600+3600);
    }
    else {
        @{$self->{is_dst_switch_result}} = (0, undef);
    }
    return @{$self->{is_dst_switch_result}};
}
# fast timelocal, cache minute's timestamp
# don't cache more than minute because of daylight saving time switch
# 0: sec, 1: min, 2: h, 3: day, 4: month, 5: year
sub str2time($$$$$$$$)
{
    my $self = shift @_;
    my $GMT = pop @_;
    my $lastmin = $self->{str2time_lastmin};
    if(defined $lastmin and
        $lastmin->[0] == $_[1] and
        $lastmin->[1] == $_[2] and
        $lastmin->[2] == $_[3] and
        $lastmin->[3] == $_[4] and
        $lastmin->[4] == $_[5])
    {
        $self->{last_time} = $self->{str2time_lastmin_time} + $_[0];
        return $self->{last_time} + ($self->{dst_comp}||0);
    }
    my $time;
    if($GMT) {
        $time = timegm(@_);
    }
    else {
        $time = timelocal(@_);
    }
    # compensate for DST-switch
    # - if a timewarp is detected (1:00 -> 1:30 -> 1:00):
    # - test if we are in a DST-switch-hour
    # - compensate if yes
    # note that we assume that the DST-switch goes like this:
    # time   1:00  1:30  2:00  2:30  2:00  2:30  3:00  3:30
    # stamp   1     2     3     4     3     3     7     8
    # comp.   0     0     0     0     2     2     0     0
    # result  1     2     3     4     5     6     7     8
    # old Time::Local versions behave differently (1 2  5 6 5 6 7 8)
    if(!$GMT and !defined $self->{dst_comp} and
        defined $self->{last_time} and
        $self->{last_time}-$time > 1200 and
        $self->{last_time}-$time < 3600)
    {
        my ($off, $until) = $self->is_dst_switch(\@_, $time);
        if($off) {
            $self->{dst_comp} = $off;
            $self->{dst_comp_until} = $until;
        }
    }
    if(defined $self->{dst_comp_until} and $time > $self->{dst_comp_until}) {
        delete $self->{dst_comp};
        delete $self->{dst_comp_until};
    }
    $self->{str2time_lastmin} = [ @_[1..5] ];
    $self->{str2time_lastmin_time} = $time-$_[0];
    $self->{last_time} = $time;
    return $time+($self->{dst_comp}||0);
}
sub _use_locale($)
{
    use POSIX qw(locale_h strftime);
    my $old_locale = setlocale(LC_TIME);
    for my $locale (@_) {
        croak "new(): wrong 'locale' value: '$locale'" unless setlocale(LC_TIME, $locale);
        for my $month (0..11) {
            $months_map{strftime("%b", 0, 0, 0, 1, $month, 96)} = $month;
        }
    }
    setlocale(LC_TIME, $old_locale);
}
sub new($$;%)
{
    my ($class, $file, %data) = @_;
    croak "new() requires one argument: file" unless defined $file;
    %data = () unless %data;
    if(not defined $data{year}) {
        $data{year} = (localtime(time))[5]+1900;
    }
    $data{type} = 'syslog' unless defined $data{type};
    $data{_repeat}=0;
    if(UNIVERSAL::isa($file, 'IO::Handle')) {
        $data{file} = $file;
    }
    elsif(UNIVERSAL::isa($file, 'File::Tail')) {
        $data{file} = $file;
        $data{filetail}=1;
    }
    elsif(! ref $file) {
        if($file eq '-') {
            my $io = new IO::Handle;
            $data{file} = $io->fdopen(fileno(STDIN),"r");
        }
        else {
            $data{file} = new IO::File($file, "<");
            defined $data{file} or croak "can't open $file: $!";
        }
    }
    else {
        croak "argument must be either a file-name or an IO::Handle object.";
    }
    if(defined $data{locale}) {
        if(ref $data{locale} eq 'ARRAY') {
            _use_locale @{$data{locale}};
        }
        elsif(ref $data{locale} eq '') {
            _use_locale $data{locale};
        }
        else {
            croak "'locale' parameter must be scalar or array of scalars";
        }
    }
    return bless \%data, $class;
}
sub _year_increment($$)
{
    my ($self, $mon) = @_;
    # year change
    if($mon==0) {
        $self->{year}++ if defined $self->{_last_mon} and $self->{_last_mon} == 11;
        $self->{enable_year_decrement} = 1;
    }
    elsif($mon == 11) {
        if($self->{enable_year_decrement}) {
            $self->{year}-- if defined $self->{_last_mon} and $self->{_last_mon} != 11;
        }
    }
    else {
        $self->{enable_year_decrement} = 0;
    }
    $self->{_last_mon} = $mon;
}
sub _next_line($)
{
    my $self = shift;
    my $f = $self->{file};
    if(defined $self->{filetail}) {
        return $f->read;
    }
    else {
        return $f->getline;
    }
}
sub _next_syslog($)
{
    my ($self) = @_;
    while($self->{_repeat}>0) {
        $self->{_repeat}--;
        return $self->{_repeat_data};
    }
    my $file = $self->{file};
    line: while(defined (my $str = $self->_next_line)) {
        # date, time and host
        my ($year, $mon, $day, $hour, $min, $sec, $host, $text);
        if($self->{type} eq 'rsyslog') {
            ($year, $mon, $day, $hour, $min, $sec, $host, $text) = $str =~ /^
                (\d+)-(\d+)-(\d+)T(\d+):(\d+):(\d+)\S+  # datetime
                \s+
                (\S+)                                   # host
                \s+
                (.*)                                    # text
                $/x or do
            {
                warn "WARNING: line not in high precision rsyslog format: $str";
                next line;
            };
            $mon--;
            $self->{year}=$year;
        }
        else {
            my($montxt);
            ($mon, $day, $hour, $min, $sec, $host, $text) = $str =~ /^
                (\S{3})\s+(\d+)      # date
                \s
                (\d+):(\d+):(\d+)    # time
                (?:\s<\w+\.\w+>)?    # FreeBSD's verbose-mode
                \s
                ([-\w\.\@:]+)        # host
                \s+
                (?:\[LOG_[A-Z]+\]\s+)?  # FreeBSD
                (.*)                 # text
                $/x or do
            {
                warn "WARNING: line not in syslog format: $str";
                next line;
            };
            $mon = $months_map{$montxt};
            defined $mon or croak "unknown month $montxt\n";
            $self->_year_increment($mon);
        }
        # convert to unix time
        my $time = $self->str2time($sec,$min,$hour,$day,$mon,$self->{year}-1900,$self->{GMT});
        if(not $self->{allow_future}) {
            # accept maximum one day in the present future
            if($time - time > 86400) {
                warn "WARNING: ignoring future date in syslog line: $str";
                next line;
            }
        }
        # last message repeated ... times
        if($text =~ /^(?:last message repeated|above message repeats) (\d+) time/) {
            next line if defined $self->{repeat} and not $self->{repeat};
            next line if not defined $self->{_last_data}{$host};
            $1 > 0 or do {
                warn "WARNING: last message repeated 0 or less times??\n";
                next line;
            };
            $self->{_repeat}=$1-1;
            $self->{_repeat_data}=$self->{_last_data}{$host};
            return $self->{_last_data}{$host};
        }
        # marks
        next if $text eq '-- MARK --';
        # some systems send over the network their
        # hostname prefixed to the text. strip that.
        $text =~ s/^$host\s+//;
        # discard ':' in HP-UX 'su' entries like this:
        # Apr 24 19:09:40 remedy : su : + tty?? root-oracle
        $text =~ s/^:\s+//;
        $text =~ /^
            ([^:]+?)        # program   -- 1
            (?:\[(\d+)\])?  # PID       -- 2
            :\s+
            (?:\[ID\ (\d+)\ ([a-z0-9]+)\.([a-z]+)\]\ )?   # Solaris 8 "message id" -- 3, 4, 5
            (.*)            # text      -- 6
            $/x or do
        {
            warn "WARNING: line not in syslog format: $str";
            next line;
        };
        if($self->{arrayref}) {
            $self->{_last_data}{$host} = [
                $time,  # 0: timestamp
                $host,  # 1: host
                $1,     # 2: program
                $2,     # 3: pid
                $6,     # 4: text
                ];
        }
        else {
            $self->{_last_data}{$host} = {
                timestamp => $time,
                host      => $host,
                program   => $1,
                pid       => $2,
                msgid     => $3,
                facility  => $4,
                level     => $5,
                text      => $6,
            };
        }
        return $self->{_last_data}{$host};
    }
    return undef;
}
sub _next_metalog($)
{
    my ($self) = @_;
    my $file = $self->{file};
    line: while(my $str = $self->_next_line) {
        # date, time and host
        $str =~ /^
            (\S{3})\s+(\d+)   # date  -- 1, 2
            \s
            (\d+):(\d+):(\d+) # time  -- 3, 4, 5
                              # host is not logged
            \s+
            (.*)              # text  -- 6
            $/x or do
        {
            warn "WARNING: line not in metalog format: $str";
            next line;
        };
        my $mon = $months_map{$1};
        defined $mon or croak "unknown month $1\n";
        $self->_year_increment($mon);
        # convert to unix time
        my $time = $self->str2time($5,$4,$3,$2,$mon,$self->{year}-1900,$self->{GMT});
        my $text = $6;
        $text =~ /^
            \[(.*?)\]        # program   -- 1
                             # no PID
            \s+
            (.*)             # text      -- 2
            $/x or do
        {
            warn "WARNING: text line not in metalog format: $text ($str)";
            next line;
        };
        if($self->{arrayref}) {
            return [
                $time,  # 0: timestamp
                'localhost',  # 1: host
                $1,     # 2: program
                undef,  # 3: (no) pid
                $2,     # 4: text
                ];
        }
        else {
            return {
                timestamp => $time,
                host      => 'localhost',
                program   => $1,
                text      => $2,
            };
        }
    }
    return undef;
}
sub next($)
{
    my ($self) = @_;
    if($self->{type} eq 'syslog' || $self->{type} eq 'rsyslog') {
        return $self->_next_syslog();
    }
    elsif($self->{type} eq 'metalog') {
        return $self->_next_metalog();
    }
    croak "Internal error: unknown type: $self->{type}";
}

#####################################################################
#####################################################################
#####################################################################

use RRDs;

use strict;
use File::Tail;
use Getopt::Long;
use POSIX 'setsid';

my $VERSION = "1.14";

# config
my $rrdstep = 60;
my $xpoints = 540;
my $points_per_sample = 3;

my $daemon_logfile = '/var/log/mailgraph.log';
my $daemon_pidfile = '/var/run/mailgraph.pid';
my $daemon_rrd_dir = '/var/lib/mailgraph';

# global variables
my $logfile;
my $rrd = "mailgraph.rrd";
my $rrd_virus = "mailgraph_virus.rrd";
my $rrd_greylist = "mailgraph_greylist.rrd";
my $year;
my $this_minute;
my %sum = ( sent => 0, received => 0, bounced => 0, rejected => 0, virus => 0, spam => 0, greylisted => 0, delayed => 0);
my $rrd_inited=0;

my %opt = ();

# prototypes
sub daemonize();
sub process_line($);
sub event_sent($);
sub event_received($);
sub event_bounced($);
sub event_rejected($);
sub event_virus($);
sub event_spam($);
sub event_greylisted($);
sub event_delayed($);
sub init_rrd($);
sub update($);

sub usage
{
        print "usage: mailgraph [*options*]\n\n";
        print "  -h, --help         display this help and exit\n";
        print "  -v, --verbose      be verbose about what you do\n";
        print "  -V, --version      output version information and exit\n";
        print "  -c, --cat          causes the logfile to be only read and not monitored\n";
        print "  -l, --logfile f    monitor logfile f instead of /var/log/syslog\n";
        print "  -t, --logtype t    set logfile's type (default: syslog)\n";
        print "  -y, --year         starting year of the log file (default: current year)\n";
        print "      --host=HOST    use only entries for HOST (regexp) in syslog\n";
        print "  -d, --daemon       start in the background\n";
        print "  --daemon-pid=FILE  write PID to FILE instead of /var/run/mailgraph.pid\n";
        print "  --daemon-rrd=DIR   write RRDs to DIR instead of /var/lib/mailgraph\n";
        print "  --daemon-log=FILE  write verbose-log to FILE instead of /var/log/mailgraph.log\n";
        print "  --ignore-localhost ignore mail to/from localhost (used for virus scanner)\n";
        print "  --ignore-host=HOST ignore mail to/from HOST regexp (used for virus scanner)\n";
        print "  --no-mail-rrd      no update mail rrd\n";
        print "  --no-virus-rrd     no update virus rrd\n";
        print "  --no-greylist-rrd  no update greylist rrd\n";
        print "  --rrd-name=NAME    use NAME.rrd and NAME_virus.rrd for the rrd files\n";
        print "  --rbl-is-spam      count rbl rejects as spam\n";
        print "  --virbl-is-virus   count virbl rejects as viruses\n";

        exit;
}

sub main
{
        Getopt::Long::Configure('no_ignore_case');
        GetOptions(\%opt, 'help|h', 'cat|c', 'logfile|l=s', 'logtype|t=s', 'version|V',
                'year|y=i', 'host=s', 'verbose|v', 'daemon|d!',
                'daemon_pid|daemon-pid=s', 'daemon_rrd|daemon-rrd=s',
                'daemon_log|daemon-log=s', 'ignore-localhost!', 'ignore-host=s@',
                'no-mail-rrd', 'no-virus-rrd', 'no-greylist-rrd', 'rrd_name|rrd-name=s',
                'rbl-is-spam', 'virbl-is-virus'
                ) or exit(1);
        usage if $opt{help};

        if($opt{version}) {
                print "mailgraph $VERSION by david\@schweikert.ch\n";
                exit;
        }

        $daemon_pidfile = $opt{daemon_pid} if defined $opt{daemon_pid};
        $daemon_logfile = $opt{daemon_log} if defined $opt{daemon_log};
        $daemon_rrd_dir = $opt{daemon_rrd} if defined $opt{daemon_rrd};
        $rrd            = $opt{rrd_name}.".rrd" if defined $opt{rrd_name};
        $rrd_virus      = $opt{rrd_name}."_virus.rrd" if defined $opt{rrd_name};
        $rrd_greylist   = $opt{rrd_name}."_greylist.rrd" if defined $opt{rrd_name};

        # compile --ignore-host regexps
        if(defined $opt{'ignore-host'}) {
                for my $ih (@{$opt{'ignore-host'}}) {
                        push @{$opt{'ignore-host-re'}}, qr{\brelay=[^\s,]*$ih}i;
                }
        }

        if($opt{daemon} or $opt{daemon_rrd}) {
                chdir $daemon_rrd_dir or die "mailgraph: can't chdir to $daemon_rrd_dir: $!";
                -w $daemon_rrd_dir or die "mailgraph: can't write to $daemon_rrd_dir\n";
        }

        daemonize if $opt{daemon};

        my $logfile = defined $opt{logfile} ? $opt{logfile} : '/var/log/syslog';
        my $file;
        if($opt{cat}) {
                $file = $logfile;
        }
        else {
                $file = File::Tail->new(name=>$logfile, tail=>-1);
        }
        my $parser = new Parse::Syslog($file, year => $opt{year}, arrayref => 1,
                type => defined $opt{logtype} ? $opt{logtype} : 'syslog');

        if(not defined $opt{host}) {
                while(my $sl = $parser->next) {
                        process_line($sl);
                }
        }
        else {
                my $host = qr/^$opt{host}$/i;
                while(my $sl = $parser->next) {
                        process_line($sl) if $sl->[1] =~ $host;
                }
        }
}

sub daemonize()
{
        open STDIN, '/dev/null' or die "mailgraph: can't read /dev/null: $!";
        if($opt{verbose}) {
                open STDOUT, ">>$daemon_logfile"
                        or die "mailgraph: can't write to $daemon_logfile: $!";
        }
        else {
                open STDOUT, '>/dev/null'
                        or die "mailgraph: can't write to /dev/null: $!";
        }
        defined(my $pid = fork) or die "mailgraph: can't fork: $!";
        if($pid) {
                # parent
                open PIDFILE, ">$daemon_pidfile"
                        or die "mailgraph: can't write to $daemon_pidfile: $!\n";
                print PIDFILE "$pid\n";
                close(PIDFILE);
                exit;
        }
        # child
        setsid                  or die "mailgraph: can't start a new session: $!";
        open STDERR, '>&STDOUT' or die "mailgraph: can't dup stdout: $!";
}

sub init_rrd($)
{
        my $m = shift;
        my $rows = $xpoints/$points_per_sample;
        my $realrows = int($rows*1.1); # ensure that the full range is covered
        my $day_steps = int(3600*24 / ($rrdstep*$rows));
        # use multiples, otherwise rrdtool could choose the wrong RRA
        my $week_steps = $day_steps*7;
        my $month_steps = $week_steps*5;
        my $year_steps = $month_steps*12;

        # mail rrd
        if(! -f $rrd and ! $opt{'no-mail-rrd'}) {
                RRDs::create($rrd, '--start', $m, '--step', $rrdstep,
                                'DS:sent:ABSOLUTE:'.($rrdstep*2).':0:U',
                                'DS:recv:ABSOLUTE:'.($rrdstep*2).':0:U',
                                'DS:bounced:ABSOLUTE:'.($rrdstep*2).':0:U',
                                'DS:rejected:ABSOLUTE:'.($rrdstep*2).':0:U',
                                "RRA:AVERAGE:0.5:$day_steps:$realrows",   # day
                                "RRA:AVERAGE:0.5:$week_steps:$realrows",  # week
                                "RRA:AVERAGE:0.5:$month_steps:$realrows", # month
                                "RRA:AVERAGE:0.5:$year_steps:$realrows",  # year
                                "RRA:MAX:0.5:$day_steps:$realrows",   # day
                                "RRA:MAX:0.5:$week_steps:$realrows",  # week
                                "RRA:MAX:0.5:$month_steps:$realrows", # month
                                "RRA:MAX:0.5:$year_steps:$realrows",  # year
                                );
                $this_minute = $m;
        }
        elsif(-f $rrd) {
                $this_minute = RRDs::last($rrd) + $rrdstep;
        }

        # virus rrd
        if(! -f $rrd_virus and ! $opt{'no-virus-rrd'}) {
                RRDs::create($rrd_virus, '--start', $m, '--step', $rrdstep,
                                'DS:virus:ABSOLUTE:'.($rrdstep*2).':0:U',
                                'DS:spam:ABSOLUTE:'.($rrdstep*2).':0:U',
                                "RRA:AVERAGE:0.5:$day_steps:$realrows",   # day
                                "RRA:AVERAGE:0.5:$week_steps:$realrows",  # week
                                "RRA:AVERAGE:0.5:$month_steps:$realrows", # month
                                "RRA:AVERAGE:0.5:$year_steps:$realrows",  # year
                                "RRA:MAX:0.5:$day_steps:$realrows",   # day
                                "RRA:MAX:0.5:$week_steps:$realrows",  # week
                                "RRA:MAX:0.5:$month_steps:$realrows", # month
                                "RRA:MAX:0.5:$year_steps:$realrows",  # year
                                );
        }
        elsif(-f $rrd_virus and ! defined $rrd_virus) {
                $this_minute = RRDs::last($rrd_virus) + $rrdstep;
        }
        # greylist rrd
        if(! -f $rrd_greylist and ! $opt{'no-greylist-rrd'}) {
                RRDs::create($rrd_greylist, '--start', $m, '--step', $rrdstep,
                                'DS:greylisted:ABSOLUTE:'.($rrdstep*2).':0:U',
                                'DS:delayed:ABSOLUTE:'.($rrdstep*2).':0:U',
                                "RRA:AVERAGE:0.5:$day_steps:$realrows",   # day
                                "RRA:AVERAGE:0.5:$week_steps:$realrows",  # week
                                "RRA:AVERAGE:0.5:$month_steps:$realrows", # month
                                "RRA:AVERAGE:0.5:$year_steps:$realrows",  # year
                                "RRA:MAX:0.5:$day_steps:$realrows",   # day
                                "RRA:MAX:0.5:$week_steps:$realrows",  # week
                                "RRA:MAX:0.5:$month_steps:$realrows", # month
                                "RRA:MAX:0.5:$year_steps:$realrows",  # year
                                );
                        $this_minute = $m;
        }
        elsif(-f $rrd_greylist and ! defined $rrd_greylist) {
                $this_minute = RRDs::last($rrd_greylist) + $rrdstep;
        }

        $rrd_inited=1;
}

sub process_line($)
{
        my $sl = shift;
        my $time = $sl->[0];
        my $prog = $sl->[2];
        my $text = $sl->[4];

        if($prog =~ /^postfix\/(.*)/) {
                my $prog = $1;
                if($prog eq 'smtp') {
                        if($text =~ /\bstatus=sent\b/) {
                                return if $opt{'ignore-localhost'} and
                                        $text =~ /\brelay=[^\s\[]*\[127\.0\.0\.1\]/;
                                if(defined $opt{'ignore-host-re'}) {
                                        for my $ih (@{$opt{'ignore-host-re'}}) {
                                                warn "MATCH! $text\n" if $text =~ $ih;
                                                return if $text =~ $ih;
                                        }
                                }
                                event($time, 'sent');
                        }
                        elsif($text =~ /\bstatus=bounced\b/) {
                                event($time, 'bounced');
                        }
                }
                elsif($prog eq 'local') {
                        if($text =~ /\bstatus=bounced\b/) {
                                event($time, 'bounced');
                        }
                }
                elsif($prog eq 'smtpd' || $prog eq 'postscreen') {
                        if($text =~ /^(?:[\dA-F]+|[\dB-DF-HJ-NP-TV-Zb-df-hj-np-tv-z]+): client=(\S+)/) {
                                my $client = $1;
                                return if $opt{'ignore-localhost'} and
                                        $client =~ /\[127\.0\.0\.1\]$/;
                                return if $opt{'ignore-host'} and
                                        $client =~ /$opt{'ignore-host'}/oi;
                                event($time, 'received');
                        }
                        elsif($opt{'virbl-is-virus'} and $text =~ /^(?:[\dA-F]+: |[\dB-DF-HJ-NP-TV-Zb-df-hj-np-tv-z]+: |NOQUEUE: )?reject: .*: 554.* blocked using virbl.dnsbl.bit.nl/) {
                                event($time, 'virus');
                        }
                        elsif($opt{'rbl-is-spam'} and $text    =~ /^(?:[\dA-F]+: |[\dB-DF-HJ-NP-TV-Zb-df-hj-np-tv-z]+: |NOQUEUE: )?reject: .*: 554.* blocked using/) {
                                event($time, 'spam');
                        }
                        elsif($text =~ /Greylisted/) {
                                event($time, 'greylisted');
                        }
                        elsif($text =~ /^(?:[\dA-F]+: |[\dB-DF-HJ-NP-TV-Zb-df-hj-np-tv-z]+: |NOQUEUE: )?reject: /) {
                                event($time, 'rejected');
                        }
                        elsif($text =~ /^(?:[\dA-F]+: |[\dB-DF-HJ-NP-TV-Zb-df-hj-np-tv-z]+: |NOQUEUE: )?milter-reject: /) {
                                if($text =~ /Blocked by SpamAssassin/) {
                                        event($time, 'spam');
                                }
                                else {
                                        event($time, 'rejected');
                                }
                        }
                }
                elsif($prog eq 'error') {
                        if($text =~ /\bstatus=bounced\b/) {
                                event($time, 'bounced');
                        }
                }
                elsif($prog eq 'cleanup') {
                        if($text =~ /(?:[\dA-F]+|[\dB-DF-HJ-NP-TV-Zb-df-hj-np-tv-z]+): (?:reject|discard): /) {
                                event($time, 'rejected');
                        }
                }
        }
        elsif($prog eq 'sendmail' or $prog eq 'sm-mta') {
                if($text =~ /\bmailer=local\b/ ) {
                        event($time, 'received');
                }
                elsif($text =~ /\bmailer=relay\b/) {
                        event($time, 'received');
                }
                elsif($text =~ /\bstat=Sent\b/ ) {
                        event($time, 'sent');
                }
                elsif($text =~ /\bmailer=esmtp\b/ ) {
                        event($time, 'sent');
                }
                elsif($text =~ /\bruleset=check_XS4ALL\b/ ) {
                        event($time, 'rejected');
                }
                elsif($text =~ /\blost input channel\b/ ) {
                        event($time, 'rejected');
                }
                elsif($text =~ /\bruleset=check_rcpt\b/ ) {
                        event($time, 'rejected');
                }
                elsif($text =~ /\bstat=virus\b/ ) {
                        event($time, 'virus');
                }
                elsif($text =~ /\bruleset=check_relay\b/ ) {
                        if (($opt{'virbl-is-virus'}) and ($text =~ /\bivirbl\b/ )) {
                                event($time, 'virus');
                        } elsif ($opt{'rbl-is-spam'}) {
                                event($time, 'spam');
                        } else {
                                event($time, 'rejected');
                        }
                }
                elsif($text =~ /\bsender blocked\b/ ) {
                        event($time, 'rejected');
                }
                elsif($text =~ /\bsender denied\b/ ) {
                        event($time, 'rejected');
                }
                elsif($text =~ /\brecipient denied\b/ ) {
                        event($time, 'rejected');
                }
                elsif($text =~ /\brecipient unknown\b/ ) {
                        event($time, 'rejected');
                }
                elsif($text =~ /\bUser unknown$/i ) {
                        event($time, 'bounced');
                }
                elsif($text =~ /\bMilter:.*\breject=55/ ) {
                        event($time, 'rejected');
                }
        }
        elsif($prog eq 'exim') {
                if($text =~ /^[0-9a-zA-Z]{6}-[0-9a-zA-Z]{6}-[0-9a-zA-Z]{2} <= \S+/) {
                        event($time, 'received');
                }
                elsif($text =~ /^[0-9a-zA-Z]{6}-[0-9a-zA-Z]{6}-[0-9a-zA-Z]{2} => \S+/) {
                        event($time, 'sent');
                }
                elsif($text =~ / rejected because \S+ is in a black list at \S+/) {
                        if($opt{'rbl-is-spam'}) {
                                event($time, 'spam');
                        } else {
                                event($time, 'rejected');
                        }
                }
                elsif($text =~ / rejected RCPT \S+: (Sender verify failed|Unknown user)/) {
                        event($time, 'rejected');
                }
        }
        elsif($prog eq 'amavis' || $prog eq 'amavisd') {
                if(   $text =~ /^\([\w-]+\) (Passed|Blocked) SPAM(?:MY)?\b/) {
                        if($text !~ /\btag2=/) { # ignore new per-recipient log entry (2.2.0)
                                event($time, 'spam'); # since amavisd-new-2004xxxx
                        }
                }
                elsif($text =~ /^\([\w-]+\) (Passed|Not-Delivered)\b.*\bquarantine spam/) {
                        event($time, 'spam'); # amavisd-new-20030616 and earlier
                }
                elsif($text =~ /^\([\w-]+\) (Passed |Blocked )?INFECTED\b/) {
                        if($text !~ /\btag2=/) {
                                event($time, 'virus');# Passed|Blocked inserted since 2004xxxx
                        }
                }
                elsif($text =~ /^\([\w-]+\) (Passed |Blocked )?BANNED\b/) {
                        if($text !~ /\btag2=/) {
                               event($time, 'virus');
                        }
                }
                elsif($text =~ /^Virus found\b/) {
                        event($time, 'virus');# AMaViS 0.3.12 and amavisd-0.1
                }
#               elsif($text =~ /^\([\w-]+\) Passed|Blocked BAD-HEADER\b/) {
#                      event($time, 'badh');
#               }
        }
        elsif($prog eq 'vagatefwd') {
                # Vexira antivirus (old)
                if($text =~ /^VIRUS/) {
                        event($time, 'virus');
                }
        }
        elsif($prog eq 'hook') {
                # Vexira antivirus
                if($text =~ /^\*+ Virus\b/) {
                        event($time, 'virus');
                }
                # Vexira antispam
                elsif($text =~ /\bcontains spam\b/) {
                        event($time, 'spam');
                }
        }
        elsif($prog eq 'avgatefwd' or $prog eq 'avmailgate.bin') {
                # AntiVir MailGate
                if($text =~ /^Alert!/) {
                        event($time, 'virus');
                }
                elsif($text =~ /blocked\.$/) {
                        event($time, 'virus');
                }
        }
        elsif($prog eq 'avcheck') {
                # avcheck
                if($text =~ /^infected/) {
                        event($time, 'virus');
                }
        }
        elsif($prog eq 'spamd') {
                if($text =~ /^(?:spamd: )?identified spam/) {
                        event($time, 'spam');
                }
                # ClamAV SpamAssassin-plugin
                elsif($text =~ /(?:result: )?CLAMAV/) {
                        event($time, 'virus');
                }
        }
        elsif($prog eq 'dspam') {
                if($text =~ /spam detected from/) {
                        event($time, 'spam');
                }
                elsif($text =~ /infected message from/) {
                        event($time, 'virus');
                }
        }
        elsif($prog eq 'spamproxyd' or $prog eq 'spampd') {
                if($text =~ /^\s*SPAM/ or $text =~ /^identified spam/) {
                        event($time, 'spam');
                }
        }
        elsif($prog eq 'drweb-postfix') {
                # DrWeb
                if($text =~ /infected/) {
                        event($time, 'virus');
                }
        }
        elsif($prog eq 'BlackHole') {
                if($text =~ /Virus/) {
                        event($time, 'virus');
                }
                if($text =~ /(?:RBL|Razor|Spam)/) {
                        event($time, 'spam');
                }
        }
        elsif($prog eq 'MailScanner') {
                if($text =~ /(Virus Scanning: Found)/ ) {
                        event($time, 'virus');
                }
                elsif($text =~ /Bounce to/ ) {
                        event($time, 'bounced');
                }
                elsif($text =~ /^Spam Checks: Found ([0-9]+) spam messages/) {
                        my $cnt = $1;
                        for (my $i=0; $i<$cnt; $i++) {
                                event($time, 'spam');
                        }
                }
        }
        elsif($prog eq 'clamsmtpd') {
                if($text =~ /status=VIRUS/) {
                        event($time, 'virus');
                }
        }
        elsif($prog eq 'clamav-milter') {
                if($text =~ /Intercepted/) {
                        event($time, 'virus');
                }
        elsif($text =~ /Message.*infected by/) {
                        event($time, 'virus');
                }
        }
        # uncommment for clamassassin:
        #elsif($prog eq 'clamd') {
        #       if($text =~ /^stream: .* FOUND$/) {
        #               event($time, 'virus');
        #       }
        #}
        elsif ($prog eq 'smtp-vilter') {
                if ($text =~ /clamd: found/) {
                        event($time, 'virus');
                }
        }
        elsif($prog eq 'avmilter') {
                # AntiVir Milter
                if($text =~ /^Alert!/) {
                        event($time, 'virus');
                }
                elsif($text =~ /blocked\.$/) {
                        event($time, 'virus');
                }
        }
        elsif($prog eq 'bogofilter') {
                if($text =~ /Spam/) {
                        event($time, 'spam');
                }
        }
        elsif($prog eq 'filter-module') {
                if($text =~ /\bspam_status\=(?:yes|spam)/) {
                        event($time, 'spam');
                }
        }
        elsif($prog eq 'sta_scanner') {
                if($text =~ /^[0-9A-F]+: virus/) {
                        event($time, 'virus');
                }
        }
        elsif($prog eq 'postgrey') {
                # Old versions (up to 1.27)
                if($text =~ /delayed [0-9]+ seconds: client/) {
                        event($time, 'delayed');
                }
                # New versions (from 1.28)
                if($text =~ /delay=[0-9]+/) {
                        event($time, 'delayed');
                }
        }
        elsif($prog eq 'grossd') {
                if($text =~ /a\=greylist/) {
                        event($time, 'greylisted');
                }
        }
}

sub event($$)
{
        my ($t, $type) = @_;
        update($t) and $sum{$type}++;
}

# returns 1 if $sum should be updated
sub update($)
{
        my $t = shift;
        my $m = $t - $t%$rrdstep;
        init_rrd($m) unless $rrd_inited;
        return 1 if $m == $this_minute;
        return 0 if $m < $this_minute;

        print "update $this_minute:$sum{sent}:$sum{received}:$sum{bounced}:$sum{rejected}:$sum{virus}:$sum{spam}:$sum{greylisted}:$sum{delayed}\n" if $opt{verbose};
        RRDs::update $rrd, "$this_minute:$sum{sent}:$sum{received}:$sum{bounced}:$sum{rejected}" unless $opt{'no-mail-rrd'};
        RRDs::update $rrd_virus, "$this_minute:$sum{virus}:$sum{spam}" unless $opt{'no-virus-rrd'};
        RRDs::update $rrd_greylist, "$this_minute:$sum{greylisted}:$sum{delayed}" unless $opt{'no-greylist-rrd'};
        if($m > $this_minute+$rrdstep) {
                for(my $sm=$this_minute+$rrdstep;$sm<$m;$sm+=$rrdstep) {
                        print "update $sm:0:0:0:0:0:0:0:0 (SKIP)\n" if $opt{verbose};
                        RRDs::update $rrd, "$sm:0:0:0:0" unless $opt{'no-mail-rrd'};
                        RRDs::update $rrd_virus, "$sm:0:0" unless $opt{'no-virus-rrd'};
                        RRDs::update $rrd_greylist, "$sm:0:0" unless $opt{'no-greylist-rrd'};
                }
        }
        $this_minute = $m;
        $sum{sent}=0;
        $sum{received}=0;
        $sum{bounced}=0;
        $sum{rejected}=0;
        $sum{virus}=0;
        $sum{spam}=0;
        $sum{greylisted}=0;
        $sum{delayed}=0;
        return 1;
}

main;

__END__

=head1 NAME

mailgraph.pl - rrdtool frontend for mail statistics

=head1 SYNOPSIS

B<mailgraph> [I<options>...]

     --man          show man-page and exit
 -h, --help         display this help and exit
     --version      output version information and exit
 -h, --help         display this help and exit
 -v, --verbose      be verbose about what you do
 -V, --version      output version information and exit
 -c, --cat          causes the logfile to be only read and not monitored
 -l, --logfile f    monitor logfile f instead of /var/log/syslog
 -t, --logtype t    set logfile's type (default: syslog)
 -y, --year         starting year of the log file (default: current year)
     --host=HOST    use only entries for HOST (regexp) in syslog
 -d, --daemon       start in the background
 --daemon-pid=FILE  write PID to FILE instead of /var/run/mailgraph.pid
 --daemon-rrd=DIR   write RRDs to DIR instead of /var/log
 --daemon-log=FILE  write verbose-log to FILE instead of /var/log/mailgraph.log
 --ignore-localhost ignore mail to/from localhost (used for virus scanner)
 --ignore-host=HOST ignore mail to/from HOST regexp (used for virus scanner)
 --no-mail-rrd      do not update mail rrd
 --no-virus-rrd     do not update virus rrd
 --no-greylist-rrd  do not update greylist rrd
 --rrd-name=NAME    use NAME.rrd and NAME_virus.rrd for the rrd files
 --rbl-is-spam      count rbl rejects as spam
 --virbl-is-virus   count virbl rejects as viruses

=head1 DESCRIPTION

This script does parse syslog and updates the RRD database (mailgraph.rrd) in
the current directory.

=head2 Log-Types

The following types can be given to --logtype:

=over 10

=item syslog

Traditional "syslog" (default)

=item rsyslog

High precision format "rsyslog"

=item metalog

Metalog (see http://metalog.sourceforge.net/)

=back

=head1 COPYRIGHT

Copyright (c) 2000-2007 by ETH Zurich
Copyright (c) 2000-2007 by David Schweikert
Copyright (c) 2018 by M.D. Klapwijk

=head1 LICENSE

This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.

=head1 AUTHOR

S<David Schweikert E<lt>david@schweikert.chE<gt>>

=cut

# vi: sw=8