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