Implement IMAP email-to-ticket gateway
This commit is contained in:
1 parent
b7e5884457
commit
6f25869345
33 files changed
+5619
-5
No files matched your search
+138
@@ -0,0 +1,138 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user