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; } }