'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";