#!/usr/bin/env php
<?php
/* ============================================================
   alpha-maillog — what the SMTP log says about OUR outbound mail, as JSON.

     alpha-maillog summary <days>

   Reads /var/log/mail.log (+ rotated .1 and .N.gz) for Postfix delivery
   attempts in the window and reports: how many were accepted (250), deferred
   (421/450/451…), permanently failed (550–553…), how many of those carried a
   reputation-related text (blocklists, policy, "spam"), how many were rate
   limiting — each broken down by mailbox provider (gmail / outlook / yahoo /
   icloud / other / local / test = RFC 2606 QA addresses), plus the top rejection texts, a masked sample of
   the problem lines, the inbound anvil rate hits and the current queue.

   Only root can read the log, hence a helper; the panel calls it via sudo.
   Addresses are masked before anything leaves this process (the DOMAIN is kept
   — it is the provider; the local part is not needed). Self-contained on
   purpose: a root helper must not execute code www-data can edit.
   ALPHA_MAILLOG_FILES (colon-separated) overrides the file list — for tests.
   ============================================================ */
$cmd  = $argv[1] ?? '';
$days = (int) ($argv[2] ?? 0);
if ($cmd !== 'summary' || $days < 1 || $days > 90) { fwrite(STDERR, "usage: alpha-maillog summary <days 1-90>\n"); exit(2); }
$since = time() - $days * 86400;
$files = getenv('ALPHA_MAILLOG_FILES') ? explode(':', getenv('ALPHA_MAILLOG_FILES')) : array_merge(['/var/log/mail.log', '/var/log/mail.log.1'], glob('/var/log/mail.log.*.gz') ?: []);

function bucket($domain) {
  $d = strtolower($domain);
  if (preg_match('/^(gmail|googlemail)\.com$/', $d)) return 'gmail';
  if (preg_match('/^(outlook|hotmail|live|msn|windowslive)\.[a-z.]{2,}$/', $d)) return 'outlook';
  if (preg_match('/^(yahoo|ymail|rocketmail)\.[a-z.]{2,}$/', $d)) return 'yahoo';
  if (preg_match('/^(icloud|me|mac)\.com$/', $d)) return 'icloud';
  // RFC 2606 / 6761 reserved names never reach a person: QA sends land here instead of inflating "other" bounces
  if (preg_match('/(^|\.)example\.(com|net|org)$|\.(example|test|invalid|localhost)$/', $d)) return 'test';
  return 'other';
}
function mask($s) { return preg_replace('/<[^@>\s]+@/', '<***@', preg_replace('/\b[A-Za-z0-9._%+-]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/', '***@$1', (string) $s)); }
function norm($text) {
  // one reason per *kind* of message: drop queue ids, hashes, addresses, numbers that vary per message
  $t = mask($text);
  $t = preg_replace('/^host \S+ said:\s*/i', '', $t);          // which MX answered varies; what it said is the reason
  $t = preg_replace('/queued as [^\s)]+/i', 'queued', $t);
  $t = preg_replace('/\b[0-9A-F]{8,}\b/', '…', $t);
  $t = preg_replace('/<[^>]*>/', '<…>', $t);
  $t = preg_replace('/\s+/', ' ', trim($t));
  return mb_substr($t, 0, 160);
}
$REP = '/spamhaus|block ?list|blacklist|\blisted\b|reputation|5\.7\.1|unsolicited|\bspam\b|\bbulk\b|policy|not authenticated|dmarc|\bspf\b|dkim|sender address rejected|banned|abuse|unauthenticated/i';
$RATE = '/rate ?limit|\brate\b|too many|throttl|4\.7\.0|exceed|quota|\bbusy\b|try again later|temporarily deferred|slow down|4\.7\.28|4\.7\.[0-9]+ .*(frequency|volume)/i';

$re = '/^(?<ts>\d{4}-\d\d-\d\dT[\d:.]+(?:[+-]\d\d:?\d\d|Z)|[A-Z][a-z]{2}\s+\d{1,2}\s+[\d:]{8})\s+\S+\s+postfix\/(?<prog>smtp|lmtp|local|virtual|error|pipe)\[\d+\]:\s+(?<qid>[0-9A-Fa-f]+):\s+to=<(?<to>[^>]*)>,(?:\s+orig_to=<[^>]*>,)?\s+relay=(?<relay>[^,]+),.*?dsn=(?<dsn>[\d.]+),\s+status=(?<status>\w+)\s+\((?<text>.*)\)\s*$/';
$anvilRe = '/^(?<ts>\S+)\s+\S+\s+postfix\/anvil\[\d+\]:\s+statistics: max connection rate/';

$providers = ['gmail', 'outlook', 'yahoo', 'icloud', 'other', 'local', 'test'];
$blank = ['sent' => 0, 'deferred' => 0, 'bounced' => 0, 'reputation' => 0, 'ratelimit' => 0];
$by = []; foreach ($providers as $p) $by[$p] = $blank;
$totals = $blank + ['other' => 0, 'attempts' => 0];
$codes = []; $reasons = []; $samples = []; $anvil = 0; $used = []; $relayHosts = [];

foreach ($files as $f) {
  if (!is_file($f)) continue;
  $gz = substr($f, -3) === '.gz';
  $h = $gz ? @gzopen($f, 'r') : @fopen($f, 'r');
  if (!$h) continue;
  $used[] = basename($f); $n = 0;
  while (($line = $gz ? gzgets($h, 8192) : fgets($h, 8192)) !== false) {
    if (strpos($line, 'postfix/') === false) continue;
    if (preg_match($anvilRe, $line, $am)) { $t = strtotime($am['ts']); if ($t !== false && $t >= $since) $anvil++; continue; }
    if (!preg_match($re, $line, $m)) continue;
    $t = strtotime($m['ts']); if ($t === false) continue;
    if ($t < $since) continue;
    $n++;
    $local = in_array($m['prog'], ['lmtp', 'local', 'virtual', 'pipe'], true) || stripos($m['relay'], 'dovecot') !== false;
    $dom = substr(strrchr($m['to'], '@') ?: '@', 1);
    $p = $local ? 'local' : bucket($dom);
    if (!$local && preg_match('/^([^\[]+)/', $m['relay'], $rm)) $relayHosts[trim($rm[1])] = ($relayHosts[trim($rm[1])] ?? 0) + 1;
    $st = $m['status']; $text = $m['text'];
    // the reply code: "250 2.0.0 OK", or "host mx[ip] said: 550 5.1.1 <…>" — the first 2xx/4xx/5xx followed by an enhanced status
    $code = preg_match('/\b([245]\d{2})[ -](?=\d\.\d)/', $text, $cm) ? $cm[1]
          : (preg_match('/^(\d{3})\b/', $text, $cm) ? $cm[1] : ($st === 'sent' ? '250' : ($st === 'deferred' ? '4xx' : ($st === 'bounced' ? '5xx' : '?'))));
    $totals['attempts']++;
    if (isset($totals[$st])) $totals[$st]++; else $totals['other']++;
    if (isset($by[$p][$st])) $by[$p][$st]++;
    $codes[$code] = ($codes[$code] ?? 0) + 1;
    if ($st !== 'sent') {
      $isRep = (bool) preg_match($REP, $text); $isRate = (bool) preg_match($RATE, $text);
      if ($isRep)  { $totals['reputation']++; $by[$p]['reputation']++; }
      if ($isRate) { $totals['ratelimit']++;  $by[$p]['ratelimit']++; }
      $k = norm($text);
      if (!isset($reasons[$k])) $reasons[$k] = ['text' => $k, 'n' => 0, 'status' => $st, 'code' => $code, 'reputation' => $isRep, 'ratelimit' => $isRate];
      $reasons[$k]['n']++;
      $samples[] = ['ts' => $t, 'prog' => $m['prog'], 'provider' => $p, 'domain' => $local ? 'local' : $dom, 'status' => $st, 'code' => $code,
                    'reputation' => $isRep, 'ratelimit' => $isRate, 'text' => mb_substr(mask($text), 0, 220)];
    }
  }
  $gz ? gzclose($h) : fclose($h);
}
usort($reasons, fn($a, $b) => $b['n'] <=> $a['n']);
usort($samples, fn($a, $b) => $b['ts'] <=> $a['ts']);
ksort($codes);
arsort($relayHosts);

/* the queue right now — what has not left yet */
$q = ['text' => '', 'requests' => 0, 'deferred_dir' => 0];
$qp = is_executable('/usr/sbin/postqueue') ? trim((string) @shell_exec('/usr/sbin/postqueue -p 2>/dev/null | tail -1')) : '';   // absent off the mail host (tests)
if ($qp !== '') { $q['text'] = $qp; if (preg_match('/in (\d+) Requests?/', $qp, $qm)) $q['requests'] = (int) $qm[1]; }
$dd = @glob('/var/spool/postfix/deferred/*/*'); $q['deferred_dir'] = is_array($dd) ? count($dd) : 0;
$relay = is_executable('/usr/sbin/postconf') ? trim((string) @shell_exec('/usr/sbin/postconf -h relayhost 2>/dev/null')) : '';

echo json_encode([
  'ok' => true, 'days' => $days, 'since' => $since, 'until' => time(), 'files' => $used,
  'relayhost' => $relay, 'relays_seen' => array_slice($relayHosts, 0, 5, true),
  'totals' => $totals,
  'classes' => [
    'accepted' => $totals['sent'], 'deferred' => $totals['deferred'], 'failed' => $totals['bounced'],
    'reputation' => $totals['reputation'], 'ratelimit' => $totals['ratelimit'],
  ],
  'codes' => $codes, 'providers' => $by,
  'reasons' => array_slice(array_values($reasons), 0, 12),
  'samples' => array_slice($samples, 0, 25),
  'anvil_rate_hits' => $anvil, 'queue' => $q,
], JSON_UNESCAPED_SLASHES), "\n";
