#!/usr/bin/perl
use strict;
use warnings;

use Data::Dumper;
use Getopt::Long;
use Pod::Usage;
$|++;

my %command_line_options = (
    'rid:s'         => \my $rid,
    'author:s'      => \my $author,
    'from_dom:s'    => \my $from,
    'begin:s'       => \my $begin,
    'end:s'         => \my $end,
    'sort:s'        => \my $sort,
    'limit:s'       => \my $limit,
    'disposition:s' => \my $disposition,
    'dkim:s'        => \my $dkim,
    'spf:s'         => \my $spf,
    'dns'           => \my $dns_opt,
    'geoip'         => \my $geoip_opt,
    'help'          => \my $help,
    'verbose'       => \my $verbose,
    );
GetOptions (%command_line_options);

use Mail::DMARC::Report;
my $report = Mail::DMARC::Report->new;
my $gip;
pod2usage if $help;

$limit = 100 if !defined $limit;

if ( defined $limit && ( $limit !~ /^\d+$/ || $limit < 1 ) ) {
    die "--limit must be a positive integer\n";
}

my ( $sort_by, $sort_order ) = parse_sort_option($sort);

my $reports = $report->store->retrieve(
        (defined $rid       ? (rid         => $rid    ) : () ),
        (defined $from      ? (from_domain => $from   ) : () ),
        (defined $author    ? (author      => $author ) : () ),
        (defined $begin     ? (begin       => $begin  ) : () ),
        (defined $end       ? (end         => $end    ) : () ),
        (defined $limit     ? (limit       => $limit  ) : () ),
        (sort_by            => $sort_by),
        (sort_order         => $sort_order),
        );

my @display_data;
my %max_width = (
    rid         => length('ID'),
    author      => length('Author'),
    begin       => length('Report-Start'),
    count       => length('Qty'),
    header_from => length('From'),
    source_ip   => length('IP'),
    disposition => length('Disposition'),
    dkim        => length('DKIM'),
    spf         => length('SPF'),
);

foreach my $r ( @$reports ) {
    my $rows = $report->store->backend->get_rr( rid => $r->{rid} )->{data};

    my @filtered_rows;
    foreach my $row ( @$rows ) {
        no warnings;  ## no critic (NoWarn)
        next if $disposition && !_matches($disposition, $row->{disposition});
        next if $dkim && !_matches($dkim, $row->{dkim});
        next if $spf  && !_matches($spf,  $row->{spf});

        $row->{reasons_str} = join '  ', map { $_->{type} . ($_->{comment} ? " ($_->{comment})" : "") } @{$row->{reasons} // []};
        $row->{geoip_details} = get_geoip_details( $row->{source_ip} ) // '';
        $row->{dns_hostname} = get_dns_hostname( $row->{source_ip} ) // '';

        foreach my $field (qw/ count header_from source_ip disposition dkim spf reasons_str geoip_details /) {
            my $len = length($row->{$field} // '');
            $max_width{$field} = $len if $len > $max_width{$field};
        }
        push @filtered_rows, $row;
    }

    if ( $disposition || $dkim || $spf ) {
        next if ! @filtered_rows;
    }

    foreach my $field (qw/ rid author begin /) {
        my $len = length($r->{$field} // '');
        $max_width{$field} = $len if $len > $max_width{$field};
    }

    push @display_data, { record => $r, rows => \@filtered_rows };
}

# Print header
printf "%*s  %-*s  %-*s\n",
    $max_width{rid}, 'ID',
    $max_width{author}, 'Author',
    $max_width{begin}, 'Report-Start';

printf "  | -- %*s %-*s %-*s %-*s %-*s %-*s\n",
    $max_width{count}, 'Qty',
    $max_width{header_from}, 'From',
    $max_width{source_ip}, 'IP',
    $max_width{disposition}, 'Disposition',
    $max_width{dkim}, 'DKIM',
    $max_width{spf}, 'SPF';
print "\n";

foreach my $d ( @display_data ) {
    my $r = $d->{record};
    printf "%*s  %-*s  %-*s\n",
        $max_width{rid}, $r->{rid},
        $max_width{author}, $r->{author},
        $max_width{begin}, $r->{begin};

    foreach my $row ( @{$d->{rows}} ) {
        printf "  | -- %*s %-*s %-*s %-*s %-*s %-*s",
            $max_width{count}, $row->{count},
            $max_width{header_from}, $row->{header_from},
            $max_width{source_ip}, $row->{source_ip},
            $max_width{disposition}, $row->{disposition},
            $max_width{dkim}, $row->{dkim},
            $max_width{spf}, $row->{spf};

        printf "  %-*s", $max_width{reasons_str}, $row->{reasons_str} if $max_width{reasons_str};
        printf "  %-*s", $max_width{geoip_details}, $row->{geoip_details} if $max_width{geoip_details};
        print "  $row->{dns_hostname}" if $row->{dns_hostname};
        print "\n";
    }
    print "\n";
}

sub _matches {
    my ($filter, $value) = @_;
    no warnings;  ## no critic (NoWarn)
    if ( substr($filter, 0, 1) eq '!' ) { return $value ne substr($filter, 1); }
    return $value eq $filter;
}

sub parse_sort_option {
    my ($sort) = @_;
    $sort //= 'rid:desc';
    $sort =~ s/^\s+|\s+$//g;

    return ('rid', 'DESC') if lc $sort eq 'newest';
    return ('rid', 'ASC')  if lc $sort eq 'oldest';

    my ($sort_by, $sort_order) = split /:/, $sort, 2;
    $sort_by    ||= 'rid';
    $sort_order ||= 'desc';

    $sort_by    = lc $sort_by;
    $sort_order = uc $sort_order;
    $sort_by    = 'rid' if $sort_by eq 'id';

    my %allowed_sort = map { $_ => 1 } qw( rid author from_domain begin end );
    die "--sort field must be one of: rid, author, from_domain, begin, end\n"
        if !$allowed_sort{$sort_by};

    die "--sort order must be ASC or DESC\n"
        if $sort_order ne 'ASC' && $sort_order ne 'DESC';

    return ($sort_by, $sort_order);
}

sub get_geoip_details {
    my $ip = shift;

    return if ! defined $geoip_opt;

    $gip ||= get_geoip_db();
    return if ! $gip;

    my $city = $gip->city(ip => $ip) or return '';
    my $result = '';
    if ($city->continent->code) {
        $result .= $city->continent->code;
    }
    if ($city->country->iso_code) {
        $result .= ', ' .$city->country->iso_code;
    }
    if ($city->most_specific_subdivision->name) {
        $result .= ', ' . $city->most_specific_subdivision->name;
    }
    if ($city->city->name) {
        $result .= ', ' . $city->city->name;
    }
    return $result;
}

sub get_geoip_db {

    return $gip if $gip;
    eval "require GeoIP2::Database::Reader";  ## no critic (Eval)
    if ($@) {
        warn "unable to load GeoIP2\n";
        return;
    };

    foreach my $local ( '/usr/local', '/opt/local', '/usr' ) {
        my $db_dir = "$local/share/GeoIP";

        foreach my $db (qw/ GeoLite2-City GeoLite2-Country /) {
            if (-f "$db_dir/$db.mmdb") {
                print "using db $db" if $verbose;
                $gip = GeoIP2::Database::Reader->new( file => "$db_dir/$db.mmdb" );
            }
            last if $gip;
        }
        last if $gip;
    };
    return $gip;
}

sub get_dns_hostname {
    my $ip = shift;
    return if ! $dns_opt;

    my @answers = $report->has_dns_rr('PTR', $ip);
    return '' if 0 == scalar @answers;
    return $answers[0] if scalar @answers >= 1;
    print Dumper(\@answers);
    return;
};

exit;

__END__

=head1 SYNOPSIS

  dmarc_view_reports [ --option=value ]

Dumps the contents of the DMARC data store to your terminal. The most recent records are show first.

=head2 Search Options

    rid          - report ID (internal database ID)
    author       - report author (Yahoo! Inc, google.com, etc..)
    from_dom     - message sender domain
    begin        - epoch start time to display messages after
    end          - epoch end time to display messages before
    disposition  - DMARC disposition (none,quarantine,reject)
    dkim         - DKIM alignment result (pass/fail)
    spf          - SPF alignment result  (pass/fail)

    limit        - limit number of reports returned (defaults to 100)
    sort         - sort by: rid, author, from_domain, begin, end (append :asc or :desc)

The default sort is C<rid:desc> (newest reports first). You can also use C<--sort=newest>
or C<--sort=oldest>.

Prefix a value with C<!> to negate the match (exclude matching records). This is
supported for C<author>, C<from_dom>, C<disposition>, C<dkim>, and C<spf>.
For example, C<--author=!google.com> will exclude reports authored by google.com.

=head2 Other Options

  dmarc_view_reports [ --geoip --dns --help --verbose ]

    geoip        - do GeoIP lookups (requires the free Maxmind GeoCityLitev6 database).
    dns          - do reverse DNS lookups and display hostnames
    help         - print this syntax guide
    verbose      - print additional debug info

=head1 EXAMPLES

To view a specific report by its ID:

  dmarc_view_reports --rid=560

To search for all reports from google.com that failed DMARC alignment:

  dmarc_view_reports --author=google.com --dkim=fail --spf=fail

Note that we don't use --disposition. That would only tell us the result of applying DMARC policy, not necessarily if the messages failed DMARC alignment.

To exclude reports from google.com:

  dmarc_view_reports --author=!google.com

To exclude rows with a specific disposition:

  dmarc_view_reports --disposition=!none

To show only the newest 50 reports:

    dmarc_view_reports --limit=50

To view oldest reports first:

    dmarc_view_reports --sort=oldest

To display GeoIP lookup data for the source ip:

  dmarc_view_reports --geoip

By default; city, country_code & continent_code are shown. You can optionally pass a comma delimited string to --geoip= with any of the following fields:

country_code
country_code3
country_name
region
region_name
city
postal_code
latitude
longitude
time_zone
area_code
continent_code
metro_code

  dmarc_view_reports --geoip=country_name,continent_code
  dmarc_view_reports --geoip=continent_code,country_name # keep order
  dmarc_view_reports --geoip=city,city,city              # repeat


=head1 SAMPLE OUTPUT


 ID  Author            Report-Start
  | -- Qty From                                         IP             Disposition DKIM SPF

 570  theartfarm.com   2013-05-20 09:40:50
  | --   1 simerson.net                                 75.126.200.152 quarantine  fail fail

 568  yeah.net         2013-05-21 09:00:00
  | --   1 tnpi.net                                     111.176.77.138 reject      fail fail

 565  google.com       2013-05-20 17:00:00
  | --  88 mesick.us                                    208.75.177.101 none        pass pass

 563  google.com       2013-05-20 17:00:00
  | --   1 lynboyer.com                                 2a00:1450:4010:c03::235 none pass fail  forwarded
  | --  12 lynboyer.com                                 208.75.177.101          none pass pass
  | --   1 lynboyer.com                                 209.85.217.174          none pass fail  forwarded


=head1 AUTHORS

=over 4

=item *

Matt Simerson <msimerson@cpan.org>

=item *

Davide Migliavacca <shari@cpan.org>

=item *

Marc Bradshaw <marc@marcbradshaw.net>

=back

=cut
