From 6f25869345d102bd7acd743a1de10dc225f0b27d Mon Sep 17 00:00:00 2001 From: Seb Molenaar Date: Sat, 10 Oct 2026 16:22:56 +0100 Subject: [PATCH] Implement IMAP email-to-ticket gateway --- .gitignore | 1 - Api/Admin.php | 73 +- DependencyInstaller.php | 111 ++ ImapConnectionTest.php | 112 ++ ImapMailbox.php | 147 ++ InboundPolicy.php | 101 + MessageTracker.php | 146 ++ NotificationCooldown.php | 47 + NotificationEmailProxy.php | 30 + NotificationSettings.php | 76 + Poller.php | 138 ++ README.md | 190 ++ RuntimeConfig.php | 44 + Service.php | 402 ++++ Settings.php | 267 +++ TemplateInstaller.php | 30 + composer.json | 5 + composer.lock | 1704 +++++++++++++++++ config.php | 18 + schema.sql | 20 + .../admin/mod_mailgateway_settings.html.twig | 192 ++ .../mailgateway_sender_mismatch.html.twig | 89 + tests/automatic.php | 82 + tests/config.php | 6 + tests/cooldown.php | 115 ++ tests/polling.php | 346 ++++ tests/replies.php | 152 ++ tests/settings.php | 488 +++++ tests/tracking.php | 194 ++ tests/validation.php | 192 ++ tools/poll.php | 23 + tools/setup.php | 12 + tools/test-imap.php | 71 + 33 files changed, 5619 insertions(+), 5 deletions(-) create mode 100644 DependencyInstaller.php create mode 100644 ImapConnectionTest.php create mode 100644 ImapMailbox.php create mode 100644 InboundPolicy.php create mode 100644 MessageTracker.php create mode 100644 NotificationCooldown.php create mode 100644 NotificationEmailProxy.php create mode 100644 NotificationSettings.php create mode 100644 Poller.php create mode 100644 README.md create mode 100644 RuntimeConfig.php create mode 100644 Service.php create mode 100644 Settings.php create mode 100644 TemplateInstaller.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config.php create mode 100644 schema.sql create mode 100644 templates/admin/mod_mailgateway_settings.html.twig create mode 100644 templates/email/mailgateway_sender_mismatch.html.twig create mode 100644 tests/automatic.php create mode 100644 tests/config.php create mode 100644 tests/cooldown.php create mode 100644 tests/polling.php create mode 100644 tests/replies.php create mode 100644 tests/settings.php create mode 100644 tests/tracking.php create mode 100644 tests/validation.php create mode 100644 tools/poll.php create mode 100644 tools/setup.php create mode 100644 tools/test-imap.php diff --git a/.gitignore b/.gitignore index 55e0bc4..32df578 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,4 @@ .env .env.* /vendor/ -/composer.lock *.log diff --git a/Api/Admin.php b/Api/Admin.php index d7420a1..07b00fe 100644 --- a/Api/Admin.php +++ b/Api/Admin.php @@ -6,12 +6,77 @@ namespace Box\Mod\Mailgateway\Api; class Admin extends \FOSSBilling\Api\AbstractApi { - public function ping(array $data): array + public function settings_get(array $data): array { + if (!$this->getIdentity() instanceof \Model_Admin) { + throw new \FOSSBilling\InformationException('Administrator authentication required'); + } + $this->checkPermissions('mailgateway', 'manage_settings'); return [ - 'status' => 'ok', - 'module' => 'mailgateway', - 'version' => '0.1.0', + 'settings' => $this->getService()->getSettings(), + 'dependencies' => $this->getService()->getDependencyStatus(), + 'notifications' => $this->getService()->getNotificationStatus(), + 'mailboxes' => $this->getService()->getMailboxes(), + 'helpdesks' => $this->getDi()['mod_service']('support')->getHelpdeskRepository()->getPairs(), ]; } + + public function settings_save(array $data): bool + { + if (!$this->getIdentity() instanceof \Model_Admin) { + throw new \FOSSBilling\InformationException('Administrator authentication required'); + } + $this->checkPermissions('mailgateway', 'manage_settings'); + return $this->getService()->saveSettings($data); + } + + public function test_connection(array $data): array + { + if (!$this->getIdentity() instanceof \Model_Admin) { + throw new \FOSSBilling\InformationException('Administrator authentication required'); + } + $this->checkPermissions('mailgateway', 'manage_settings'); + return $this->getService()->testConnection($data); + } + + public function install_dependencies(array $data): array + { + if (!$this->getIdentity() instanceof \Model_Admin) { + throw new \FOSSBilling\InformationException('Administrator authentication required'); + } + $this->checkPermissions('mailgateway', 'manage_settings'); + $force = $data['force'] ?? false; + if (!in_array($force, [true, false, 0, 1, '0', '1'], true)) { + throw new \FOSSBilling\InformationException('Invalid dependency installation option'); + } + return $this->getService()->installDependencies(in_array($force, [true, 1, '1'], true)); + } + + public function setup(array $data): bool + { + if (!$this->getIdentity() instanceof \Model_Admin) { + throw new \FOSSBilling\InformationException('Administrator authentication required'); + } + $this->checkPermissions('mailgateway', 'manage_settings'); + return $this->getService()->setup(); + } + + public function notifications_setup(array $data): array + { + if (!$this->getIdentity() instanceof \Model_Admin) { + throw new \FOSSBilling\InformationException('Administrator authentication required'); + } + $this->checkPermissions('mailgateway', 'manage_settings'); + return $this->getService()->configureNotifications(); + } + + public function mailbox_delete(array $data): bool + { + if (!$this->getIdentity() instanceof \Model_Admin) { + throw new \FOSSBilling\InformationException('Administrator authentication required'); + } + $this->checkPermissions('mailgateway', 'manage_settings'); + return $this->getService()->deleteMailbox($data); + } + } diff --git a/DependencyInstaller.php b/DependencyInstaller.php new file mode 100644 index 0000000..f2e811e --- /dev/null +++ b/DependencyInstaller.php @@ -0,0 +1,111 @@ + $this->installed(), 'available' => function_exists('proc_open') + && $this->php() !== null && $this->composer() !== null && is_writable(__DIR__), + 'request_timeout_ms' => (RuntimeConfig::get('composer_timeout_seconds') + 30) * 1000]; + } + + public function install(bool $force = false): array + { + if (!$force && $this->installed()) { + return ['installed' => true]; + } + $php = $this->php(); + $composer = $this->composer(); + if (!function_exists('proc_open') || $php === null || $composer === null || !is_writable(__DIR__)) { + throw new InformationException('Web installation is unavailable. Run composer install from the Mailgateway directory using SSH.'); + } + if (!is_file(__DIR__ . '/composer.lock')) { + throw new InformationException('The module composer.lock is missing. Restore it before installing dependencies.'); + } + $command = [$php, $composer, 'install', '--no-dev', '--prefer-dist', '--no-interaction', + '--no-progress', '--no-scripts', '--no-plugins', '--optimize-autoloader']; + $result = $this->run($command); + if ($result['timeout']) { + throw new InformationException('Dependency installation exceeded its configured timeout. Run composer install from the Mailgateway directory using SSH.'); + } + if ($result['exit'] !== 0 || !$this->installed()) { + throw new InformationException('Composer installation failed. Run composer install from the Mailgateway directory using SSH to see the detailed error.'); + } + return ['installed' => true]; + } + + private function installed(): bool + { + return is_file(__DIR__ . '/vendor/autoload.php') + && is_file(__DIR__ . '/vendor/webklex/php-imap/src/ClientManager.php'); + } + + private function php(): ?string + { + foreach (RuntimeConfig::get('php_cli_paths') as $path) { + if (is_file($path) && is_executable($path)) { + return $path; + } + } + return null; + } + + private function composer(): ?string + { + foreach (RuntimeConfig::get('composer_paths') as $path) { + if (is_file($path) && is_readable($path)) { + return $path; + } + } + return null; + } + + protected function run(array $command): array + { + // PHP may terminate a web request before Composer completes otherwise. + @set_time_limit(RuntimeConfig::get('composer_timeout_seconds') + 15); + $process = proc_open($command, [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $pipes, __DIR__); + if (!is_resource($process)) { + throw new InformationException('Composer could not be started. Run composer install using SSH.'); + } + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + $deadline = microtime(true) + RuntimeConfig::get('composer_timeout_seconds'); + $exit = -1; + $timeout = false; + try { + while (true) { + // Drain both pipes to avoid deadlock. Never expose Composer output: + // repository authentication and environment details can appear there. + stream_get_contents($pipes[1]); + stream_get_contents($pipes[2]); + $status = proc_get_status($process); + if (!$status['running']) { + $exit = $status['exitcode']; + break; + } + if (microtime(true) >= $deadline) { + $timeout = true; + proc_terminate($process, 9); + break; + } + usleep(100000); + } + } finally { + fclose($pipes[1]); + fclose($pipes[2]); + $closed = proc_close($process); + if ($exit === -1) { + $exit = $closed; + } + } + return ['exit' => $exit, 'timeout' => $timeout]; + } +} diff --git a/ImapConnectionTest.php b/ImapConnectionTest.php new file mode 100644 index 0000000..46ce93c --- /dev/null +++ b/ImapConnectionTest.php @@ -0,0 +1,112 @@ +factory === null) { + if (!is_file(__DIR__ . '/vendor/autoload.php')) { + throw new InformationException('Install the Mail Gateway Composer dependencies first'); + } + require_once __DIR__ . '/vendor/autoload.php'; + } + $config = [ + 'host' => $settings['host'], 'port' => (int) $settings['port'], + 'protocol' => 'imap', 'encryption' => $settings['encryption'], + 'validate_cert' => true, 'username' => $settings['username'], + 'password' => $settings['password'], 'authentication' => null, + 'timeout' => RuntimeConfig::get('imap_timeout_seconds'), + ]; + $client = $this->factory !== null ? ($this->factory)($config) + : (new \Webklex\PHPIMAP\ClientManager(['options' => ['debug' => false]]))->make($config); + $client->connect(); + $connected = true; + try { + $capabilities = $client->getConnection()->getCapabilities()->validatedData(); + $moveSupported = in_array('MOVE', array_map('strtoupper', $capabilities), true); + if (!$moveSupported) { + $errors[] = 'IMAP login succeeded, but the server does not support native MOVE. Importing requires MOVE to archive individual messages safely.'; + } + } catch (\Throwable) { + $errors[] = 'IMAP login succeeded, but checking server MOVE support failed. Check server access and try again.'; + } + $paths = $this->paths($client); + foreach (['inbox_folder', 'processed_folder', 'ignored_folder', 'failed_folder'] as $field) { + $path = $settings[$field]; + $exists = $this->exists($path, $paths); + $created = false; + $error = null; + if (!$exists && $field === 'inbox_folder') { + $error = 'The configured inbox does not exist. Correct its path or create it on the mail server.'; + } elseif (!$exists && $createFolders) { + try { + // Never expunge messages while creating a destination folder. + $client->createFolder($path, false); + } catch (\Throwable) { + $error = 'Folder creation failed. Check the folder path, parent folders, and mailbox permissions or quota.'; + } + if ($error === null) { + try { + $paths = $this->paths($client); + $exists = $this->exists($path, $paths); + $created = $exists; + if (!$exists) { + $error = 'The server accepted folder creation, but the folder could not be found afterwards.'; + } + } catch (\Throwable) { + $error = 'The folder creation request completed, but listing folders to verify it failed. Check server access and try again.'; + } + } + } elseif (!$exists) { + $error = 'The configured destination folder does not exist.'; + } + $folders[] = ['path' => $path, 'exists' => $exists, 'created' => $created, 'error' => $error]; + } + } catch (\Throwable) { + // Never return IMAP exception messages or traces (may contain credentials). + $errors[] = $connected + ? 'IMAP login succeeded, but listing folders failed. Check mailbox permissions and server access.' + : 'IMAP connection or login failed. Check the saved host, port, security, username, password, TLS certificate, and network access.'; + } finally { + if ($client !== null) { + try { $client->disconnect(); } catch (\Throwable) {} + } + } + $success = $connected && $errors === [] && count($folders) === 4 + && array_filter($folders, fn ($folder) => !$folder['exists'] || $folder['error'] !== null) === []; + return ['success' => $success, 'connected' => $connected, 'move_supported' => $moveSupported, 'folders' => $folders, 'errors' => $errors]; + } + + private function paths(object $client): array + { + $paths = []; + foreach ($client->getFolders(false) as $folder) { + $paths[] = $folder->path; + $paths[] = $folder->full_name ?? $folder->path; + } + return $paths; + } + + private function exists(string $path, array $paths): bool + { + return in_array($path, $paths, true) + || (strcasecmp($path, 'INBOX') === 0 && in_array('INBOX', array_map('strtoupper', $paths), true)); + } +} diff --git a/ImapMailbox.php b/ImapMailbox.php new file mode 100644 index 0000000..4114e29 --- /dev/null +++ b/ImapMailbox.php @@ -0,0 +1,147 @@ +settings['password'] === '') { + throw new InformationException('Mailbox password is missing'); + } + if ($this->factory === null) { + if (!is_file(__DIR__ . '/vendor/autoload.php')) { + throw new InformationException('Mail Gateway Composer dependencies are missing'); + } + require_once __DIR__ . '/vendor/autoload.php'; + } + $config = [ + 'host' => $this->settings['host'], 'port' => (int) $this->settings['port'], + 'protocol' => 'imap', 'encryption' => $this->settings['encryption'], + 'validate_cert' => true, 'username' => $this->settings['username'], + 'password' => $this->settings['password'], 'authentication' => null, 'timeout' => RuntimeConfig::get('imap_timeout_seconds'), + ]; + $this->client = $this->factory !== null ? ($this->factory)($config) + : (new \Webklex\PHPIMAP\ClientManager(['options' => ['debug' => false]]))->make($config); + $this->client->connect(); + $capabilities = $this->client->getConnection()->getCapabilities()->validatedData(); + if (!in_array('MOVE', array_map('strtoupper', $capabilities), true)) { + throw new InformationException('IMAP server must support MOVE'); + } + foreach (['inbox_folder', 'processed_folder', 'ignored_folder', 'failed_folder'] as $field) { + $folder = $this->client->getFolderByPath($this->settings[$field]); + if ($folder === null || $folder->no_select) { + throw new InformationException('A configured IMAP folder is missing or cannot be selected'); + } + $this->folders[$field] = $folder; + } + $this->inbox = $this->folders['inbox_folder']; + } + + public function uids(int $limit): array + { + $query = $this->inbox->query()->leaveUnread()->setSequence(IMAP::ST_UID)->whereUndeleted(); + $uids = array_map('intval', $query->search()->toArray()); + sort($uids, SORT_NUMERIC); + return array_slice($uids, 0, $limit); + } + + public function read(int $uid): array + { + $message = $this->inbox->query()->leaveUnread()->fetchBody(false)->setFetchFlags(false)->getMessageByUid($uid); + $header = $message->getHeader(); + $headers = self::rawHeaders($header->raw); + $policy = new InboundPolicy(); + $ignored = $policy->ignoreReason($policy->headers(['headers' => $headers])) !== null; + if (!$ignored) { + if ((int) $message->getSize() > RuntimeConfig::get('max_message_bytes')) { + throw new InformationException('Email exceeds the configured import size limit'); + } + // Webklex parseBody() fetches RFC822.TEXT then restores Seen flags. + // Fetch with true PEEK instead: even a crash cannot mark mail as read. + $bodies = $this->client->getConnection()->fetch(['BODY.PEEK[TEXT]', 'UID'], [$uid], null, IMAP::ST_UID)->validatedData(); + $body = $bodies[$uid]['BODY[TEXT]'] ?? null; + if (!is_string($body)) { + throw new \RuntimeException('IMAP body was not returned'); + } + $message->parseRawBody($body); + } + return self::data($message, $this->settings['mailbox'], $headers, $ignored); + } + + /** Keep repeated values and the literal null Return-Path lost by address parsers. */ + public static function rawHeaders(string $raw): array + { + $raw = preg_replace('/\r?\n[ \t]+/', ' ', $raw); + $headers = []; + foreach (preg_split('/\r?\n/', $raw) as $line) { + if (preg_match('/^([A-Za-z0-9-]+):[ \t]*(.*)$/D', $line, $match)) { + $headers[strtolower($match[1])][] = trim($match[2]); + } + } + return $headers; + } + + public static function data(object $message, string $mailbox, array $headers, bool $ignored): array + { + $ids = $headers['message-id'] ?? []; + if (count($ids) > 1) { + throw new InformationException('Email has multiple Message-ID headers'); + } + // A stable MIME fingerprint also deduplicates messages without a Message-ID. + $id = $ids[0] ?? '<' . hash('sha256', $message->getHeader()->raw . ($ignored ? '' : $message->getRawBody())) . '@mailgateway.invalid>'; + $data = ['mailbox' => $mailbox, 'message_id' => $id, 'headers' => $headers]; + if ($ignored) { + return $data; + } + $senders = $message->getFrom()->toArray(); + if (count($headers['from'] ?? []) !== 1 || count($senders) !== 1) { + throw new InformationException('Email must have exactly one From address'); + } + $sender = $senders[0]; + $content = trim($message->getTextBody()); + if ($content === '') { + $html = preg_replace('/<(script|style)\b[^>]*>.*?<\/\1\s*>/is', '', $message->getHTMLBody()); + $html = preg_replace('/|<\/(?:p|div|li|tr|h[1-6])\s*>/i', "\n", $html); + $content = trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + } + $subject = trim(mb_decode_mimeheader((string) $message->getSubject())); + return $data + [ + 'sender_email' => $sender->mail, + 'sender_name' => trim(mb_decode_mimeheader($sender->personal)) ?: $sender->mail, + 'subject' => $subject === '' ? '(No subject)' : $subject, + 'content' => $content, + ]; + } + + public function move(int $uid, string $destination): void + { + $this->client->openFolder($this->inbox->path); + $protocol = $this->client->getConnection(); + // Native UID MOVE touches only this message. No COPY/STORE/EXPUNGE fallback. + $response = $protocol->requestAndResponse('UID MOVE', [(string) $uid, + $protocol->escapeString($this->folders[$destination]->path)], true); + if (!$response->boolean()) { + throw new \RuntimeException('IMAP MOVE failed'); + } + } + + public function disconnect(): void + { + if ($this->client !== null) { + $this->client->disconnect(); + } + } +} diff --git a/InboundPolicy.php b/InboundPolicy.php new file mode 100644 index 0000000..69f2ca7 --- /dev/null +++ b/InboundPolicy.php @@ -0,0 +1,101 @@ + $values) { + if (!is_string($name)) { + throw new InformationException('Header names must be strings'); + } + $name = strtolower(str_replace('_', '-', $name)); + if (!in_array($name, self::FIELDS, true)) { + continue; + } + $values = is_array($values) ? $values : [$values]; + if (count($values) > 20) { + throw new InformationException('Too many repeated header values'); + } + foreach ($values as $value) { + if (!is_string($value) || strlen($value) > 16384) { + throw new InformationException('Header values must be strings of at most 16384 bytes'); + } + $value = preg_replace('/\r?\n[ \t]+/', ' ', $value); + if (preg_match('/[\x00-\x08\x0A-\x1F\x7F]/', $value)) { + throw new InformationException('Invalid control character in header value'); + } + $headers[$name][] = strtolower(trim($value)); + } + } + ksort($headers); + + return $headers; + } + + public function ignoreReason(array $headers): ?string + { + foreach ($headers['auto-submitted'] ?? [] as $value) { + if (trim(explode(';', $value, 2)[0]) !== 'no') { + return 'auto_submitted'; + } + } + foreach ($headers['return-path'] ?? [] as $value) { + if (preg_match('/^<\s*>$/D', $value)) { + return 'null_return_path'; + } + } + foreach ($headers['content-type'] ?? [] as $value) { + if (preg_match('/^multipart\/report\s*(?:;|$)/', $value) + || preg_match('/^message\/(?:delivery-status|disposition-notification|global-delivery-status)\s*(?:;|$)/', $value)) { + return 'delivery_report'; + } + } + foreach ($headers['precedence'] ?? [] as $value) { + if (in_array(trim(explode(';', $value, 2)[0]), ['bulk', 'list', 'junk'], true)) { + return 'bulk_mail'; + } + } + foreach ($headers['list-id'] ?? [] as $value) { + if ($value !== '') { + return 'mailing_list'; + } + } + foreach ($headers['x-auto-response-suppress'] ?? [] as $value) { + if (preg_match('/(?:^|[\s,;])(?:all|autoreply|oof|dr|ndr|rn|nrn)(?:$|[\s,;])/', $value)) { + return 'response_suppressed'; + } + } + foreach (['x-autoreply', 'x-autorespond'] as $field) { + foreach ($headers[$field] ?? [] as $value) { + if (!in_array($value, ['', 'no', 'false', '0'], true)) { + return 'legacy_autoresponder'; + } + } + } + + return null; + } +} diff --git a/MessageTracker.php b/MessageTracker.php new file mode 100644 index 0000000..21fa447 --- /dev/null +++ b/MessageTracker.php @@ -0,0 +1,146 @@ +mailbox($data); + $messageId = $this->messageId($data['message_id'] ?? null); + $policy = new InboundPolicy(); + $headers = $policy->headers($data); + $ignoreReason = $policy->ignoreReason($headers); + $ticketId = $ignoreReason === null ? $this->subjectTicketId($data['subject'] ?? null) : null; + // Hash only accepted inputs, independent of request field order. + $payload = []; + foreach (['sender_email', 'sender_name', 'subject', 'content'] as $field) { + $value = $data[$field] ?? null; + if ($value !== null && !is_string($value)) { + throw new InformationException($field . ' must be a string'); + } + $payload[$field] = $value; + } + $payload['parents'] = []; // Preserve fingerprints of earlier new-ticket imports. + if ($headers !== []) { + $payload['headers'] = $headers; + } + $hash = hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR)); + $key = $this->key($mailbox, $messageId); + $this->assertStandaloneTransaction(); + $this->connection->beginTransaction(); + try { + $this->connection->insert('mailgateway_message', [ + 'message_key' => $key, 'mailbox' => $mailbox, 'message_id' => $messageId, + 'payload_hash' => $hash, + ]); + } catch (UniqueConstraintViolationException) { + $this->connection->rollBack(); + $row = $this->connection->fetchAssociative('SELECT * FROM mailgateway_message WHERE message_key = ?', [$key]); + if (!$row || $row['payload_hash'] !== $hash || !$row['result_json']) { + throw new InformationException('Message-ID is already registered with different contents'); + } + return array_merge(json_decode($row['result_json'], true, 512, JSON_THROW_ON_ERROR), ['duplicate' => true]); + } catch (\Throwable $error) { + $this->connection->rollBack(); + throw $error; + } + + try { + if ($ignoreReason !== null) { + $result = ['action' => 'ignored', 'reason' => $ignoreReason]; + } elseif ($ticketId !== null) { + $data['ticket_id'] = $ticketId; + $result = $service->reply($data); + $result['action'] = 'reply'; + } else { + $result = $service->import($data); + $result['action'] = 'ticket'; + } + $result['duplicate'] = false; + $this->connection->update('mailgateway_message', [ + 'ticket_id' => $result['ticket_id'] ?? null, + 'result_json' => json_encode($result, JSON_THROW_ON_ERROR), + ], ['message_key' => $key]); + $this->connection->commit(); + return $result; + } catch (\Throwable $error) { + if ($this->connection->isTransactionActive()) { + $this->connection->rollBack(); + } + // Drop entities whose writes were rolled back, including queued mail. + $service->getDi()['em']->clear(); + // Queue rejection notifications only after the import has rolled back. + if ($error instanceof InformationException && in_array($error->getMessage(), [ + 'Sender does not own this ticket', 'Sender does not uniquely match the ticket owner', + ], true)) { + $service->notifySenderMismatch($data['sender_email'], (int) $data['ticket_id']); + } + throw $error; + } + } + + private function subjectTicketId(mixed $subject): ?int + { + if (!is_string($subject) || trim($subject) === '' || strlen($subject) > 998 + || !mb_check_encoding($subject, 'UTF-8') || preg_match('/[\x00-\x1F\x7F]/', $subject)) { + throw new InformationException('A non-empty UTF-8 subject without control characters is required (maximum 998 bytes)'); + } + preg_match_all('/\[Ticket\b[^\]]*\]/i', $subject, $matches); + if (preg_match('/\[Ticket\b/i', str_replace($matches[0], '', $subject))) { + throw new InformationException('Incomplete ticket reference in subject'); + } + $ids = []; + foreach ($matches[0] as $reference) { + if (!preg_match('/^\[Ticket #([1-9][0-9]*)\]$/iD', $reference, $match) + || filter_var($match[1], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) === false) { + throw new InformationException('Invalid ticket reference in subject; expected [Ticket #123]'); + } + $ids[(int) $match[1]] = true; + } + if (count($ids) > 1) { + throw new InformationException('Subject refers to multiple tickets'); + } + + return $ids ? array_key_first($ids) : null; + } + + private function assertStandaloneTransaction(): void + { + if ($this->connection->isTransactionActive()) { + throw new \LogicException('Tracked import requires its own transaction'); + } + } + + private function mailbox(array $data): string + { + $mailbox = $data['mailbox'] ?? null; + if (!is_string($mailbox) || strlen($mailbox) > 255 || !filter_var($mailbox, FILTER_VALIDATE_EMAIL)) { + throw new InformationException('A valid mailbox address is required'); + } + return strtolower($mailbox); + } + + private function messageId(mixed $value): string + { + if (!is_string($value) || strlen($value) > 998 + || !preg_match('/^<[^<>\s@]+@[^<>\s@]+>$/D', $value) + || preg_match('/[^\x21-\x7E]/', $value)) { + throw new InformationException('Message-ID must be a single ASCII value'); + } + return $value; + } + + private function key(string $mailbox, string $messageId): string + { + return hash('sha256', $mailbox . "\0" . $messageId); + } +} diff --git a/NotificationCooldown.php b/NotificationCooldown.php new file mode 100644 index 0000000..d04ac69 --- /dev/null +++ b/NotificationCooldown.php @@ -0,0 +1,47 @@ +seconds = $seconds ?? RuntimeConfig::get('notification_cooldown_seconds'); + } + + /** Atomically reserve a sender's notification slot and queue the email. */ + public function run(string $sender, callable $queue): bool + { + $key = hash('sha256', strtolower(trim($sender))); + $now = time(); + + return $this->connection->transactional(function () use ($key, $now, $queue): bool { + try { + $this->connection->insert('mailgateway_notification', ['sender_key' => $key, 'last_notified_at' => 0]); + } catch (UniqueConstraintViolationException) { + // Existing sender; the conditional update decides who owns the slot. + } + $claimed = $this->connection->executeStatement( + 'UPDATE mailgateway_notification SET last_notified_at = ? WHERE sender_key = ? AND last_notified_at <= ?', + [$now, $key, $now - $this->seconds], + ); + if ($claimed !== 1) { + return false; + } + if ($queue() === false) { + // Disabled templates do not consume the sender's notification slot. + $this->connection->delete('mailgateway_notification', ['sender_key' => $key]); + return false; + } + + return true; + }); + } +} diff --git a/NotificationEmailProxy.php b/NotificationEmailProxy.php new file mode 100644 index 0000000..4cd9526 --- /dev/null +++ b/NotificationEmailProxy.php @@ -0,0 +1,30 @@ +acknowledge)($data); + } + // The canned reply remains in the ticket. Send the ticket-number + // acknowledgement instead of a second automatic canned-reply email. + if (($data['code'] ?? '') === 'mod_support_ticket_staff_reply') { + return false; + } + + return $this->email->sendTemplate($data); + } + + public function __call(string $method, array $arguments): mixed + { + return $this->email->$method(...$arguments); + } +} diff --git a/NotificationSettings.php b/NotificationSettings.php new file mode 100644 index 0000000..962bca4 --- /dev/null +++ b/NotificationSettings.php @@ -0,0 +1,76 @@ +di['mod_service']('email'); + foreach (self::CODES as $code) { + $template = $email->getTemplateRepository()->findOneByActionCode($code); + if ($template === null) { + $source = file_get_contents(dirname(__DIR__) . '/Support/templates/email/' . $code . '.html.twig'); + if (!is_string($source) + || !preg_match('/{%\s*block subject\s*%}(.*?){%\s*endblock\s*%}/s', $source, $subject) + || !preg_match('/{%\s*block content\s*%}(.*?){%\s*endblock\s*%}/s', $source, $content)) { + throw new \FOSSBilling\InformationException("Cannot configure $code: its default template is missing or invalid."); + } + $template = $email->templateCreate($code, trim($subject[1]), trim($content[1]), + $code === 'mod_support_ticket_open' ? 1 : 0, 'support'); + } + $data = $email->templateToApiArray($template); + if ($code !== 'mod_support_ticket_open' && !$data['enabled']) { + continue; + } + $subject = $data['subject'] ?? ''; + $enabled = $code === 'mod_support_ticket_open' ? true : null; + if (!$this->hasMarker($subject)) { + // Keep the existing subject and body, adding only the routing marker. + $email->updateTemplate($template, $enabled, null, '[Ticket #{{ ticket.id }}] ' . $subject); + } elseif ($code === 'mod_support_ticket_open' && !$data['enabled']) { + $email->updateTemplate($template, true); + } + } + return $this->status(); + } + + public function status(): array + { + $email = $this->di['mod_service']('email'); + $errors = []; + foreach (self::CODES as $code) { + $template = $email->getTemplateRepository()->findOneByActionCode($code); + if ($template === null) { + $errors[] = "Open FOSSBilling Email settings and configure $code before enabling importing."; + continue; + } + $data = $email->templateToApiArray($template); + if (!$data['enabled']) { + if ($code === 'mod_support_ticket_open') { + $errors[] = 'Enable mod_support_ticket_open in FOSSBilling Email settings to send ticket acknowledgements.'; + } + continue; + } + // Check the documented dynamic marker without executing custom template code. + if (!$this->hasMarker($data['subject'] ?? '')) { + $errors[] = "Include [Ticket #{{ ticket.id }}] in the subject of $code in FOSSBilling Email settings."; + } + } + return ['ready' => $errors === [], 'errors' => $errors]; + } +} diff --git a/Poller.php b/Poller.php new file mode 100644 index 0000000..107c0c4 --- /dev/null +++ b/Poller.php @@ -0,0 +1,138 @@ +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; + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..4bb9c8f --- /dev/null +++ b/README.md @@ -0,0 +1,190 @@ +# Mail Gateway for FOSSBilling + +Mail Gateway imports email into FOSSBilling support tickets using the existing +FOSSBilling cron. Configure multiple IMAP mailboxes and route each one to a +separate helpdesk. + +## Features + +- Create customer tickets from recognised email addresses and guest tickets from unknown senders. +- Add replies to existing tickets using the ticket number in the subject. +- Check that the sender owns the ticket before accepting a reply. +- Filter recognised automatic messages and prevent duplicate imports. +- Move handled emails into Processed, Ignored or Failed folders. +- Manage mailbox connections and test them from the admin settings page. + +## Requirements + +- FOSSBilling with the Support module enabled and its cron running regularly. +- An IMAP server supporting native `MOVE` and a valid TLS certificate when using TLS. +- A receiving mail server with appropriate email authentication and anti-spoofing policies. +- The `webklex/php-imap` Composer package and its required PHP extensions. + +The native PHP IMAP extension is not required. Composer checks dependency +requirements when installing. For installation through the settings page, the +web server also needs access to PHP CLI, Composer, `proc_open`, a writable module +directory and package downloads. If web installation is unavailable or the module +directory is deliberately not writable by the web server, installation over SSH +is the preferred fallback. Run this from the module directory as its owning user: + +```bash +composer install --no-dev --prefer-dist --no-interaction --no-scripts --no-plugins --optimize-autoloader +``` + +This uses the supplied lock file. The web server still needs read access to the +installed dependencies; it does not need write access to them for importing. + +## Installation + +1. Place the module in `modules/Mailgateway`. +2. Activate Mail Gateway in FOSSBilling. Activation creates the module tables, + registers its sender-mismatch email template and attempts to install dependencies. +3. Open Mail Gateway's settings from the Extensions page. If dependencies were + not installed automatically, use **Install dependencies** or run + `composer install` in the module directory. +4. Configure the receiving email address, helpdesk, IMAP connection details and + inbox and destination folders, then save. +5. Select **Test Connection and Create Folders**. This verifies the connection + and creates missing destination folders without processing emails. +6. After a successful test, enable mailbox importing and save again. This also + configures ticket acknowledgement and notification subjects automatically. + +**Notification template changes persist:** enabling importing or running +**Configure ticket notifications** enables the ticket acknowledgement and adds +missing ticket references to existing FOSSBilling notification subjects. These +changes apply to those templates throughout FOSSBilling. Disabling or uninstalling +Mail Gateway does not restore their original subjects or enabled state. If you +want to restore them later, keep a copy of the original settings and edit the +templates manually in FOSSBilling's Email settings. + +Use **Add mailbox** to configure additional addresses. Each mailbox has its own +connection details, folders and helpdesk. All four folder paths must be distinct; +the inbox must already exist. + +Importing remains disabled until dependencies are installed and the mailbox has +passed its connection and folder test. Changing connection details or folder +paths requires another successful test. The settings page shows the results and +explains any failures. + +No additional cron entry is needed. The module polls every five minutes by +default, provided the FOSSBilling cron runs that often. + +## Tickets and replies + +Email from a customer's primary email address creates a customer ticket. +Unknown senders create guest tickets when guest tickets are enabled in Support. +New tickets go to the mailbox's selected helpdesk. + +Replies are matched by a subject marker such as `[Ticket #123]`. The sender must +own the referenced ticket. Emails without a ticket marker create a new ticket; +reply headers are not used for matching. + +Sender matching uses the email's From address; it does not prove that the sender +controls that address. Mail Gateway does not independently verify SPF, DKIM or +DMARC, and relies on the receiving mail server to apply authentication and +anti-spoofing policies before mail reaches the imported inbox. Configure those +policies to reject or quarantine suspicious messages outside that inbox. +A forged message accepted into the inbox could impersonate a customer or the +owner of a guest ticket. Guest reply ownership is based on the original sender's +email address, without an authenticated customer account. + +Enabling mailbox importing automatically enables the `mod_support_ticket_open` +acknowledgement and adds `[Ticket #{{ ticket.id }}]` to its subject and enabled +staff notification subjects where missing. Existing wording is preserved and +disabled optional staff notifications stay disabled. Notifications are delivered +through FOSSBilling's email queue. + +For an existing mailbox, use **Advanced → Configure ticket notifications** to run +the same setup. Notification status is shown in Advanced, with any problems also +displayed at the top of the page. Setup does not send test emails. You can edit +notification wording in FOSSBilling's Email settings; keep the ticket marker in +subjects so replies can be matched. + +The included `mailgateway_sender_mismatch` template uses your company name and +email signature to tell senders when their address does not match the referenced +ticket. It is installed only if missing. Its wording can be edited or the +notification disabled in Email settings. Existing custom templates are preserved. + +Messages use plain text when available, with HTML converted to text otherwise. +Quoted reply history is retained. Attachments stay in the archived email and are +not attached to tickets. + +## Email handling + +| Result | Destination | +| --- | --- | +| Imported ticket, reply or duplicate | Processed | +| Recognised automatic message | Ignored | +| Invalid message or rejected reply | Failed | +| Temporary connection or database failure | Inbox, for retry | + +Only the configured inbox is imported, including already-read messages. +Duplicate tracking prevents repeated imports, including when a folder move needs +to be retried. To retry a rejected email after resolving its cause, move it from +Failed back to the inbox. + +Recognised automatic messages receive no response. Unmarked automatic messages +cannot always be identified; a notification cooldown limits automated +acknowledgements and sender-mismatch notices to one per sender per hour by default. + +## Configuration and maintenance + +Mailbox settings are stored in the database, with passwords encrypted using +FOSSBilling's encryption helper. Leaving a password blank keeps the saved value; +use the password-clearing option to remove it. + +Operational settings are in `config.php`, including polling limits, timeouts, +message size limits, notification cooldown and executable paths. Edit this file +over SSH; changes take effect on the next cron run or web request. By default, +each run handles up to 50 messages per mailbox, with a shared 90-second time +budget. Complete emails are limited to 5 MiB and ticket bodies to 65,535 bytes. + +The settings page's **Advanced** section provides notification setup, Composer +installation and table setup. Table setup creates missing tables and the default +mismatch template without deleting existing data or replacing customised templates. + +Deleting a mailbox removes its configuration while keeping emails, folders, +tickets and message tracking. + +The following commands are available from the module directory: + +```bash +# Test a saved mailbox without processing emails. +php tools/test-imap.php + +# Test another mailbox and create missing destination folders. +php tools/test-imap.php --mailbox=billing@example.com --create-folders + +# Import mail immediately. +php tools/poll.php --force + +# Create missing tables and register the default email template. +php tools/setup.php +``` + +A connection test with `--create-folders` records the mailbox's readiness, just as +the settings-page test does. A read-only test does not change readiness. + +## Compatibility note + +The module loads its own Composer dependencies into the same PHP process as +FOSSBilling. Shared Symfony or Illuminate packages are not isolated by the module's +separate `vendor` directory. Check installation, connection testing and importing +on your FOSSBilling version before deploying; compatibility across clean +FOSSBilling installations and versions has not yet been established. + +During imported ticket creation, the module temporarily wraps FOSSBilling's +`mod_service` factory to intercept email calls. This applies the acknowledgement +cooldown and suppresses the separate canned-response email while retaining its +ticket history. Other module lookups are delegated to the original factory, +which is restored in a `finally` block, including when creation fails. + +Ticket-creation hooks also see this wrapped factory: email services obtained +during that scope are proxies, and matching notification codes are intercepted. +The installed FOSSBilling notification interfaces provide no per-ticket +suppression option. This integration should be reviewed when upgrading +FOSSBilling or adding hooks that send emails during ticket creation. + +## License + +Apache-2.0. diff --git a/RuntimeConfig.php b/RuntimeConfig.php new file mode 100644 index 0000000..2b1094e --- /dev/null +++ b/RuntimeConfig.php @@ -0,0 +1,44 @@ + 65535) { + throw new \RuntimeException('Mail Gateway ticket body limit cannot exceed 65535 bytes'); + } + foreach (['php_cli_paths', 'composer_paths'] as $key) { + if (!isset($settings[$key]) || !is_array($settings[$key]) || $settings[$key] === []) { + throw new \RuntimeException('Mail Gateway config.php requires executable paths for ' . $key); + } + foreach ($settings[$key] as $value) { + if (!is_string($value) || !str_starts_with($value, '/') || str_contains($value, "\0")) { + throw new \RuntimeException('Mail Gateway executable paths must be absolute paths'); + } + } + } + return $settings; + } +} diff --git a/Service.php b/Service.php new file mode 100644 index 0000000..048d45d --- /dev/null +++ b/Service.php @@ -0,0 +1,402 @@ +di = $di; + } + + public function getDi(): ?Container + { + return $this->di; + } + + public function getModulePermissions(): array + { + return ['manage_settings' => [ + 'type' => 'bool', 'display_name' => 'Manage Mail Gateway settings', + 'description' => 'Configure the inbound mailbox and ticket routing.', + ]]; + } + + public function getSettings(): array + { + return $this->settingsStore()->getPublic(); + } + + public function getMailboxes(): array + { + return $this->settingsStore()->getAllPublic(); + } + + public function getDependencyStatus(): array + { + return ($this->di['mailgateway_dependency_installer'] ?? new DependencyInstaller())->status(); + } + + public function installDependencies(bool $force = false): array + { + // Share the poller's lock so imports cannot run while vendor is being written. + if ((int) $this->di['dbal']->fetchOne('SELECT GET_LOCK(?, 0)', ['fossbilling_mailgateway_poll']) !== 1) { + throw new InformationException('Mail Gateway is busy. Try installing dependencies again after polling finishes.'); + } + try { + return ($this->di['mailgateway_dependency_installer'] ?? new DependencyInstaller())->install($force); + } finally { + $this->di['dbal']->fetchOne('SELECT RELEASE_LOCK(?)', ['fossbilling_mailgateway_poll']); + } + } + + public function testConnection(array $data): array + { + $id = $data['mailbox_id'] ?? 'default'; + if (!is_string($id)) { + throw new \FOSSBilling\InformationException('Mailbox not found'); + } + $store = $this->settingsStore(); + $settings = $store->get($id); + $result = ($this->di['mailgateway_connection_test'] ?? new ImapConnectionTest())->test($settings, true); + $store->recordTest($id, $settings, $result); + return $result; + } + + public function saveSettings(array $data): bool + { + if (in_array($data['enabled'] ?? false, [true, 1, '1'], true)) { + $status = $this->configureNotifications(); + if (!$status['ready']) { + throw new InformationException(implode(' ', $status['errors'])); + } + } + return $this->settingsStore()->save($data); + } + + public function getNotificationStatus(): array + { + return (new NotificationSettings($this->di))->status(); + } + + public function configureNotifications(): array + { + return (new NotificationSettings($this->di))->repair(); + } + + public function deleteMailbox(array $data): bool + { + $id = $data['mailbox_id'] ?? null; + if (!is_string($id) || $id === '') { + throw new InformationException('Mailbox ID is required'); + } + // Prevent a removed mailbox from being imported by an already-running poll. + if ((int) $this->di['dbal']->fetchOne('SELECT GET_LOCK(?, 0)', ['fossbilling_mailgateway_poll']) !== 1) { + throw new InformationException('Mail Gateway is busy. Try deleting the mailbox after polling finishes.'); + } + try { + return $this->settingsStore()->delete($id); + } finally { + $this->di['dbal']->fetchOne('SELECT RELEASE_LOCK(?)', ['fossbilling_mailgateway_poll']); + } + } + + private function settingsStore(): object + { + return $this->di['mailgateway_settings'] ?? new Settings($this->di); + } + + public function poll(bool $force = false): array + { + return (new Poller($this->di, $this))->poll($force); + } + + public static function onBeforeAdminCronRun(\Box_Event $event): void + { + $di = $event->getDi(); + try { + $result = $di['mod_service']('mailgateway')->poll(); + if ($result['status'] === 'completed') { + $di['logger']->info('Mail Gateway polling: ' . json_encode($result, JSON_THROW_ON_ERROR)); + } + } catch (\Throwable) { + // A mail gateway outage must not interrupt billing or the outgoing queue. + $di['logger']->warning('Mail Gateway polling unavailable; FOSSBilling cron will continue'); + } + } + + private function setupTables(): void + { + foreach (explode(';', file_get_contents(__DIR__ . '/schema.sql')) as $statement) { + if (trim($statement) !== '') { + $this->di['dbal']->executeStatement($statement); + } + } + } + + private function setupTemplates(): void + { + ($this->di['mailgateway_template_installer'] ?? new TemplateInstaller($this->di))->install(); + } + + public function setup(): bool + { + if ((int) $this->di['dbal']->fetchOne('SELECT GET_LOCK(?, 0)', ['fossbilling_mailgateway_poll']) !== 1) { + throw new InformationException('Mail Gateway is busy. Try setup again after polling finishes.'); + } + try { + $this->setupTables(); + $this->setupTemplates(); + return true; + } finally { + $this->di['dbal']->fetchOne('SELECT RELEASE_LOCK(?)', ['fossbilling_mailgateway_poll']); + } + } + + public function install(): bool + { + $this->setupTables(); + $this->setupTemplates(); + + try { + $this->installDependencies(); + } catch (\Throwable) { + // Keep the settings page reachable if the host cannot install Composer packages. + // Dependency status there provides the install button or SSH instructions. + $this->di['logger']->warning('Mail Gateway tables are ready, but dependencies could not be installed automatically. Open Mail Gateway settings to finish installation.'); + } + + return true; + } + + /** Create a customer or guest ticket from a decoded inbound message. */ + public function import(array $data): array + { + $policy = new InboundPolicy(); + $reason = $policy->ignoreReason($policy->headers($data)); + if ($reason !== null) { + return ['action' => 'ignored', 'reason' => $reason]; + } + $sender = $this->senderEmail($data); + + $subject = $this->requiredString($data, 'subject'); + if (!mb_check_encoding($subject, 'UTF-8') || mb_strlen($subject, 'UTF-8') > 255 + || preg_match('/[\x00-\x1F\x7F]/', $subject)) { + throw new InformationException('Subject must be valid UTF-8, at most 255 characters, with no control characters'); + } + + $content = $this->messageContent($data); + + $senderName = isset($data['sender_name']) ? $this->requiredString($data, 'sender_name') : $sender; + if (!mb_check_encoding($senderName, 'UTF-8') || mb_strlen($senderName, 'UTF-8') > 255 + || preg_match('/[\x00-\x1F\x7F]/', $senderName)) { + throw new InformationException('Sender name must be valid UTF-8, at most 255 characters, with no control characters'); + } + + // Exact mailbox matching, case-insensitive; never use LIKE or choose the first match. + $clients = $this->di['dbal']->fetchFirstColumn( + 'SELECT id FROM client WHERE LOWER(email) = LOWER(:email) LIMIT 2', + ['email' => $sender], + ); + if (count($clients) > 1) { + throw new InformationException('Multiple customers match sender_email'); + } + + $support = $this->di['mod_service']('support'); + $store = $this->settingsStore(); + $settings = isset($data['mailbox']) && $store instanceof Settings + ? $store->forAddress($data['mailbox']) : $store->get(); + $helpdeskId = $settings['support_helpdesk_id']; + $helpdesks = $helpdeskId !== null + ? [$support->getHelpdeskRepository()->find((int) $helpdeskId)] + : $support->getHelpdeskRepository()->findBy(['name' => 'General'], null, 2); + if (count($helpdesks) !== 1 || !$helpdesks[0] instanceof Helpdesk) { + throw new InformationException('Choose an existing helpdesk in Mail Gateway settings, or configure exactly one General helpdesk'); + } + + if ($clients === []) { + // Guest creation sends its own acknowledgement without a client + // session. Keep its access hash private; return only the ticket ID. + $hash = $this->withNotificationCooldown($sender, fn () => $support->ticketCreateForGuest([ + 'name' => $senderName, + 'email' => $sender, + 'subject' => $subject, + 'content' => $content, + 'support_helpdesk_id' => $helpdesks[0]->getId(), + ])); + $ticket = $support->findOneByHash($hash); + + return ['ticket_id' => $ticket->getId(), 'client_id' => null, 'support_helpdesk_id' => $helpdesks[0]->getId()]; + } + + $client = $this->di['db']->getExistingModelById('Client', (int) $clients[0]); + if (!$client instanceof \Model_Client) { + throw new InformationException('Customer identity could not be loaded'); + } + + $id = $this->withNotificationCooldown($sender, fn () => $support->ticketCreateForClient($client, $helpdesks[0], [ + 'subject' => $subject, + 'content' => $content, + ])); + + // A canned acknowledgement counts as an admin reply in Support and + // puts the ticket on hold. Incoming mail still needs staff attention. + $ticket = $support->getTicketById($id); + if ($ticket->getStatus() === SupportTicket::STATUS_ONHOLD) { + $ticket->setStatus(SupportTicket::STATUS_OPEN); + $this->di['em']->flush(); + } + + // Support's normal notification hook requires a customer session. + // Cron imports have no customer session. + if (!$this->di['auth']->isClientLoggedIn()) { + $this->queueAutomaticTemplate($sender, [ + 'to_client' => (int) $client->id, + 'code' => 'mod_support_ticket_open', + 'ticket' => $support->toApiArray($ticket, true, $client), + ]); + } + + return ['ticket_id' => $id, 'client_id' => (int) $client->id, 'support_helpdesk_id' => $helpdesks[0]->getId()]; + } + + /** Reply to an explicitly identified ticket; never infer ownership from a subject. */ + public function reply(array $data): array + { + $policy = new InboundPolicy(); + $reason = $policy->ignoreReason($policy->headers($data)); + if ($reason !== null) { + return ['action' => 'ignored', 'reason' => $reason]; + } + $sender = $this->senderEmail($data); + $content = $this->messageContent($data); + $rawId = $data['ticket_id'] ?? null; + if ((!is_int($rawId) && !is_string($rawId)) + || filter_var($rawId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) === false) { + throw new InformationException('A positive integer ticket_id is required'); + } + + $support = $this->di['mod_service']('support'); + $ticket = $support->getTicketById((int) $rawId); + if ($ticket->isGuestTicket()) { + if (strcasecmp($sender, (string) $ticket->getAuthorEmail()) !== 0) { + throw new InformationException('Sender does not own this ticket'); + } + $identity = new \FOSSBilling\Identity\Guest(); + } else { + $ids = $this->di['dbal']->fetchFirstColumn( + 'SELECT id FROM client WHERE LOWER(email) = LOWER(:email) LIMIT 2', + ['email' => $sender], + ); + if (count($ids) !== 1 || (int) $ids[0] !== $ticket->getClientId()) { + throw new InformationException('Sender does not uniquely match the ticket owner'); + } + $identity = $this->di['db']->getExistingModelById('Client', (int) $ids[0]); + if (!$identity instanceof \Model_Client) { + throw new InformationException('Customer identity could not be loaded'); + } + } + + if (!$support->canBeReopened($ticket)) { + throw new InformationException('Ticket cannot be reopened'); + } + + $messageId = $support->ticketReply($ticket, $identity, $content); + + return ['ticket_id' => $ticket->getId(), 'message_id' => $messageId]; + } + + public function notifySenderMismatch(string $sender, int $ticketId): void + { + try { + $this->queueAutomaticTemplate($sender, [ + 'to' => $sender, + 'code' => 'mailgateway_sender_mismatch', + 'sender_email' => $sender, + // Only echo the reference supplied by the sender; do not pass + // the ticket's details, owner identity, or guest access hash. + 'ticket_id' => $ticketId, + ]); + } catch (\Exception) { + // Notification failure must never permit the rejected reply. + $this->di['logger']->warning('Mail Gateway could not queue the sender mismatch notification'); + } + } + + private function senderEmail(array $data): string + { + $sender = $this->requiredString($data, 'sender_email'); + if (strlen($sender) > 255 || !filter_var($sender, FILTER_VALIDATE_EMAIL)) { + throw new InformationException('A valid sender_email is required'); + } + + return $sender; + } + + private function queueAutomaticTemplate(string $sender, array $data): bool + { + // An injectable limiter permits isolated tests without a live database. + $limiter = $this->di['mailgateway_notification_cooldown'] ?? new NotificationCooldown($this->di['dbal']); + return $limiter->run($sender, fn () => $this->di['mod_service']('email')->sendTemplate($data)); + } + + private function withNotificationCooldown(string $sender, callable $create): mixed + { + $lookup = $this->di['mod_service']; + $proxy = null; + unset($this->di['mod_service']); + $this->di['mod_service'] = $this->di->protect(function ($module, $sub = '') use ($lookup, $sender, &$proxy) { + if (strtolower($module) !== 'email' || $sub !== '') { + return $lookup($module, $sub); + } + if ($proxy === null) { + $email = $lookup($module, $sub); + $proxy = new NotificationEmailProxy($email, function ($data) use ($sender, $email) { + $limiter = $this->di['mailgateway_notification_cooldown'] ?? new NotificationCooldown($this->di['dbal']); + return $limiter->run($sender, fn () => $email->sendTemplate($data)); + }); + } + return $proxy; + }); + try { + return $create(); + } finally { + unset($this->di['mod_service']); + $this->di['mod_service'] = $this->di->protect($lookup); + } + } + + private function messageContent(array $data): string + { + $content = $this->requiredString($data, 'content'); + // Support stores this in a TEXT column (maximum 65,535 bytes). + if (!mb_check_encoding($content, 'UTF-8') || strlen($content) > RuntimeConfig::get('max_ticket_body_bytes')) { + throw new InformationException('Content must be valid UTF-8 and within the configured ticket body limit'); + } + $content = Tools::sanitizeMarkdownContent($content); + if ($content === '') { + throw new InformationException('Content must not be empty'); + } + + return $content; + } + + private function requiredString(array $data, string $key): string + { + if (!isset($data[$key]) || !is_string($data[$key]) || trim($data[$key]) === '') { + throw new InformationException($key . ' must be a non-empty string'); + } + + return trim($data[$key]); + } +} diff --git a/Settings.php b/Settings.php new file mode 100644 index 0000000..060f17f --- /dev/null +++ b/Settings.php @@ -0,0 +1,267 @@ + false, 'host' => '', 'port' => 993, + 'encryption' => 'ssl', 'validate_cert' => true, + 'username' => '', 'password' => '', + 'mailbox' => '', 'inbox_folder' => 'INBOX', + 'processed_folder' => 'Processed', 'ignored_folder' => 'Ignored', + 'failed_folder' => 'Failed', 'support_helpdesk_id' => null, + 'tested_config_hash' => null, 'test_result' => null, + ]; + + public function __construct(private Container $di) {} + + private function all(): array + { + try { + $encrypted = $this->di['dbal']->fetchOne('SELECT config_encrypted FROM mailgateway_settings WHERE id = 1'); + } catch (\Doctrine\DBAL\Exception\TableNotFoundException) { + // Keep Advanced setup reachable if the module tables need creating. + return ['default' => self::DEFAULTS]; + } + if ($encrypted === false) { + return ['default' => self::DEFAULTS]; + } + $plain = $this->di['crypt']->decrypt($encrypted); + if (!is_string($plain)) { + throw new InformationException('Mail Gateway settings could not be decrypted'); + } + $decoded = json_decode($plain, true, 512, JSON_THROW_ON_ERROR); + $mailboxes = $decoded['mailboxes'] ?? ['default' => $decoded]; + return array_map(function ($settings) { + // Older configurations stored this unused operational value per mailbox. + unset($settings['poll_interval_minutes']); + $settings = array_replace(self::DEFAULTS, $settings); + if (!$this->verified($settings)) { + $settings['enabled'] = false; + } + return $settings; + }, $mailboxes); + } + + /** Internal polling configuration, including encrypted-at-rest credentials. */ + public function getAll(): array + { + return $this->all(); + } + + public function get(?string $id = null): array + { + $mailboxes = $this->all(); + if ($id === null) { + return $mailboxes['default'] ?? (reset($mailboxes) ?: self::DEFAULTS); + } + return $mailboxes[$id] ?? throw new InformationException('Mailbox not found'); + } + + public function getAllPublic(): array + { + return array_map(fn ($settings) => $this->publicSettings($settings), $this->all()); + } + + public function delete(string $id): bool + { + return $this->di['dbal']->transactional(function ($connection) use ($id): bool { + if ($connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) { + $connection->fetchOne('SELECT id FROM mailgateway_settings WHERE id = 1 FOR UPDATE'); + } + $mailboxes = $this->all(); + if (!isset($mailboxes[$id])) { + throw new InformationException('Mailbox not found'); + } + unset($mailboxes[$id]); + $encrypted = $this->di['crypt']->encrypt(json_encode(['mailboxes' => $mailboxes], JSON_THROW_ON_ERROR)); + $connection->update('mailgateway_settings', ['config_encrypted' => $encrypted], ['id' => 1]); + return true; + }); + } + + private function publicSettings(array $settings): array + { + $settings['password_configured'] = $settings['password'] !== ''; + $settings['test_passed'] = $this->verified($settings); + unset($settings['password'], $settings['tested_config_hash']); + return $settings; + } + + private function fingerprint(array $settings): string + { + $connection = array_intersect_key($settings, array_flip(['host', 'port', 'encryption', 'username', + 'password', 'mailbox', 'inbox_folder', 'processed_folder', 'ignored_folder', 'failed_folder'])); + ksort($connection); + return hash('sha256', json_encode($connection, JSON_THROW_ON_ERROR)); + } + + private function verified(array $settings): bool + { + return is_string($settings['tested_config_hash']) + && hash_equals($settings['tested_config_hash'], $this->fingerprint($settings)); + } + + public function recordTest(string $id, array $testedSettings, array $result): bool + { + return $this->di['dbal']->transactional(function ($connection) use ($id, $testedSettings, $result): bool { + if ($connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) { + $connection->fetchOne('SELECT id FROM mailgateway_settings WHERE id = 1 FOR UPDATE'); + } + $mailboxes = $this->all(); + if (!isset($mailboxes[$id]) || $this->fingerprint($mailboxes[$id]) !== $this->fingerprint($testedSettings)) { + throw new InformationException('Mailbox settings changed during the test. Save and test again.'); + } + $mailboxes[$id]['tested_config_hash'] = $result['success'] ? $this->fingerprint($testedSettings) : null; + $mailboxes[$id]['test_result'] = $result; + if (!$result['success']) { + $mailboxes[$id]['enabled'] = false; + } + $encrypted = $this->di['crypt']->encrypt(json_encode(['mailboxes' => $mailboxes], JSON_THROW_ON_ERROR)); + $connection->update('mailgateway_settings', ['config_encrypted' => $encrypted], ['id' => 1]); + return true; + }); + } + + public function forAddress(string $address): array + { + foreach ($this->all() as $settings) { + if (strcasecmp($settings['mailbox'], $address) === 0) { + return $settings; + } + } + throw new InformationException('Receiving mailbox is not configured'); + } + + public function getPublic(): array + { + return $this->publicSettings($this->get()); + } + + public function save(array $data): bool + { + return $this->di['dbal']->transactional(function ($connection) use ($data): bool { + // Lock before reading so concurrent saves cannot overwrite another mailbox. + if ($connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) { + $connection->fetchOne('SELECT id FROM mailgateway_settings WHERE id = 1 FOR UPDATE'); + } + return $this->saveMailbox($data); + }); + } + + private function saveMailbox(array $data): bool + { + $mailboxes = $this->all(); + $id = $data['mailbox_id'] ?? 'default'; + if (!is_string($id) || ($id !== 'new' && !isset($mailboxes[$id]))) { + throw new InformationException('Mailbox not found'); + } + $old = $id === 'new' ? self::DEFAULTS : $mailboxes[$id]; + $settings = self::DEFAULTS; + $settings['enabled'] = $this->boolean($data['enabled'] ?? false); + if ($settings['enabled'] && !($this->di['mailgateway_dependency_installer'] ?? new DependencyInstaller())->status()['installed']) { + throw new InformationException('Install Mail Gateway dependencies before enabling mailbox importing'); + } + foreach (['host', 'username', 'mailbox', 'inbox_folder', 'processed_folder', 'ignored_folder', 'failed_folder'] as $field) { + if (!isset($data[$field]) || !is_string($data[$field])) { + throw new InformationException($field . ' is required'); + } + $value = trim($data[$field]); + if ($value === '' || mb_strlen($value, 'UTF-8') > 255 || !mb_check_encoding($value, 'UTF-8') + || preg_match('/[\x00-\x1F\x7F]/', $value)) { + throw new InformationException($field . ' must be a non-empty value of at most 255 characters, without control characters'); + } + $settings[$field] = $value; + } + if (!filter_var($settings['host'], FILTER_VALIDATE_IP) + && !filter_var($settings['host'], FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) { + throw new InformationException('IMAP host must be a hostname or IP address'); + } + if (!filter_var($settings['mailbox'], FILTER_VALIDATE_EMAIL) || strlen($settings['mailbox']) > 255) { + throw new InformationException('A valid mailbox email address is required'); + } + $settings['mailbox'] = strtolower($settings['mailbox']); + $settings['port'] = $this->integer($data['port'] ?? null, 1, 65535, 'port'); + $settings['encryption'] = $data['encryption'] ?? null; + if (!in_array($settings['encryption'], ['ssl', 'tls'], true)) { + throw new InformationException('Choose implicit TLS or STARTTLS'); + } + $folders = array_map(fn ($key) => mb_strtolower($settings[$key], 'UTF-8'), + ['inbox_folder', 'processed_folder', 'ignored_folder', 'failed_folder']); + if (count(array_unique($folders)) !== 4) { + throw new InformationException('Inbox, processed, ignored, and failed folders must be different'); + } + $settings['support_helpdesk_id'] = $this->integer($data['support_helpdesk_id'] ?? null, 1, PHP_INT_MAX, 'support_helpdesk_id'); + if (!$this->di['mod_service']('support')->getHelpdeskRepository()->find($settings['support_helpdesk_id']) instanceof Helpdesk) { + throw new InformationException('Choose an existing helpdesk'); + } + $password = $data['password'] ?? ''; + if (!is_string($password) || strlen($password) > 4096 || str_contains($password, "\0")) { + throw new InformationException('Invalid mailbox password'); + } + $clear = $this->boolean($data['clear_password'] ?? false); + if ($clear && $password !== '') { + throw new InformationException('Choose either a new password or clear the saved password'); + } + $settings['password'] = $clear ? '' : ($password !== '' ? $password : $old['password']); + if ($settings['enabled'] && $settings['password'] === '') { + throw new InformationException('Save a mailbox password before enabling imports'); + } + $changed = $this->fingerprint($settings) !== $this->fingerprint($old); + $settings['tested_config_hash'] = $changed ? null : $old['tested_config_hash']; + $settings['test_result'] = $changed ? null : $old['test_result']; + if ($settings['enabled'] && !$this->verified($settings)) { + if ($changed && $this->verified($old)) { + // Saving changed connection details disables imports until retested. + $settings['enabled'] = false; + } else { + throw new InformationException('Save this mailbox and successfully run Test Connection and Create Folders before enabling importing'); + } + } + foreach ($mailboxes as $otherId => $other) { + if ($otherId !== $id && strcasecmp($other['mailbox'], $settings['mailbox']) === 0) { + throw new InformationException('This mailbox email address is already configured'); + } + } + if ($id === 'new') { + $id = bin2hex(random_bytes(16)); + } + $mailboxes[$id] = $settings; + $encrypted = $this->di['crypt']->encrypt(json_encode(['mailboxes' => $mailboxes], JSON_THROW_ON_ERROR)); + $this->di['dbal']->transactional(function ($connection) use ($encrypted): void { + // MySQL serializes concurrent settings saves on this singleton row. + try { + $connection->insert('mailgateway_settings', ['id' => 1, 'config_encrypted' => $encrypted]); + } catch (UniqueConstraintViolationException) { + // Existing singleton configuration; update under its row lock. + } + $connection->update('mailgateway_settings', ['config_encrypted' => $encrypted], ['id' => 1]); + }); + return true; + } + + private function integer(mixed $value, int $min, int $max, string $field): int + { + if ((!is_string($value) && !is_int($value)) + || filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => $min, 'max_range' => $max]]) === false) { + throw new InformationException($field . ' is invalid'); + } + return (int) $value; + } + + private function boolean(mixed $value): bool + { + if (!in_array($value, [true, false, 0, 1, '0', '1'], true)) { + throw new InformationException('Invalid checkbox value'); + } + return in_array($value, [true, 1, '1'], true); + } +} diff --git a/TemplateInstaller.php b/TemplateInstaller.php new file mode 100644 index 0000000..7aa9036 --- /dev/null +++ b/TemplateInstaller.php @@ -0,0 +1,30 @@ +di['mod_service']('email'); + // Preserve existing custom content and enabled/disabled preference. + if ($email->getTemplateRepository()->findOneByActionCode($code) !== null) { + return; + } + $source = file_get_contents(__DIR__ . '/templates/email/' . $code . '.html.twig'); + if (!is_string($source) + || !preg_match('/{%\s*block subject\s*%}(.*?){%\s*endblock\s*%}/s', $source, $subject) + || !preg_match('/{%\s*block content\s*%}(.*?){%\s*endblock\s*%}/s', $source, $content)) { + throw new \RuntimeException('Mail Gateway sender-mismatch template is missing or invalid'); + } + // This existing custom action code does not use the mod_* discovery convention. + $email->templateCreate($code, trim($subject[1]), trim($content[1]), 1, 'mailgateway'); + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8d56034 --- /dev/null +++ b/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "webklex/php-imap": "^6.2" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..298a59e --- /dev/null +++ b/composer.lock @@ -0,0 +1,1704 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "5cc550c87df98ff88db99d5cbe524f1c", + "packages": [ + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", + "reference": "5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2026-09-06T14:11:38+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "illuminate/collections", + "version": "v13.35.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/collections.git", + "reference": "6eebd20c5ec8958a782008aea1374e9600cf8135" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/collections/zipball/6eebd20c5ec8958a782008aea1374e9600cf8135", + "reference": "6eebd20c5ec8958a782008aea1374e9600cf8135", + "shasum": "" + }, + "require": { + "illuminate/conditionable": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/macroable": "^13.0", + "php": "^8.3", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36" + }, + "suggest": { + "illuminate/http": "Required to convert collections to API resources (^13.0).", + "symfony/var-dumper": "Required to use the dump method (^7.4 || ^8.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Collections package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-10-02T12:50:49+00:00" + }, + { + "name": "illuminate/conditionable", + "version": "v13.35.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/conditionable.git", + "reference": "7f1ef52d9a346f829421b296adfb7644a951b216" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/conditionable/zipball/7f1ef52d9a346f829421b296adfb7644a951b216", + "reference": "7f1ef52d9a346f829421b296adfb7644a951b216", + "shasum": "" + }, + "require": { + "php": "^8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Conditionable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-02-25T16:07:55+00:00" + }, + { + "name": "illuminate/contracts", + "version": "v13.35.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/contracts.git", + "reference": "0cabe75392aad93ac739bbb220a3f5d943b12b30" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/0cabe75392aad93ac739bbb220a3f5d943b12b30", + "reference": "0cabe75392aad93ac739bbb220a3f5d943b12b30", + "shasum": "" + }, + "require": { + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Contracts\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Contracts package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-09-25T12:59:28+00:00" + }, + { + "name": "illuminate/macroable", + "version": "v13.35.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/macroable.git", + "reference": "59b5b5f3cf290a91db8cf6cd3d35ff56978bc057" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/macroable/zipball/59b5b5f3cf290a91db8cf6cd3d35ff56978bc057", + "reference": "59b5b5f3cf290a91db8cf6cd3d35ff56978bc057", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Macroable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-04-29T09:35:06+00:00" + }, + { + "name": "illuminate/pagination", + "version": "v13.35.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/pagination.git", + "reference": "0847cb3408d2ac92b96793e6e308a90973cb7fd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/pagination/zipball/0847cb3408d2ac92b96793e6e308a90973cb7fd2", + "reference": "0847cb3408d2ac92b96793e6e308a90973cb7fd2", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "illuminate/collections": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/support": "^13.0", + "php": "^8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Pagination\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Pagination package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-08-02T20:48:08+00:00" + }, + { + "name": "illuminate/reflection", + "version": "v13.35.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/reflection.git", + "reference": "2b6ba33970dd84849322a6a9857647ca96c1bb66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/reflection/zipball/2b6ba33970dd84849322a6a9857647ca96c1bb66", + "reference": "2b6ba33970dd84849322a6a9857647ca96c1bb66", + "shasum": "" + }, + "require": { + "illuminate/collections": "^13.0", + "illuminate/contracts": "^13.0", + "php": "^8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Reflection package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-08-03T17:56:24+00:00" + }, + { + "name": "illuminate/support", + "version": "v13.35.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/support.git", + "reference": "ca230c018111767a6dd7cab7b157ef346801454a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/support/zipball/ca230c018111767a6dd7cab7b157ef346801454a", + "reference": "ca230c018111767a6dd7cab7b157ef346801454a", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^2.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-mbstring": "*", + "illuminate/collections": "^13.0", + "illuminate/conditionable": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/macroable": "^13.0", + "illuminate/reflection": "^13.0", + "nesbot/carbon": "^3.8.4", + "php": "^8.3", + "symfony/polyfill-php85": "^1.36", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "replace": { + "spatie/once": "*" + }, + "suggest": { + "illuminate/filesystem": "Required to use the Composer class (^13.0).", + "laravel/serializable-closure": "Required to use the once function (^2.0.10).", + "league/commonmark": "Required to use Str::markdown() and Stringable::markdown() (^2.7).", + "league/uri": "Required to use the Uri class (^7.5.1).", + "ramsey/uuid": "Required to use Str::uuid() (^4.7).", + "symfony/process": "Required to use the Composer class (^7.4 || ^8.0).", + "symfony/uid": "Required to use Str::ulid() (^7.4 || ^8.0).", + "symfony/var-dumper": "Required to use the dd function (^7.4 || ^8.0).", + "vlucas/phpdotenv": "Required to use the Env class and env helper (^5.6.1)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Support package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-10-05T15:51:39+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.14.2", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "94cfc34887f07ab5c0efa07914c8f527544e3224" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/94cfc34887f07ab5c0efa07914c8f527544e3224", + "reference": "94cfc34887f07ab5c0efa07914c8f527544e3224", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^3.0@dev", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-10-02T07:26:52+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v8.1.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "662f5257658d4f60deff1bc790e9f2c68cb391bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/662f5257658d4f60deff1bc790e9f2c68cb391bf", + "reference": "662f5257658d4f60deff1bc790e9f2c68cb391bf", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<4.3" + }, + "require-dev": { + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v8.1.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T07:13:53+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-24T15:19:04+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/22c6548499cbe5c5bb072fc0c42312dff062a015", + "reference": "22c6548499cbe5c5bb072fc0c42312dff062a015", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-23T22:01:09+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/86a42cbcc07adc331481a5ab310a4024bfc227f6", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00" + }, + { + "name": "symfony/polyfill-php86", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "8840df45b24f33a49dc419fa18dc0057231ae1d8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/8840df45b24f33a49dc419fa18dc0057231ae1d8", + "reference": "8840df45b24f33a49dc419fa18dc0057231ae1d8", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:46:47+00:00" + }, + { + "name": "symfony/translation", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/d9e1caba0d6b6f9a26710af8a2f88d37f001215a", + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + }, + { + "name": "webklex/php-imap", + "version": "6.2.0", + "source": { + "type": "git", + "url": "https://github.com/Webklex/php-imap.git", + "reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Webklex/php-imap/zipball/6b8ef85d621bbbaf52741b00cca8e9237e2b2e05", + "reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "ext-iconv": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-zip": "*", + "illuminate/pagination": ">=5.0.0", + "nesbot/carbon": "^2.62.1|^3.2.4", + "php": "^8.0.2", + "symfony/http-foundation": ">=2.8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5.10" + }, + "suggest": { + "symfony/mime": "Recomended for better extension support", + "symfony/var-dumper": "Usefull tool for debugging" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webklex\\PHPIMAP\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Malte Goldenbaum", + "email": "github@webklex.com", + "role": "Developer" + } + ], + "description": "PHP IMAP client", + "homepage": "https://github.com/webklex/php-imap", + "keywords": [ + "imap", + "mail", + "php-imap", + "pop3", + "webklex" + ], + "support": { + "issues": "https://github.com/Webklex/php-imap/issues", + "source": "https://github.com/Webklex/php-imap/tree/6.2.0" + }, + "funding": [ + { + "url": "https://www.buymeacoffee.com/webklex", + "type": "custom" + }, + { + "url": "https://ko-fi.com/webklex", + "type": "ko_fi" + } + ], + "time": "2025-04-25T06:02:37+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/config.php b/config.php new file mode 100644 index 0000000..d6d7086 --- /dev/null +++ b/config.php @@ -0,0 +1,18 @@ + 300, + 'messages_per_mailbox' => 50, + 'poll_budget_seconds' => 90, + 'imap_timeout_seconds' => 15, + 'max_message_bytes' => 5 * 1024 * 1024, + 'max_ticket_body_bytes' => 65535, // Cannot exceed Support's TEXT column limit. + 'notification_cooldown_seconds' => 3600, + 'composer_timeout_seconds' => 60, + 'php_cli_paths' => [PHP_BINDIR . '/php' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION, PHP_BINDIR . '/php'], + 'composer_paths' => ['/usr/local/bin/composer', '/usr/bin/composer'], +]; diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..45dc214 --- /dev/null +++ b/schema.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS mailgateway_message ( + message_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY, + mailbox VARCHAR(255) NOT NULL, + message_id VARCHAR(998) NOT NULL, + payload_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + ticket_id INT DEFAULT NULL, + result_json TEXT DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX mailgateway_ticket (ticket_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS mailgateway_notification ( + sender_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY, + last_notified_at BIGINT NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS mailgateway_settings ( + id SMALLINT NOT NULL PRIMARY KEY, + config_encrypted TEXT NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/templates/admin/mod_mailgateway_settings.html.twig b/templates/admin/mod_mailgateway_settings.html.twig new file mode 100644 index 0000000..07c79ed --- /dev/null +++ b/templates/admin/mod_mailgateway_settings.html.twig @@ -0,0 +1,192 @@ +{% extends request.ajax ? 'layout_blank.html.twig' : 'layout_default.html.twig' %} +{% block meta_title %}{{ 'Mail Gateway Settings'|trans }}{% endblock %} +{% set active_menu = 'system' %} +{% set result = admin.mailgateway_settings_get({}) %} +{% set params = result.settings %} + +{% block content %} +{% if result.notifications is defined %} +
+ {% if result.notifications.ready %} + {{ 'Ticket acknowledgement and enabled staff notification subjects are configured for email replies.'|trans }} + {% else %} +

{{ 'Ticket notifications need configuration. Enabling mailbox importing will configure them automatically, or use Advanced below.'|trans }}

+
    {% for error in result.notifications.errors %}
  • {{ error }}
  • {% endfor %}
+ {% endif %} +
+{% endif %} +{% if result.dependencies is defined and not result.dependencies.installed %} +
+
+ {% if result.dependencies.available %} +

{{ 'Install the required mail library before testing connections or enabling imports.'|trans }}

+
+ +
+ {% else %} +

{{ 'Automatic installation is unavailable on this server. Run composer install from the Mailgateway directory using SSH.'|trans }}

+ {% endif %} +
+
+{% endif %} + +{% set mailboxes = result.mailboxes is defined ? result.mailboxes : {'default': result.settings} %} +{% set newMailbox = result.settings|merge({'password_configured': false, 'enabled': false, 'mailbox': '', 'username': '', 'test_passed': false, 'test_result': null}) %} +{% for mailboxId, params in mailboxes|merge({'new': newMailbox}) %} +{% set canEnable = result.dependencies.installed|default(false) and params.test_passed|default(false) %} +{% set disabledReason = not result.dependencies.installed|default(false) ? 'Install dependencies before enabling mailbox importing.'|trans : 'Save this mailbox, then run Test Connection and Create Folders successfully before enabling importing. Connection, password, or folder changes require another test.'|trans %} +
+

{{ mailboxId == 'new' ? 'New mailbox'|trans : params.mailbox }}

+
+ +
+

{{ 'Each enabled mailbox routes new tickets to its selected helpdesk. The existing FOSSBilling cron polls mailboxes at the interval defined in the module configuration file.'|trans }}

+
+ + {% if not canEnable %}{{ disabledReason }}{% endif %} +
+
+
+
+
+ + + {{ 'TLS certificate validation is always enabled.'|trans }} +
+
+
+
+ + + {% if params.password_configured %}{{ 'A password is saved. Leave blank to keep it.'|trans }}{% else %}{{ 'No password is saved.'|trans }}{% endif %} + +
+
+ + +
+ {% for field, label in {'inbox_folder': 'Inbox folder', 'processed_folder': 'Processed folder', 'ignored_folder': 'Ignored folder', 'failed_folder': 'Failed folder'} %} +
+ {% endfor %} +
+

{{ 'Only the configured inbox is imported. Keep spam in a separate folder. Enabled mailboxes are imported automatically by FOSSBilling cron.'|trans }}

+
+ +
+ {% if mailboxId != 'new' %} + + + {% endif %} +
+{% endfor %} +
+ {{ 'Advanced'|trans }} +
+

{{ 'Maintenance actions preserve saved mailbox settings and existing ticket data.'|trans }}

+ {% if result.notifications is defined %} +
+

{{ 'Ticket notifications'|trans }}

+ {% if result.notifications.ready %} +

{{ 'Ticket acknowledgement and enabled staff notification subjects are configured for email replies.'|trans }}

+ {% else %} +
    {% for error in result.notifications.errors %}
  • {{ error }}
  • {% endfor %}
+ {% endif %} +

{{ 'Setup enables the ticket acknowledgement and adds ticket numbers to its subject and enabled staff notification subjects. Existing wording is kept.'|trans }}

+
+ +
+
+ {% endif %} +
+

{{ 'Composer dependencies'|trans }}

+ {% if result.dependencies.installed|default(false) %} +

{{ 'Mail Gateway dependencies are installed.'|trans }}

+ {% else %} +

{{ 'Mail Gateway dependencies are not installed.'|trans }}

+ {% endif %} +

{{ 'Run composer install again using the module lock file, even when dependencies are already installed.'|trans }}

+ {% if result.dependencies is defined and result.dependencies.available %} +
+ + +
+ {% else %} +

{{ 'Web installation is unavailable. Run composer install from the Mailgateway directory using SSH.'|trans }}

+ {% endif %} +
+
+

{{ 'Module table setup'|trans }}

+

{{ 'Runs the same setup as php tools/setup.php. Creates missing module tables and adds the default mismatch email template if absent. Existing records and templates are kept. Composer runs separately.'|trans }}

+
+ +
+
+
+
+ +{% endblock %} diff --git a/templates/email/mailgateway_sender_mismatch.html.twig b/templates/email/mailgateway_sender_mismatch.html.twig new file mode 100644 index 0000000..6cbd941 --- /dev/null +++ b/templates/email/mailgateway_sender_mismatch.html.twig @@ -0,0 +1,89 @@ +{% block subject %}[{{ guest.system_company.name }}] Unable to process your support reply{% endblock %} +{% block content %} + + + + + + + + +

We couldn't process your reply

+ +

Hello,

+ +

+ Thank you for contacting {{ guest.system_company.name }}. +

+ +

+ Unfortunately, we couldn't add your email to the support ticket + you replied to. +

+ +
+

+ Please reply from the email address associated + with your account. +

+ +

+ For security reasons, we can only accept email replies + from an address authorised to access the support ticket. +

+
+ +

+ Your message has not been added to the ticket. + Please resend it using your registered email address. +

+ +

+ If you no longer have access to that email address, + please log in to your account to update your details + or contact us for assistance. +

+ +

+ Kind regards, +

+ +
+ {{ guest.system_email.signature }} +
+ + + +{% endblock %} diff --git a/tests/automatic.php b/tests/automatic.php new file mode 100644 index 0000000..ba66dc4 --- /dev/null +++ b/tests/automatic.php @@ -0,0 +1,82 @@ + 'auto-replied'], 'auto_submitted'], + [['auto_submitted' => 'Auto-Generated; owner-email="support@example.com"'], 'auto_submitted'], + [['headers' => ['AUTO-SUBMITTED' => ['no', 'auto-replied']]], 'auto_submitted'], + [['return_path' => '<>'], 'null_return_path'], + [['content_type' => 'multipart/report; report-type=delivery-status; boundary=abc'], 'delivery_report'], + [['content_type' => 'message/disposition-notification'], 'delivery_report'], + [['precedence' => 'bulk'], 'bulk_mail'], + [['precedence' => 'LIST'], 'bulk_mail'], + [['list_id' => 'Announcements '], 'mailing_list'], + [['x_auto_response_suppress' => 'All'], 'response_suppressed'], + [['x_auto_response_suppress' => 'DR, OOF, AutoReply'], 'response_suppressed'], + [['x_autoreply' => 'yes'], 'legacy_autoresponder'], + [['x_autorespond' => 'true'], 'legacy_autoresponder'], +]; +$messageCount = (int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages'); +$notifications = $trackingService->notifications; +foreach ($cases as $index => [$fields, $reason]) { + $input = array_merge([ + 'mailbox' => 'support@dev.sebs.space', + 'message_id' => '', + // Would normally reject this mismatched reply and send a notification. + 'subject' => 'Re: [Ticket #41] Test', + 'sender_email' => 'wrong@example.com', + 'content' => 'Away from the office', + ], $fields); + $result = $tracker->process($input, $trackingService); + if ($result !== ['action' => 'ignored', 'reason' => $reason, 'duplicate' => false]) { + throw new RuntimeException('Automatic message was not ignored: ' . $index); + } + $repeat = $tracker->process($input, $trackingService); + if (!$repeat['duplicate'] || $repeat['action'] !== 'ignored') { + throw new RuntimeException('Ignored message retry was not deduplicated'); + } + $row = $connection->fetchAssociative('SELECT ticket_id FROM mailgateway_message WHERE message_id = ?', [$input['message_id']]); + if (!$row || $row['ticket_id'] !== null) { + throw new RuntimeException('Ignored mail acquired a ticket association'); + } + // The underlying service methods must also skip without database or mail access. + $guardedService = new \Box\Mod\Mailgateway\Service(); + foreach (['import', 'reply'] as $method) { + if ($guardedService->$method($fields) !== ['action' => 'ignored', 'reason' => $reason]) { + throw new RuntimeException('Service method responded to automatic mail'); + } + } +} +$minimal = $tracker->process([ + 'mailbox' => 'support@dev.sebs.space', 'message_id' => '', + 'auto_submitted' => 'auto-replied', +], $trackingService); +if ($minimal['action'] !== 'ignored' + || (int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages') !== $messageCount + || $trackingService->notifications !== $notifications) { + throw new RuntimeException('Automatic mail created a message or sent a notification'); +} +foreach ([[], ['auto_submitted' => 'NO'], ['auto_submitted' => 'no; extension=value'], + ['return_path' => ''], ['content_type' => 'text/plain; charset=utf-8'], + ['x_auto_response_suppress' => 'None'], ['x_autoreply' => 'false']] as $fields) { + if ($policy->ignoreReason($policy->headers($fields)) !== null) { + throw new RuntimeException('Normal message classified as automatic'); + } +} +$folded = $policy->headers(['headers' => ['Auto-Submitted' => "auto-replied;\r\n owner=test"]]); +if ($policy->ignoreReason($folded) !== 'auto_submitted') { + throw new RuntimeException('Folded header failed'); +} +try { + $policy->headers(['auto_submitted' => "no\r\nInjected: value"]); + throw new RuntimeException('Header injection accepted'); +} catch (\FOSSBilling\InformationException) {} +try { + $tracker->process(array_merge($input, ['auto_submitted' => 'no']), $trackingService); + throw new RuntimeException('Changed detection headers bypassed deduplication'); +} catch (\FOSSBilling\InformationException) {} +echo "Passed automatic-message filtering, repeated/mixed/folded headers, ignored-message deduplication, no ticket writes or notifications, service method protection, and normal-mail exceptions.\n"; diff --git a/tests/config.php b/tests/config.php new file mode 100644 index 0000000..11d36fe --- /dev/null +++ b/tests/config.php @@ -0,0 +1,6 @@ + ['log_stacktrace' => false]]; diff --git a/tests/cooldown.php b/tests/cooldown.php new file mode 100644 index 0000000..fc71df5 --- /dev/null +++ b/tests/cooldown.php @@ -0,0 +1,115 @@ +executeStatement('CREATE TABLE mailgateway_notification (sender_key TEXT PRIMARY KEY, last_notified_at INTEGER NOT NULL)'); +$sent = 0; +$send = function () use (&$sent): bool { ++$sent; return true; }; +$limiter = new \Box\Mod\Mailgateway\NotificationCooldown($connection); +$newRun = new \Box\Mod\Mailgateway\NotificationCooldown($connection); +if (!$limiter->run('Guest@example.com', $send) || $newRun->run('guest@example.com', $send) || $sent !== 1) { + throw new RuntimeException('New polling run reset cooldown'); +} +$key = hash('sha256', 'guest@example.com'); +$connection->update('mailgateway_notification', ['last_notified_at' => time() - 300], ['sender_key' => $key]); +if ($newRun->run('guest@example.com', $send)) { + throw new RuntimeException('Five-minute poll bypassed cooldown'); +} +$connection->update('mailgateway_notification', ['last_notified_at' => time() - 3601], ['sender_key' => $key]); +if (!$newRun->run('guest@example.com', $send)) { + throw new RuntimeException('Expired cooldown did not permit notification'); +} +if ($limiter->run('disabled@example.com', fn () => false) || !$limiter->run('disabled@example.com', $send)) { + throw new RuntimeException('Disabled template consumed cooldown'); +} +$before = (int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages'); +try { + $limiter->run('failure@example.com', function () use ($connection): void { + $connection->insert('test_messages', ['ticket_id' => 41]); + throw new RuntimeException('Queue failed'); + }); + throw new LogicException('Queue failure swallowed'); +} catch (RuntimeException $error) { + if ($error->getMessage() !== 'Queue failed') { throw $error; } +} +if ((int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages') !== $before || !$limiter->run('failure@example.com', $send)) { + throw new RuntimeException('Failed queue did not roll back'); +} +$connection->beginTransaction(); +$limiter->run('transaction@example.com', $send); +$connection->rollBack(); +if (!$limiter->run('transaction@example.com', $send)) { + throw new RuntimeException('Outer rollback retained cooldown'); +} + +$connection->executeStatement('CREATE TABLE client (id INTEGER PRIMARY KEY, email TEXT)'); +$di = new \Pimple\Container(); +$di['dbal'] = $connection; +$di['mailgateway_settings'] = new class { + public function get(): array { return ['support_helpdesk_id' => null]; } +}; +$di['em'] = new class { public function flush(): void {} public function clear(): void {} }; +$di['logger'] = new class { public function warning(...$args): void {} }; +$mail = new class { + public array $emails = []; + public function sendTemplate($data): bool { $this->emails[] = $data; return true; } +}; +$ticket = new \Box\Mod\Support\Entity\SupportTicket(); +(new ReflectionProperty($ticket, 'id'))->setValue($ticket, 50); +$ticket->setAccessHash('guest-hash'); +$ticket->setAuthorEmail('guest-owner@example.com'); +$support = new class($di, $ticket) { + public int $created = 0; + public int $replies = 0; + public bool $fail = false; + public function __construct(public $di, public $ticket) {} + public function getHelpdeskRepository() { return $this; } + public function findBy($criteria, $order, $limit) + { + $desk = new \Box\Mod\Support\Entity\Helpdesk(); + (new ReflectionProperty($desk, 'id'))->setValue($desk, 1); + return [$desk]; + } + public function ticketCreateForGuest($data) + { + ++$this->created; + foreach (['mod_support_ticket_open', 'mod_support_ticket_staff_reply'] as $code) { + $this->di['mod_service']('email')->sendTemplate(['code' => $code, 'to' => $data['email']]); + } + if ($this->fail) { throw new RuntimeException('Support failed'); } + return 'guest-hash'; + } + public function findOneByHash($hash) { return $this->ticket; } + public function getTicketById($id) { return $this->ticket; } + public function canBeReopened($ticket): bool { return true; } + public function ticketReply($ticket, $identity, $content): int { return ++$this->replies; } +}; +$lookup = fn ($module, $sub = '') => $module === 'email' ? $mail : $support; +$di['mod_service'] = $di->protect($lookup); +$service = new \Box\Mod\Mailgateway\Service(); +$service->setDi($di); +$input = ['sender_email' => 'guest-owner@example.com', 'subject' => 'Test', 'content' => 'Test body']; +$service->import($input); +$service->import($input); +if ($support->created !== 2 || count($mail->emails) !== 1 || $di['mod_service'] !== $lookup) { + throw new RuntimeException('Guest notification cooldown or lookup restoration failed'); +} +$service->reply(['sender_email' => $input['sender_email'], 'ticket_id' => 50, 'content' => 'Human reply']); +if ($support->replies !== 1) { throw new RuntimeException('Human reply blocked by cooldown'); } +foreach ([1, 2] as $attempt) { + try { + $tracker->process(['mailbox' => 'support@example.com', 'message_id' => "", + 'sender_email' => 'wrong@example.com', 'subject' => '[Ticket #50] Reply', 'content' => 'Mismatch'], $service); + throw new RuntimeException('Mismatch accepted'); + } catch (\FOSSBilling\InformationException) {} +} +if (count($mail->emails) !== 2 || $mail->emails[1]['code'] !== 'mailgateway_sender_mismatch') { + throw new RuntimeException('Mismatch cooldown failed'); +} +$support->fail = true; +try { $service->import($input); } catch (RuntimeException $error) { + if ($error->getMessage() !== 'Support failed') { throw $error; } +} +if ($di['mod_service'] !== $lookup) { throw new RuntimeException('Failure left email interception active'); } +echo "Passed persistent cooldown, five-minute polls, expiry, failed/disabled queues, nested rollback, guest acknowledgements, mismatch notifications, human replies, and service restoration.\n"; diff --git a/tests/polling.php b/tests/polling.php new file mode 100644 index 0000000..94bfd22 --- /dev/null +++ b/tests/polling.php @@ -0,0 +1,346 @@ + 'pdo_sqlite', 'memory' => true]); +$pollDb->executeStatement('CREATE TABLE mailgateway_message (message_key TEXT PRIMARY KEY, mailbox TEXT, message_id TEXT, payload_hash TEXT, ticket_id INTEGER, result_json TEXT)'); +$pollDb->executeStatement('CREATE TABLE imported (id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT)'); +$pollService = new class($pollDb) extends \Box\Mod\Mailgateway\Service { + public function __construct(public $connection) {} + public function import(array $data): array { + if ($data['content'] === 'database failure') { throw new RuntimeException('SECRET infrastructure failure'); } + $this->connection->insert('imported', ['kind' => 'ticket']); + return ['ticket_id' => 101]; + } + public function reply(array $data): array { + if ($data['ticket_id'] === 999) { throw new \FOSSBilling\InformationException('Ticket not found'); } + $this->connection->insert('imported', ['kind' => 'reply']); + return ['ticket_id' => $data['ticket_id'], 'message_id' => 77]; + } +}; +$pollDi = new \Pimple\Container(); +$pollDi['dbal'] = $pollDb; +$pollDi['em'] = new class { public function clear() {} }; +$pollDi['logger'] = new class { + public array $logs = []; + public function warning($text) { $this->logs[] = $text; } + public function info($text) { $this->logs[] = $text; } +}; +$system = new class { + public $last = 0; + public function getParamValue($key, $default) { return $this->last; } + public function setParamValue($key, $value, $create) { $this->last = $value; } +}; +$enabled = array_replace(\Box\Mod\Mailgateway\Settings::DEFAULTS, ['host' => 'mail.example.com', 'username' => 'support@example.com', 'mailbox' => 'support@example.com', 'enabled' => true, 'password' => 'TEST-SECRET']); +$pollStore = new class($enabled) { + public function __construct(public $settings) {} + public function getAll() { return ['default' => $this->settings, 'disabled' => array_replace($this->settings, ['enabled' => false]), 'other' => array_replace($this->settings, ['mailbox' => 'other@example.com'])]; } +}; +$pollDi['mailgateway_settings'] = $pollStore; +$pollService->setDi($pollDi); +$data = ['mailbox' => $enabled['mailbox'], 'sender_email' => 'guest@example.com', 'subject' => 'New email', 'content' => 'Body']; +$transport = new class($data) { + public array $messages; + public array $moves = []; + public int $connections = 0; + public bool $disconnected = false; + public bool $failMove = true; + public function __construct($data) { + $this->messages = [ + 1 => $data + ['message_id' => ''], + 2 => array_replace($data, ['message_id' => '', 'subject' => 'Re: [Ticket #101] New email']), + 3 => ['mailbox' => $data['mailbox'], 'message_id' => '', 'headers' => ['Auto-Submitted' => 'auto-replied']], + 4 => array_replace($data, ['message_id' => '', 'subject' => '[Ticket #999] Unknown']), + 5 => array_replace($data, ['message_id' => '', 'content' => 'database failure']), + 6 => new RuntimeException('SECRET fetch failure'), + ]; + } + public function connect() { ++$this->connections; } + public function uids($limit) { if ($limit !== 50) { throw new RuntimeException('Batch unbounded'); } return array_keys($this->messages); } + public function read($uid) { $data = $this->messages[$uid]; if ($data instanceof Throwable) { throw $data; } return $data; } + public function move($uid, $destination) { + if ($uid === 1 && $this->failMove) { throw new RuntimeException('SECRET move failure'); } + $this->moves[$uid] = $destination; + unset($this->messages[$uid]); + } + public function disconnect() { $this->disconnected = true; } +}; +$other = new class { + public bool $disconnected = false; + public function connect() { throw new RuntimeException('TEST-SECRET login failure'); } + public function disconnect() { $this->disconnected = true; } +}; +$clock = 10000; +$locked = false; +$releases = 0; +$locker = function ($acquire) use (&$locked, &$releases) { + if ($acquire) { if ($locked) { return false; } $locked = true; return true; } + $locked = false; ++$releases; return true; +}; +$factory = fn ($settings) => $settings['mailbox'] === 'other@example.com' ? $other : $transport; +$poller = new \Box\Mod\Mailgateway\Poller($pollDi, $pollService, $factory, function () use (&$clock) { return $clock; }, $locker); +$pollDi['mod_service'] = $pollDi->protect(fn ($module) => $module === 'system' ? $system : $pollService); +$result = $poller->poll(); +$counts = $result['mailboxes']['default']; +$diagnostics = $counts['errors']; +unset($counts['errors']); +if ($diagnostics[0]['stage'] !== 'moving email' || $diagnostics[1]['stage'] !== 'importing email' + || $diagnostics[3]['stage'] !== 'reading email' + || str_contains(json_encode($diagnostics), 'SECRET')) { + throw new RuntimeException('Missing or unsafe poll diagnostics'); +} +if ($counts !== ['status' => 'completed', 'ticket' => 1, 'reply' => 1, 'ignored' => 1, 'duplicate' => 0, 'failed' => 1, 'retry_pending' => 2, 'move_pending' => 1] + || $result['mailboxes']['other']['status'] !== 'failed' || isset($result['mailboxes']['disabled']) + || $transport->moves !== [2 => 'processed_folder', 3 => 'ignored_folder', 4 => 'failed_folder'] + || !$transport->disconnected || !$other->disconnected || $locked) { + throw new RuntimeException('Polling result or folder routing incorrect: ' . json_encode($result)); +} +$clock += 299; +if ($poller->poll()['status'] !== 'not_due' || $transport->connections !== 1 || $locked) { throw new RuntimeException('Five-minute interval failed'); } +$clock++; +$transport->failMove = false; +$result = $poller->poll(); +if ($result['mailboxes']['default']['duplicate'] !== 1 || $transport->moves[1] !== 'processed_folder' + || (int) $pollDb->fetchOne('SELECT COUNT(*) FROM imported') !== 2) { throw new RuntimeException('Move retry duplicated import'); } +if ($poller->poll(true)['status'] !== 'completed') { throw new RuntimeException('Forced run failed'); } +$locked = true; +if ($poller->poll(true)['status'] !== 'busy') { throw new RuntimeException('Concurrent poll allowed'); } +$locked = false; +$pollStore->settings['enabled'] = false; +if ($poller->poll()['status'] !== 'disabled' || $locked) { throw new RuntimeException('Disabled mailbox handling failed'); } +if (str_contains(implode("\n", $pollDi['logger']->logs), 'SECRET')) { throw new RuntimeException('Polling leaked secrets'); } + +// Use the installed MIME parser offline: real encoded headers, multipart and HTML. +$config = \Webklex\PHPIMAP\Config::make(['options' => ['debug' => false]]); +$raw = "From: =?UTF-8?B?U2Vi?= \r\nTo: support@example.com\r\nMessage-ID: \r\nSubject: =?UTF-8?B?VGVzdCDDqQ==?=\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=boundary\r\n\r\n--boundary\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nPlain =C3=A9\r\n--boundary\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n

HTML

\r\n--boundary--\r\n"; +$message = \Webklex\PHPIMAP\Message::fromString($raw, $config); +$headers = \Box\Mod\Mailgateway\ImapMailbox::rawHeaders($message->getHeader()->raw); +$parsed = \Box\Mod\Mailgateway\ImapMailbox::data($message, 'support@example.com', $headers, false); +if ($parsed['sender_email'] !== 'seb@molenaar.co.uk' || $parsed['sender_name'] !== 'Seb' + || $parsed['subject'] !== 'Test é' || $parsed['content'] !== 'Plain é' || $parsed['message_id'] !== '') { throw new RuntimeException('Real MIME decode failed: ' . json_encode($parsed)); } +$rawHtml = "From: Seb \r\nContent-Type: text/html; charset=UTF-8\r\n\r\n

Hello & welcome

Second line

"; +$message = \Webklex\PHPIMAP\Message::fromString($rawHtml, $config); +$headers = \Box\Mod\Mailgateway\ImapMailbox::rawHeaders($message->getHeader()->raw); +$parsed = \Box\Mod\Mailgateway\ImapMailbox::data($message, 'support@example.com', $headers, false); +$again = \Box\Mod\Mailgateway\ImapMailbox::data($message, 'support@example.com', $headers, false); +if ($parsed['content'] !== "Hello & welcome\nSecond line" || $parsed['subject'] !== '(No subject)' || $parsed['message_id'] !== $again['message_id']) { throw new RuntimeException('HTML fallback or missing-ID fingerprint failed'); } +$headers = \Box\Mod\Mailgateway\ImapMailbox::rawHeaders("Return-Path: <>\r\nAuto-Submitted: no\r\nAuto-Submitted: auto-replied;\r\n reason=vacation\r\n"); +$policy = new \Box\Mod\Mailgateway\InboundPolicy(); +if (count($headers['auto-submitted']) !== 2 || $headers['return-path'] !== ['<>'] || $policy->ignoreReason($policy->headers(['headers' => $headers])) === null) { throw new RuntimeException('Raw automatic headers lost'); } + +$event = new \Box_Event(null, 'onBeforeAdminCronRun'); +$hookDi = new \Pimple\Container(); +$hookDi['logger'] = $pollDi['logger']; +$hookDi['mod_service'] = $hookDi->protect(fn ($module) => new class { public function poll() { throw new RuntimeException('SECRET'); } }); +$event->setDi($hookDi); +\Box\Mod\Mailgateway\Service::onBeforeAdminCronRun($event); +echo "Passed cron isolation, five-minute cadence, overlap lock, disabled mailboxes, folder routing, failure retry, move-failure deduplication, real MIME/HTML parsing, stable missing-ID tracking, raw autoresponder headers, and secret-safe logs.\n"; + +// Exercise the real transport orchestration without opening a network connection. +$imapBody = "Hello from the inbox."; +$imapHeader = "From: Seb \r\nMessage-ID: \r\nSubject: Inbox test\r\nContent-Type: text/plain; charset=UTF-8\r\n"; +$transportMessage = new class($imapHeader, $config) { + public $message; + public string $body = ''; + public function __construct(public string $raw, public $config) { $this->parseRawBody(''); } + public function parseRawBody($body) { $this->body = $body; $this->message = \Webklex\PHPIMAP\Message::fromString($this->raw . "\r\n" . $body, $this->config); } + public function getHeader() { return $this->message->getHeader(); } + public function getFrom() { return $this->message->getFrom(); } + public function getSubject() { return $this->message->getSubject(); } + public function getTextBody() { return $this->message->getTextBody(); } + public function getHTMLBody() { return $this->message->getHTMLBody(); } + public function getRawBody() { return $this->body; } + public function getSize() { return 1000; } +}; +$query = new class($transportMessage) { + public bool $peek = false; + public bool $undeleted = false; + public function __construct(public $message) {} + public function leaveUnread() { $this->peek = true; return $this; } + public function setSequence($value) { if ($value !== \Webklex\PHPIMAP\IMAP::ST_UID) { throw new RuntimeException('Expected UID search'); } return $this; } + public function whereUndeleted() { $this->undeleted = true; return $this; } + public function search() { return new \Illuminate\Support\Collection([39, 37, 38]); } + public function fetchBody($value) { if ($value !== false) { throw new RuntimeException('Body fetched before size/policy checks'); } return $this; } + public function setFetchFlags($value) { return $this; } + public function getMessageByUid($uid) { if ($uid !== 37 || !$this->peek) { throw new RuntimeException('Unsafe message fetch'); } return $this->message; } +}; +$protocol = new class($imapBody) { + public array $commands = []; + public bool $supportsMove = true; + public function __construct(public string $body) {} + public function getCapabilities() { return new class($this->supportsMove) { public function __construct(public bool $move) {} public function validatedData() { return $this->move ? ['IMAP4rev1', 'MOVE'] : ['IMAP4rev1']; } }; } + public function fetch($items, $uids, $to, $sequence) { + if ($items !== ['BODY.PEEK[TEXT]', 'UID'] || $uids !== [37] || $sequence !== \Webklex\PHPIMAP\IMAP::ST_UID) { throw new RuntimeException('Unsafe body fetch'); } + $this->commands[] = 'BODY.PEEK[TEXT]'; + return new class($this->body) { public function __construct(public string $body) {} public function validatedData() { return [37 => ['UID' => 37, 'BODY[TEXT]' => $this->body]]; } }; + } + public function escapeString($path) { return '"' . $path . '"'; } + public function requestAndResponse($command, $tokens, $dontParse) { + if ($command !== 'UID MOVE' || $tokens !== ['37', '"Processed"'] || !$dontParse) { throw new RuntimeException('Unsafe move or expunge'); } + $this->commands[] = $command; + return new class { public function boolean() { return true; } }; + } +}; +$client = new class($protocol, $query) { + public bool $disconnected = false; + public function __construct(public $protocol, public $query) {} + public function connect() {} + public function disconnect() { $this->disconnected = true; } + public function getConnection() { return $this->protocol; } + public function getFolderByPath($path) { return new class($path, $this->query) { public bool $no_select = false; public function __construct(public string $path, public $q) {} public function query() { return $this->q; } }; } + public function openFolder($path) { if ($path !== 'INBOX') { throw new RuntimeException('Move from wrong inbox'); } } +}; +$imap = new \Box\Mod\Mailgateway\ImapMailbox($enabled, function ($config) use ($client) { + if (!$config['validate_cert'] || $config['password'] !== 'TEST-SECRET' || $config['timeout'] !== 15) { throw new RuntimeException('Unsafe client config'); } + return $client; +}); +$imap->connect(); +if ($imap->uids(2) !== [37, 38] || !$query->undeleted) { throw new RuntimeException('UID ordering or undeleted filtering failed'); } +$transportData = $imap->read(37); +if ($transportData['content'] !== $imapBody || $transportData['message_id'] !== '') { throw new RuntimeException('Transport MIME conversion failed'); } +$imap->move(37, 'processed_folder'); +$imap->disconnect(); +if (!$client->disconnected || $protocol->commands !== ['BODY.PEEK[TEXT]', 'UID MOVE']) { throw new RuntimeException('Unsafe transport side effects'); } +$protocol->supportsMove = false; +try { $imap->connect(); throw new RuntimeException('Unsafe MOVE fallback enabled'); } catch (\FOSSBilling\InformationException) {} finally { $imap->disconnect(); } +echo "Passed transport configuration, UID snapshot order, deleted-mail exclusion, true BODY.PEEK reads, native UID MOVE, and rejection of servers requiring an EXPUNGE fallback.\n"; + +// Verify option setters against the installed API, rather than only a transport mock. +$offlineClient = (new \Webklex\PHPIMAP\ClientManager(['options' => ['debug' => false]]))->make([ + 'host' => 'example.invalid', 'port' => 993, 'protocol' => 'imap', + 'encryption' => 'ssl', 'validate_cert' => true, + 'username' => 'offline@example.invalid', 'password' => 'test-only', +]); +$realQuery = new \Webklex\PHPIMAP\Query\WhereQuery($offlineClient); +$realQuery->leaveUnread()->fetchBody(false)->setFetchFlags(false); +if ($realQuery->getFetchBody() || $realQuery->getFetchFlags() + || $realQuery->getFetchOptions() !== \Webklex\PHPIMAP\IMAP::FT_PEEK) { + throw new RuntimeException('Installed IMAP query options differ from importer assumptions'); +} +echo "Passed installed WhereQuery option setters without connecting to IMAP.\n"; + +// Validate SSH configuration overrides and reject invalid storage limits. +$runtime = require dirname(__DIR__) . '/config.php'; +$fixture = tempnam(sys_get_temp_dir(), 'mailgateway-runtime-'); +try { + $changed = array_replace($runtime, ['poll_interval_seconds' => 600, 'messages_per_mailbox' => 10, + 'imap_timeout_seconds' => 25, 'max_ticket_body_bytes' => 32000]); + file_put_contents($fixture, ' 0], ['max_ticket_body_bytes' => 65536], + ['messages_per_mailbox' => '50'], ['composer_paths' => ['relative/composer']]] as $invalid) { + file_put_contents($fixture, 'statements[] = $sql; } + }; + $setupLogger = new class { + public array $warnings = []; + public function warning($message) { $this->warnings[] = $message; } + }; + $setupDi = new \Pimple\Container(); + $setupDi['dbal'] = $setupDb; + $setupDi['logger'] = $setupLogger; + $setupDi['mailgateway_template_installer'] = new class { public function install() {} }; + $setupService = new class($setupDb, $failDependencies) extends \Box\Mod\Mailgateway\Service { + public int $installs = 0; + public function __construct(public $db, public bool $fail) {} + public function installDependencies(bool $force = false): array { + ++$this->installs; + if (count($this->db->statements) !== 3) { throw new LogicException('Dependencies attempted before table setup'); } + if ($this->fail) { throw new RuntimeException('SECRET Composer failure'); } + return ['installed' => true]; + } + }; + $setupService->setDi($setupDi); + if (!$setupService->install() || $setupService->installs !== 1 || count($setupDb->statements) !== 3 + || count($setupLogger->warnings) !== ($failDependencies ? 1 : 0) + || str_contains(implode(' ', $setupLogger->warnings), 'SECRET')) { + throw new RuntimeException('Activation dependency setup or recoverable failure handling failed'); + } +} +echo "Passed activation table setup, automatic dependency install attempt, and recoverable installation failures without running Composer.\n"; + +$repairDb = new class { + public array $statements = []; + public array $locks = []; + public bool $busy = false; + public function fetchOne($sql, $params) { $this->locks[] = $sql; return str_contains($sql, 'GET_LOCK') && $this->busy ? 0 : 1; } + public function executeStatement($sql) { + if (!str_starts_with(trim($sql), 'CREATE TABLE IF NOT EXISTS')) { throw new RuntimeException('Unexpected table repair SQL'); } + $this->statements[] = $sql; + } +}; +$repairDi = new \Pimple\Container(); +$repairDi['dbal'] = $repairDb; +$repairDi['mailgateway_template_installer'] = new class { public function install() {} }; +$repairService = new class extends \Box\Mod\Mailgateway\Service { + public function installDependencies(bool $force = false): array { throw new RuntimeException('Table repair must not invoke Composer'); } +}; +$repairService->setDi($repairDi); +$repairService->setup(); +$repairService->setup(); +if (count($repairDb->statements) !== 6 || count($repairDb->locks) !== 4 + || !str_contains($repairDb->locks[3], 'RELEASE_LOCK')) { throw new RuntimeException('Repeatable table repair or overlap lock failed'); } +$repairDb->busy = true; +try { $repairService->setup(); throw new RuntimeException('Repair allowed while polling'); } catch (\FOSSBilling\InformationException) {} +echo "Passed repeatable table setup, shared polling lock, and separate setup/Composer actions.\n"; + +require dirname(__DIR__) . '/TemplateInstaller.php'; +$templateDi = new \Pimple\Container(); +$templateEmail = new class { + public array $templates = []; + public int $creates = 0; + public function getTemplateRepository() { return $this; } + public function findOneByActionCode($code) { return $this->templates[$code] ?? null; } + public function templateCreate($code, $subject, $content, $enabled, $category) { + ++$this->creates; + return $this->templates[$code] = (new \Box\Mod\Email\Entity\EmailTemplate($code)) + ->setSubject($subject)->setContent($content)->setEnabled((bool) $enabled)->setCategory($category)->setIsCustom(true); + } +}; +$templateDi['mod_service'] = $templateDi->protect(fn ($module) => $templateEmail); +$templateInstaller = new \Box\Mod\Mailgateway\TemplateInstaller($templateDi); +$templateInstaller->install(); +$templateInstaller->install(); +$template = $templateEmail->templates['mailgateway_sender_mismatch']; +if ($templateEmail->creates !== 1 || !$template->isEnabled() || $template->getCategory() !== 'mailgateway') { + throw new RuntimeException('Fresh mismatch template was not installed exactly once'); +} +$templateTwig = new \Twig\Environment(new \Twig\Loader\ArrayLoader(), ['autoescape' => 'html', 'strict_variables' => true]); +$templateGlobals = ['guest' => ['system_company' => ['name' => 'Example Company'], + 'system_email' => ['signature' => 'Example signature']]]; +$subject = $templateTwig->createTemplate($template->getSubject())->render($templateGlobals); +$body = $templateTwig->createTemplate($template->getContent())->render($templateGlobals); +if ($subject !== '[Example Company] Unable to process your support reply' + || !str_contains($body, 'Thank you for contacting Example Company.') + || !str_contains($body, 'Example signature') + || !str_contains($body, 'Your message has not been added to the ticket.')) { + throw new RuntimeException('Packaged mismatch template did not render the supplied wording with normal globals'); +} +$template->setSubject('My custom subject')->setContent('My custom content')->setEnabled(false); +$templateInstaller->install(); +if ($templateEmail->creates !== 1 || $template->getSubject() !== 'My custom subject' + || $template->getContent() !== 'My custom content' || $template->isEnabled()) { + throw new RuntimeException('Existing mismatch template content or disabled setting was overwritten'); +} +echo "Passed fresh mismatch-template installation, repeatability, Twig rendering with minimal variables, and preservation of custom content/disabled state.\n"; diff --git a/tests/replies.php b/tests/replies.php new file mode 100644 index 0000000..1b66f7f --- /dev/null +++ b/tests/replies.php @@ -0,0 +1,152 @@ +setValue($ticket, 4); +$ticket->setClientId(7); +$ticket->setStatus('on_hold'); +$helpdesk = new Helpdesk(); +$helpdesk->setCanReopen(true); +$ticket->setSupportHelpdesk($helpdesk); +$support = new class($ticket) extends \Box\Mod\Support\Service { + public function __construct(private SupportTicket $ticket) {} + public function setDi(\Pimple\Container $di): void { $this->di = $di; } + public function getTicketById(int $id): SupportTicket + { + if ($id !== 4) { + throw new \FOSSBilling\InformationException('Ticket not found'); + } + return $this->ticket; + } +}; +$em = new class { + public array $messages = []; + public function persist($message): void + { + if (!$message instanceof SupportTicketMessage) { + throw new RuntimeException('Unexpected entity'); + } + (new ReflectionProperty($message, 'id'))->setValue($message, count($this->messages) + 10); + $this->messages[] = $message; + } + public function flush(): void {} +}; +$di = new \Pimple\Container(); +$lookup = new class { + public array $ids = [7]; + public function fetchFirstColumn($sql, $params) { return $this->ids; } +}; +$di['dbal'] = $lookup; +$di['mailgateway_notification_cooldown'] = new class { + public function run($sender, $queue): bool { return $queue() !== false; } +}; +$di['db'] = new class($client) { + public function __construct(public $client) {} + public function getExistingModelById($type, $id) { return $this->client; } +}; +$di['em'] = $em; +$di['request'] = new \Symfony\Component\HttpFoundation\Request(); +$di['events_manager'] = new class { + public array $events = []; + public function fire($data): void { $this->events[] = $data; } +}; +$di['logger'] = new class { + public function info(...$args): void {} + public function warning(...$args): void {} +}; +$email = new class { + public array $notifications = []; + public bool $fail = false; + public function sendTemplate($data): void + { + if ($this->fail) { + throw new RuntimeException('Simulated mail failure'); + } + $this->notifications[] = $data; + } +}; +$di['mod_service'] = $di->protect(fn ($module) => $module === 'email' ? $email : $support); +$support->setDi($di); +$service->setDi($di); +$reply = ['ticket_id' => '4', 'sender_email' => 'customer@example.com', 'content' => ' Customer reply ']; +$result = $service->reply($reply); +if ($result !== ['ticket_id' => 4, 'message_id' => 10] + || $ticket->getStatus() !== 'open' || $em->messages[0]->getClientId() !== 7 + || $em->messages[0]->getAdminId() !== null || $em->messages[0]->getContent() !== 'Customer reply') { + throw new RuntimeException('Customer reply attribution or status incorrect'); +} + +foreach ([[], [8], [7, 8]] as $ids) { + $lookup->ids = $ids; + try { + $service->reply($reply); + throw new RuntimeException('Wrong or ambiguous sender accepted'); + } catch (\FOSSBilling\InformationException) {} +} +$lookup->ids = [7]; +foreach ([0, -1, '4x', [], true, null, 4.5, 999] as $id) { + try { + $service->reply(array_replace($reply, ['ticket_id' => $id])); + throw new RuntimeException('Invalid or missing ticket accepted'); + } catch (\FOSSBilling\InformationException) {} +} +$ticket->setStatus('closed'); +$helpdesk->setCanReopen(false); +try { + $service->reply($reply); + throw new RuntimeException('Reopen restriction bypassed'); +} catch (\FOSSBilling\InformationException) {} +$helpdesk->setCanReopen(true); +$service->reply($reply); + +// A guest ticket retains its guest owner even if that address later registers. +$ticket->setClientId(null); +$ticket->setAccessHash('private-hash'); +$ticket->setAuthorEmail('guest@example.com'); +$ticket->setStatus('on_hold'); +$reply['sender_email'] = 'GUEST@example.com'; +$service->reply($reply); +$message = $em->messages[2]; +if ($message->getClientId() !== null || $message->getAdminId() !== null || $ticket->getStatus() !== 'open') { + throw new RuntimeException('Guest reply attribution or status incorrect'); +} +$reply['sender_email'] = 'other@example.com'; +try { + $service->reply($reply); + throw new RuntimeException('Wrong guest accepted'); +} catch (\FOSSBilling\InformationException) {} +if (count($em->messages) !== 3) { + throw new RuntimeException('Rejected reply created a message'); +} +if ($email->notifications !== []) { + throw new RuntimeException('Direct reply processing attempted a notification'); +} +$service->notifySenderMismatch('other@example.com', 4); +foreach ($email->notifications as $notification) { + if ($notification !== [ + 'to' => $notification['sender_email'], + 'code' => 'mailgateway_sender_mismatch', + 'sender_email' => $notification['sender_email'], + 'ticket_id' => 4, + ]) { + throw new RuntimeException('Mismatch notification contains incorrect or private ticket data'); + } +} +$email->fail = true; +$service->notifySenderMismatch('other@example.com', 4); +try { + $service->reply($reply); + throw new RuntimeException('Mail failure allowed a mismatched reply'); +} catch (\FOSSBilling\InformationException) {} +if (count($em->messages) !== 3) { + throw new RuntimeException('Mail failure created a message'); +} +echo "Passed customer and guest reply attribution, sender ownership, ambiguity, ticket validation, and reopen policy using Support ticketReply.\n"; +echo "Passed mismatch notification recipient, private data exclusion, and rejection despite mail failure.\n"; diff --git a/tests/settings.php b/tests/settings.php new file mode 100644 index 0000000..666ad70 --- /dev/null +++ b/tests/settings.php @@ -0,0 +1,488 @@ +executeStatement('CREATE TABLE mailgateway_settings (id INTEGER PRIMARY KEY, config_encrypted TEXT NOT NULL)'); +$di = new \Pimple\Container(); +$di['dbal'] = $connection; +$di['crypt'] = new class extends \Box_Crypt { + public function encrypt(string $text, ?string $pass = null): string { return parent::encrypt($text, 'isolated-settings-test-key'); } + public function decrypt(?string $text, ?string $pass = null) { return parent::decrypt($text, 'isolated-settings-test-key'); } +}; +$desk = new \Box\Mod\Support\Entity\Helpdesk(); +(new ReflectionProperty($desk, 'id'))->setValue($desk, 2); +$ticket = new \Box\Mod\Support\Entity\SupportTicket(); +(new ReflectionProperty($ticket, 'id'))->setValue($ticket, 61); +$support = new class($desk, $ticket) { + public array $created = []; + public function __construct(public $desk, public $ticket) {} + public function getHelpdeskRepository() { return $this; } + public function find($id) { return $id === 2 ? $this->desk : null; } + public function findBy(...$args) { throw new RuntimeException('Configured helpdesk was ignored'); } + public function ticketCreateForGuest($data) { $this->created[] = $data; return 'guest-hash'; } + public function findOneByHash($hash) { return $this->ticket; } +}; +$di['mod_service'] = $di->protect(fn ($module, $sub = '') => $support); +$store = new \Box\Mod\Mailgateway\Settings($di); +if ($store->get() !== \Box\Mod\Mailgateway\Settings::DEFAULTS || $store->getPublic()['password_configured'] + || $store->get()['host'] !== '' || $store->get()['username'] !== '' || $store->get()['mailbox'] !== '') { + throw new RuntimeException('Settings defaults incorrect'); +} +$input = array_replace(\Box\Mod\Mailgateway\Settings::DEFAULTS, [ + 'host' => 'mail.example.com', 'username' => 'support@example.com', 'mailbox' => 'support@example.com', + 'enabled' => '0', 'support_helpdesk_id' => '2', 'password' => 'test-only-password', +]); +$store->save($input); +$stored = $connection->fetchOne('SELECT config_encrypted FROM mailgateway_settings WHERE id = 1'); +if (!str_starts_with($stored, 'v2:') || str_contains($stored, 'test-only-password') + || str_contains($stored, 'support@example.com') || array_key_exists('password', $store->getPublic())) { + throw new RuntimeException('Settings stored or exposed in plaintext'); +} +$input['password'] = ''; +$store->save($input); +if ($store->get()['password'] !== 'test-only-password' || !$store->getPublic()['password_configured']) { + throw new RuntimeException('Blank password did not preserve saved credential'); +} +foreach ([ + ['host' => 'https://example.com'], ['port' => '0'], ['port' => '65536'], + ['encryption' => 'none'], ['mailbox' => 'bad-email'], ['enabled' => 'maybe'], + ['inbox_folder' => 'Processed'], ['username' => "bad\r\nuser"], + ['support_helpdesk_id' => '999'], ['password' => ['invalid']], + ['clear_password' => '1', 'password' => 'conflicting-password'], +] as $changes) { + try { + $store->save(array_replace($input, $changes)); + throw new RuntimeException('Invalid settings accepted'); + } catch (\FOSSBilling\InformationException) {} +} +$service = new \Box\Mod\Mailgateway\Service(); +$service->setDi($di); +$result = $service->import(['sender_email' => 'new-guest@example.com', 'subject' => 'Configured helpdesk', 'content' => 'Test body']); +if ($result['support_helpdesk_id'] !== 2 || $support->created[0]['support_helpdesk_id'] !== 2) { + throw new RuntimeException('Selected helpdesk not used for import'); +} +$input['enabled'] = '0'; +$input['clear_password'] = '1'; +$store->save($input); +if ($store->getPublic()['password_configured']) { throw new RuntimeException('Password not cleared'); } +try { + $store->save(array_replace($input, ['enabled' => '1', 'clear_password' => '0'])); + throw new RuntimeException('Enabled imports without a password'); +} catch (\FOSSBilling\InformationException) {} + +$input['clear_password'] = '0'; +$input['password'] = 'test-only-password'; +$store->save($input); +$source = file_get_contents(dirname(__DIR__) . '/templates/admin/mod_mailgateway_settings.html.twig'); +$twig = new \Twig\Environment(new \Twig\Loader\ArrayLoader([ + 'settings' => $source, + 'layout_default.html.twig' => '{% block content %}{% endblock %}', + 'layout_blank.html.twig' => '{% block content %}{% endblock %}', +]), ['autoescape' => 'html', 'strict_variables' => true]); +$twig->addFilter(new \Twig\TwigFilter('trans', fn ($text) => $text)); +$twig->addFilter(new \Twig\TwigFilter('api_url', fn ($path, $role = 'admin') => '/api/admin/' . $path)); +$twig->addFunction(new \Twig\TwigFunction('fb_api_form', fn ($options) => 'class="api-form"', ['is_safe' => ['html']])); +$admin = new class($store) { + public function __construct(public $store) {} + public function mailgateway_settings_get($data) { return ['settings' => $this->store->getPublic(), 'helpdesks' => [2 => 'Helpdesk