139 lines
6.0 KiB
PHP
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;
|
|
}
|
|
}
|