Files

139 lines
6.0 KiB
PHP

<?php
declare(strict_types=1);
namespace Box\Mod\Mailgateway;
use FOSSBilling\InformationException;
use Pimple\Container;
class Poller
{
private const LOCK = 'fossbilling_mailgateway_poll';
public function __construct(
private Container $di,
private Service $service,
private ?\Closure $factory = null,
private ?\Closure $clock = null,
private ?\Closure $lock = null,
) {}
public function poll(bool $force = false): array
{
if (!$this->locking(true)) {
return ['status' => 'busy', 'mailboxes' => []];
}
try {
$store = $this->di['mailgateway_settings'] ?? new Settings($this->di);
$mailboxes = array_filter($store->getAll(), fn ($settings) => $settings['enabled']);
if ($mailboxes === []) {
return ['status' => 'disabled', 'mailboxes' => []];
}
$system = $this->di['mod_service']('system');
$now = $this->now();
$last = (int) $system->getParamValue('mailgateway_last_poll', 0);
if (!$force && $last > $now - RuntimeConfig::get('poll_interval_seconds')) {
return ['status' => 'not_due', 'mailboxes' => []];
}
// Record the attempt before network work, so a failing server is not hammered.
$system->setParamValue('mailgateway_last_poll', (string) $now, true);
$results = [];
$deadline = microtime(true) + RuntimeConfig::get('poll_budget_seconds');
foreach ($mailboxes as $id => $settings) {
if (microtime(true) >= $deadline) {
break;
}
$results[$id] = $this->mailbox($id, $settings, $deadline);
}
return ['status' => 'completed', 'mailboxes' => $results];
} finally {
$this->locking(false);
}
}
private function mailbox(string $id, array $settings, float $deadline): array
{
$counts = ['ticket' => 0, 'reply' => 0, 'ignored' => 0, 'duplicate' => 0, 'failed' => 0, 'retry_pending' => 0, 'move_pending' => 0];
$transport = null;
$errors = [];
$stage = 'connecting';
try {
$transport = $this->factory !== null ? ($this->factory)($settings) : new ImapMailbox($settings);
$transport->connect();
$stage = 'listing messages';
foreach ($transport->uids(RuntimeConfig::get('messages_per_mailbox')) as $uid) {
if (microtime(true) >= $deadline) {
break;
}
try {
$stage = 'reading email';
$data = $transport->read((int) $uid);
$stage = 'importing email';
$result = (new MessageTracker($this->di['dbal']))->process($data, $this->service);
$counts[$result['duplicate'] ? 'duplicate' : $result['action']]++;
$destination = $result['action'] === 'ignored' ? 'ignored_folder' : 'processed_folder';
} catch (InformationException $error) {
$errors[] = $this->diagnostic($error, $stage, (int) $uid);
$counts['failed']++;
$destination = 'failed_folder';
$this->warning($id, (int) $uid, 'message rejected; moving to Failed');
} catch (\Throwable $error) {
$errors[] = $this->diagnostic($error, $stage, (int) $uid);
// Infrastructure/fetch failures stay in the inbox for a later attempt.
$counts['retry_pending']++;
$this->warning($id, (int) $uid, 'import unavailable; left in inbox for retry');
continue;
}
try {
$stage = 'moving email';
$transport->move((int) $uid, $destination);
} catch (\Throwable $error) {
$errors[] = $this->diagnostic($error, $stage, (int) $uid);
// Import already committed. Tracking prevents a second ticket on retry.
$counts['move_pending']++;
$this->warning($id, (int) $uid, 'folder move failed; retry will use message tracking');
}
}
return ['status' => 'completed'] + $counts + ($errors === [] ? [] : ['errors' => $errors]);
} catch (\Throwable $error) {
$errors[] = $this->diagnostic($error, $stage, null);
$this->warning($id, null, 'mailbox poll failed; check credentials, TLS, folders, network access, and server MOVE support');
return ['status' => 'failed'] + $counts + ['errors' => $errors];
} finally {
if ($transport !== null) {
try { $transport->disconnect(); } catch (\Throwable) {
$this->warning($id, null, 'IMAP logout failed');
}
}
}
}
private function diagnostic(\Throwable $error, string $stage, ?int $uid): array
{
// A location identifies code faults without exposing exception messages/arguments.
return ['uid' => $uid, 'stage' => $stage, 'exception' => $error::class,
'file' => basename($error->getFile()), 'line' => $error->getLine()];
}
private function warning(string $id, ?int $uid, string $text): void
{
// Never log IMAP exception messages/traces, credentials, sender, subject or body.
$this->di['logger']->warning('Mail Gateway mailbox ' . $id . ($uid !== null ? ' UID ' . $uid : '') . ': ' . $text);
}
private function now(): int
{
return $this->clock !== null ? ($this->clock)() : time();
}
private function locking(bool $acquire): bool
{
if ($this->lock !== null) {
return ($this->lock)($acquire);
}
$sql = $acquire ? 'SELECT GET_LOCK(?, 0)' : 'SELECT RELEASE_LOCK(?)';
return (int) $this->di['dbal']->fetchOne($sql, [self::LOCK]) === 1;
}
}