%PDF- %PDF-
Mini Shell

Mini Shell

Direktori : /www/varak.net/dmarc.varak.net/classes/Report/
Upload File :
Create Path :
Current File : /www/varak.net/dmarc.varak.net/classes/Report/SummaryReport.php

<?php

/**
 * dmarc-srg - A php parser, viewer and summary report generator for incoming DMARC reports.
 * Copyright (C) 2022-2025 Aleksey Andreev (liuch)
 *
 * Available at:
 * https://github.com/liuch/dmarc-srg
 *
 * 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 3 of the License.
 *
 * 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, see <http://www.gnu.org/licenses/>.
 *
 * =========================
 *
 * This file contains SummaryReport class
 *
 * @category API
 * @package  DmarcSrg
 * @author   Aleksey Andreev (liuch)
 * @license  https://www.gnu.org/licenses/gpl-3.0.html GNU/GPLv3
 */

namespace Liuch\DmarcSrg\Report;

use Liuch\DmarcSrg\Common;
use Liuch\DmarcSrg\DateTime;
use Liuch\DmarcSrg\TextTable;
use Liuch\DmarcSrg\Statistics;
use Liuch\DmarcSrg\Exception\SoftException;
use Liuch\DmarcSrg\Exception\LogicException;

/**
 * This class is for generating summary data for the specified period and domain
 */
class SummaryReport
{
    private const LAST_WEEK  = -1;
    private const LAST_MONTH = -2;
    private const DATE_RANGE = -3;

    private $period  = 0;
    private $offset  = 0;
    private $range   = null;
    private $domain  = null;
    private $overall = [];
    private $stat    = [];
    private $subject = '';

    /**
     * Constructor
     *
     * @param string $period The period for which the report is created
     *                       Must me one of the following values: `lastweek`, `lastmonth`, and `lastndays:N`
     *                       where N is the number of days the report is created for
     * @param int            $offset Range offset
     */
    public function __construct(string $period, int $offset = 0)
    {
        switch ($period) {
            case 'lastweek':
                $period  = self::LAST_WEEK;
                $subject = ' weekly digest';
                break;
            case 'lastmonth':
                $period  = self::LAST_MONTH;
                $subject = ' monthly digest';
                break;
            default:
                $ndays = 0;
                $av = explode(':', $period);
                if (count($av) === 2) {
                    switch ($av[0]) {
                        case 'lastndays':
                            $ndays = intval($av[1]);
                            if ($ndays <= 0) {
                                throw new SoftException('The parameter "days" has an incorrect value');
                            }
                            $period  = $ndays;
                            $subject = sprintf(' %d day%s digest', $ndays, ($ndays > 1 ? 's' : ''));
                            break;
                        case 'range':
                            $range = explode('-', $av[1]);
                            if (count($range) !== 2) {
                                throw new SoftException('The parameter "range" must contain two dates');
                            }
                            $range = array_map(function ($r) {
                                $cnt = 0;
                                $sd = preg_replace('/^(\d{4})(\d{2})(\d{2})$/', '\1-\2-\3', $r, -1, $cnt);
                                if (!$cnt) {
                                    throw new SoftException('The parameter "range" has an incorrect value');
                                }
                                return new DateTime($sd);
                            }, $range);
                            if ($range[0] > $range[1]) {
                                throw new SoftException('Incorrect date range');
                            }
                            $period  = self::DATE_RANGE;
                            $subject = ' report ' . Common::rangeToString($range);
                            $range[1]->modify('next day');
                            $this->range = $range;
                            break;
                    }
                }
                break;
        }
        if (empty($subject)) {
            throw new SoftException('The parameter "period" has an incorrect value');
        }
        if ($offset < 0) {
            throw new SoftException('The parameter "offset" has an incorrect value');
        }
        $this->period  = $period;
        $this->offset  = $offset;
        $this->subject = "DMARC{$subject}";
    }

    /**
     * Binds a domain to the report
     *
     * @param \Liuch\DmarcSrg\Domains\Domain $domain The domain for which the report is created
     *
     * @return self
     */
    public function setDomain($domain)
    {
        $this->domain = $domain;
        $this->stat   = [];
        return $this;
    }

    /**
     * Checks if the report is empty
     *
     * @return bool
     */
    public function isEmpty(): bool
    {
        return ($this->getData('summary')['emails']['total'] === 0);
    }

    /**
     * Binds a report section
     *
     * @param mixed $section Report section
     *
     * @return self
     */
    public function bindSection($section)
    {
        if ($section instanceof OverallReport) {
            $this->overall[] = $section;
        }
        return $this;
    }

    /**
     * Returns the report data as an array
     *
     * @return array
     */
    public function toArray(): array
    {
        $range = $this->getData('range');
        return [
            'date_range'    => [ 'begin' => $range[0], 'end' => $range[1] ],
            'summary'       => $this->getData('summary'),
            'sources'       => $this->getData('ips'),
            'organizations' => $this->getData('organizations')
        ];
    }

    /**
     * Returns the subject string. It is used in email messages.
     *
     * @return string
     */
    public function subject(): string
    {
        return $this->subject;
    }

    /**
     * Returns the report as an array of text strings
     *
     * @return array
     */
    public function text(): array
    {
        $rdata = $this->reportData();

        $res = [ '# Domain: ' . $this->domain->fqdn() ];
        $res[] = ' Range: ' . $rdata['range'];
        $res[] = '';

        $res[] = '## Summary';
        $total = $rdata['summary']['total'];
        $res[] = sprintf(' Total: %d', $total);
        $res[] = sprintf(' Fully aligned: %s', Common::num2percent($rdata['summary']['f_aligned'], $total, true));
        $res[] = sprintf(' Partial aligned: %s', Common::num2percent($rdata['summary']['p_aligned'], $total, true));
        $res[] = sprintf(' Not aligned: %s', Common::num2percent($rdata['summary']['n_aligned'], $total, true));
        $res[] = sprintf(' Quarantined: %s', Common::num2percent($rdata['summary']['quarantined'], $total, true));
        $res[] = sprintf(' Rejected: %s', Common::num2percent($rdata['summary']['rejected'], $total, true));
        $res[] = '';

        if (count($rdata['sources']) > 0) {
            $res[] = '## Sources';
            $table = new TextTable([ '', 'Total', 'SPF only', 'DKIM only', 'Not aligned', 'Quar+Rej' ]);
            $table->setMinColumnsWidth([ 15, 5, 8, 9, 11, 8 ])->setBorders('', '', '')
                ->setColumnAlignment(4, 'right')->setColumnAlignment(5, 'right');
            foreach ($rdata['sources'] as &$it) {
                $total = $it['emails'];
                $f_aln = $it['dkim_spf_aligned'];
                $d_aln = $it['dkim_aligned'];
                $s_aln = $it['spf_aligned'];
                $n_aln = $total - $f_aln - $d_aln - $s_aln;
                $q_dis = $it['quarantined'];
                $r_dis = $it['rejected'];
                if ($q_dis || $r_dis) {
                    $s_dis = Common::num2percent($q_dis + $r_dis, $total, false) . "({$q_dis}+{$r_dis})";
                } else {
                    $s_dis = '0';
                }
                $table->appendRow([
                    $it['ip'], $total, $s_aln, $d_aln, Common::num2percent($n_aln, $total, true), $s_dis
                ]);
            }
            unset($it);
            $res = array_merge($res, $table->toArray());
            $res[] = '';
        }

        if (count($rdata['organizations']) > 0) {
            $res[] = '## Reporting organizations';
            $table = new TextTable([ '', 'Reports', 'Emails', 'SPF only', 'DKIM only', 'Not aligned', 'Quar+Rej' ]);
            $table->setMinColumnsWidth([ 15, 7, 6, 8, 9, 11, 8 ])->setBorders('', '', '')
                ->setColumnAlignment(5, 'right')->setColumnAlignment(6, 'right');
            foreach ($rdata['organizations'] as &$it) {
                $total = $it['emails'];
                $f_aln = $it['dkim_spf_aligned'];
                $d_aln = $it['dkim_aligned'];
                $s_aln = $it['spf_aligned'];
                $n_aln = $total - $f_aln - $d_aln - $s_aln;
                $q_dis = $it['quarantined'];
                $r_dis = $it['rejected'];
                if ($q_dis || $r_dis) {
                    $s_dis = Common::num2percent($q_dis + $r_dis, $total, false) . "({$q_dis}+{$r_dis})";
                } else {
                    $s_dis = '0';
                }
                $table->appendRow([
                    $it['name'], $it['reports'], $total, $s_aln, $d_aln,
                    Common::num2percent($n_aln, $total, true), $s_dis
                ]);
            }
            unset($it);
            $res = array_merge($res, $table->toArray());
            $res[] = '';
        }

        return $res;
    }

    /**
     * Returns the report as an array of html strings
     *
     * @return array
     */
    public function html(): array
    {
        $h2a = 'style="margin:15px 0 5px;"';
        $t2a = 'style="border-collapse:collapse;border-spacing:0;"';
        $c1a = 'style="font-style:italic;"';
        $d1s = 'padding-left:1em;';
        $d2s = 'min-width:4em;';
        $d3s = 'border:1px solid #888;';
        $d4s = 'text-align:right;';
        $d5s = 'padding:.3em;';
        $rs2 = 'rowspan="2"';
        $cs2 = 'colspan="2"';

        $get_color = function (string $name, int $num) {
            $cn = '';
            if ($num > 0) {
                switch ($name) {
                    case 'red':
                        $cn = 'f00';
                        break;
                    case 'green':
                        $cn = '080';
                        break;
                }
            }
            return empty($cn) ? '' : "color:#{$cn};";
        };

        $rdata = $this->reportData();
        $res = [];
        $res[] = "<h2 {$h2a}>Domain: " . htmlspecialchars($this->domain->fqdn()) . '</h2>';
        $res[] = '<p style="margin:0;">Range: ' . htmlspecialchars($rdata['range']) . '</p>';

        $res[] = "<h3 {$h2a}>Summary</h3>";
        $res[] = '<table>';
        $total = $rdata['summary']['total'];
        $res[] = " <tr><td>Total: </td><td style=\"{$d1s}\">{$total}</td></tr>";
        foreach ([
            [ 'Fully aligned', $rdata['summary']['f_aligned'], 'green' ],
            [ 'Partial aligned', $rdata['summary']['p_aligned'], '' ],
            [ 'Not aligned', $rdata['summary']['n_aligned'], 'red' ],
            [ 'Quarantined', $rdata['summary']['quarantined'], 'red' ],
            [ 'Rejected', $rdata['summary']['rejected'], 'red' ]
        ] as &$rd) {
            $color = $get_color($rd[2], $rd[1]);
            $s_data = Common::num2percent($rd[1], $total, true);
            $res[] = " <tr><td>{$rd[0]}: </td><td style=\"{$d1s}{$color}\">{$s_data}</td></tr>";
        }
        unset($rd);
        $res[] = '</table>';

        $s_cnt = count($rdata['sources']);
        if ($s_cnt > 0) {
            $res[] = "<h3 {$h2a}>Sources</h3>";
            $res[] = "<table {$t2a}>";
            $res[] = " <caption {$c1a}>Total records: {$s_cnt}</caption>";
            $res[] = ' <thead>';
            $style = "style=\"{$d3s}{$d5s}\"";
            $res[] = "  <tr><th {$rs2} {$style}>IP address</th><th {$rs2} {$style}>Email volume</th>" .
                     "<th {$cs2} {$style}>Partial aligned</th><th {$rs2} {$style}>Not aligned</th>" .
                     "<th {$cs2} {$style}>Disposition</th></tr>";
            $style = "style=\"{$d2s}{$d3s}{$d5s}\"";
            $res[] = "  <tr><th {$style}>SPF only</th><th {$style}>DKIM only</th>" .
                     "<th {$style}>quar+rej</th><th {$style}>fail rate</th></tr>";
            $res[] = ' </thead>';
            $res[] = ' <tbody>';
            $style = "style=\"{$d3s}{$d5s}";
            foreach ($rdata['sources'] as &$row) {
                $ip    = htmlspecialchars($row['ip']);
                $total = $row['emails'];
                $f_aln = $row['dkim_spf_aligned'];
                $d_aln = $row['dkim_aligned'];
                $s_aln = $row['spf_aligned'];
                $n_aln = $total - $f_aln - $d_aln - $s_aln;
                $q_dis = $row['quarantined'];
                $r_dis = $row['rejected'];
                $s_dis = ($q_dis || $r_dis) ? "{$q_dis}+{$r_dis}" : '0';
                $res[] = "  <tr><td {$style}\">{$ip}</td><td {$style}{$d4s}\">{$total}</td>" .
                         "<td {$style}{$d4s}\">{$s_aln}</td><td {$style}{$d4s}\">{$d_aln}</td>" .
                         "<td {$style}{$d4s}{$get_color('red', $n_aln)}\">{$n_aln}</td>" .
                         "<td {$style}{$d4s}{$get_color('red', $q_dis + $r_dis)}\">{$s_dis}</td>" .
                         "<td {$style}{$d4s}\">" . Common::num2percent($q_dis + $r_dis, $total, false) .
                         '</td></tr>';
            }
            unset($row);
            $res[] = ' </tbody>';
            $res[] = '</table>';
        }

        $o_cnt = count($rdata['organizations']);
        if ($o_cnt) {
            $res[] = "<h3 {$h2a}>Organizations</h3>";
            $res[] = "<table {$t2a}>";
            $res[] = " <caption {$c1a}>Total records: {$o_cnt}</caption>";
            $res[] = ' <thead>';
            $style = "style=\"{$d3s}{$d5s}\"";
            $res[] = "  <tr><th {$rs2} {$style}>Name</th><th {$cs2} {$style}>Volume</th>" .
                     "<th {$cs2} {$style}>Partial aligned</th><th {$rs2} {$style}>Not aligned</th>" .
                     "<th {$cs2} {$style}>Disposition</th></tr>";
            $res[] = "  <tr><th {$style}>reports</th><th {$style}>emails</th>" .
                     "<th {$style}>SPF only</th><th {$style}>DKIM only</th>" .
                     "<th {$style}>quar+rej</th><th {$style}>fail rate</th></tr>";
            $res[] = ' </thead>';
            $res[] = ' <tbody>';
            $style = "style=\"{$d3s}{$d5s}";
            foreach ($rdata['organizations'] as &$row) {
                $name   = htmlspecialchars(trim($row['name']));
                $total  = $row['emails'];
                $f_aln  = $row['dkim_spf_aligned'];
                $d_aln  = $row['dkim_aligned'];
                $s_aln  = $row['spf_aligned'];
                $n_aln  = $total - $f_aln - $d_aln - $s_aln;
                $q_dis  = $row['quarantined'];
                $r_dis  = $row['rejected'];
                $s_dis  = ($q_dis || $r_dis) ? "{$q_dis}+{$r_dis}" : '0';
                $res[] = "  <tr><td {$style}\">{$name}</td>" .
                         "<td {$style}{$d4s}\">{$row['reports']}</td><td {$style}{$d4s}\">{$total}</td>" .
                         "<td {$style}{$d4s}\">{$s_aln}</td><td {$style}{$d4s}\">{$d_aln}</td>" .
                         "<td {$style}{$d4s}{$get_color('red', $n_aln)}\">{$n_aln}</td>" .
                         "<td {$style}{$d4s}{$get_color('red', $q_dis + $r_dis)}\">{$s_dis}</td>" .
                         "<td {$style}{$d4s}\">" . Common::num2percent($q_dis + $r_dis, $total, false) .
                         '</td></tr>';
            }
            unset($row);
            $res[] = ' </tbody>';
            $res[] = '</table>';
        }

        return $res;
    }

    /**
     * Returns the report data in CSV format
     *
     * @return string
     */
    public function csv(): string
    {
        $rdata = $this->reportData();

        $res = [];
        $res[] = 'Domain: ' . $this->domain->fqdn();

        $res[] = 'Range: ' . $rdata['range'];
        $res[] = '';

        $res[] = 'Summary';
        $total = $rdata['summary']['total'];
        $res[] = sprintf('Total: %d', $total);
        $res[] = sprintf('Fully aligned: %s', Common::num2percent($rdata['summary']['f_aligned'], $total, true));
        $res[] = sprintf('Partial aligned: %s', Common::num2percent($rdata['summary']['p_aligned'], $total, true));
        $res[] = sprintf('Not aligned: %s', Common::num2percent($rdata['summary']['n_aligned'], $total, true));
        $res[] = sprintf('Quarantined: %s', Common::num2percent($rdata['summary']['quarantined'], $total, true));
        $res[] = sprintf('Rejected: %s', Common::num2percent($rdata['summary']['rejected'], $total, true));
        $res[] = '';

        if (count($rdata['sources']) > 0) {
            $res[] = 'Sources';
            $res[] = [ '', 'Total', 'SPF only', 'DKIM only', 'Not aligned', 'Quar+Rej' ];
            foreach ($rdata['sources'] as &$it) {
                $total = $it['emails'];
                $f_aln = $it['dkim_spf_aligned'];
                $d_aln = $it['dkim_aligned'];
                $s_aln = $it['spf_aligned'];
                $n_aln = $total - $f_aln - $d_aln - $s_aln;
                $q_dis = $it['quarantined'];
                $r_dis = $it['rejected'];
                if ($q_dis || $r_dis) {
                    $s_dis = Common::num2percent($q_dis + $r_dis, $total, false) . "({$q_dis}+{$r_dis})";
                } else {
                    $s_dis = '0';
                }
                $res[] = [ $it['ip'], $total, $s_aln, $d_aln, Common::num2percent($n_aln, $total, true), $s_dis ];
            }
            unset($it);
            $res[] = '';
        }

        if (count($rdata['organizations']) > 0) {
            $res[] = 'Organizations';
            $res[] = [ '', 'Reports', 'Emails', 'SPF only', 'DKIM only', 'Not aligned', 'Quar+Rej' ];
            foreach ($rdata['organizations'] as &$it) {
                $total = $it['emails'];
                $f_aln = $it['dkim_spf_aligned'];
                $d_aln = $it['dkim_aligned'];
                $s_aln = $it['spf_aligned'];
                $n_aln = $total - $f_aln - $d_aln - $s_aln;
                $q_dis = $it['quarantined'];
                $r_dis = $it['rejected'];
                if ($q_dis || $r_dis) {
                    $s_dis = Common::num2percent($q_dis + $r_dis, $total, false) . "({$q_dis}+{$r_dis})";
                } else {
                    $s_dis = '0';
                }
                $res[] = [
                    trim($it['name']), $it['reports'], $total, $s_aln, $d_aln,
                    Common::num2percent($n_aln, $total, true), $s_dis
                ];
            }
            unset($it);
            $res[] = '';
        }
        return Common::arrayToCSV($res);
    }

    /**
     * Caches and returns statistics for the specified section
     *
     * @param string $section Section name
     *
     * @return array
     */
    private function getData(string $section): array
    {
        if (!$this->domain) {
            throw new LogicException('No one domain was specified');
        }

        $instance = $this->stat['instance'] ?? null;
        if (!$instance) {
            switch ($this->period) {
                case self::LAST_WEEK:
                    $instance = Statistics::lastWeek($this->domain, $this->offset);
                    break;
                case self::LAST_MONTH:
                    $instance = Statistics::lastMonth($this->domain, $this->offset);
                    break;
                case self::DATE_RANGE:
                    $instance = Statistics::fromTo($this->domain, $this->range[0], $this->range[1]);
                    break;
                default:
                    $instance = Statistics::lastNDays($this->domain, $this->period, $this->offset);
                    break;
            }
            $this->stat['instance'] = $instance;
            foreach ($this->overall as $o) {
                if ($o instanceof OverallReport) {
                    $o->appendData(
                        array_merge([ 'fqdn'  => $this->domain->fqdn() ], $this->getData('summary')['emails'])
                    );
                }
            }
        }
        switch ($section) {
            case 'range':
            case 'summary':
            case 'ips':
            case 'organizations':
                $res = $this->stat[$section] ?? null;
                break;
            default:
                throw new LogicException('Unknown section name');
        }
        if (!$res) {
            $res = $instance->$section();
            $this->stat[$section] = &$res;
        }
        return $res;
    }

    /**
     * Returns prepared data for the report
     *
     * @return array
     */
    private function reportData(): array
    {
        $rdata = [];
        $rdata['range'] = Common::rangeToString($this->getData('range'));

        $summ      = $this->getData('summary');
        $total     = $summ['emails']['total'];
        $f_aligned = $summ['emails']['dkim_spf_aligned'];
        $p_aligned = $summ['emails']['dkim_aligned'] + $summ['emails']['spf_aligned'];
        $n_aligned = $total - $f_aligned - $p_aligned;
        $rdata['summary'] = [
            'total'         => $total,
            'organizations' => $summ['organizations']
        ];

        $rdata['summary']['f_aligned']   = $f_aligned;
        $rdata['summary']['p_aligned']   = $p_aligned;
        $rdata['summary']['n_aligned']   = $n_aligned;
        $rdata['summary']['rejected']    = $summ['emails']['rejected'];
        $rdata['summary']['quarantined'] = $summ['emails']['quarantined'];

        $rdata['sources'] = $this->getData('ips');

        $rdata['organizations'] = $this->getData('organizations');

        return $rdata;
    }
}

Zerion Mini Shell 1.0