Files

347 lines
22 KiB
PHP

<?php
declare(strict_types=1);
require __DIR__ . '/settings.php';
require dirname(__DIR__) . '/Poller.php';
require dirname(__DIR__) . '/ImapMailbox.php';
require dirname(__DIR__) . '/vendor/autoload.php';
$pollDb = \Doctrine\DBAL\DriverManager::getConnection(['driver' => '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' => '<poll-1@example.com>'],
2 => array_replace($data, ['message_id' => '<poll-2@example.com>', 'subject' => 'Re: [Ticket #101] New email']),
3 => ['mailbox' => $data['mailbox'], 'message_id' => '<poll-3@example.com>', 'headers' => ['Auto-Submitted' => 'auto-replied']],
4 => array_replace($data, ['message_id' => '<poll-4@example.com>', 'subject' => '[Ticket #999] Unknown']),
5 => array_replace($data, ['message_id' => '<poll-5@example.com>', '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?= <seb@molenaar.co.uk>\r\nTo: support@example.com\r\nMessage-ID: <mime-1@example.com>\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<p>HTML</p>\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'] !== '<mime-1@example.com>') { throw new RuntimeException('Real MIME decode failed: ' . json_encode($parsed)); }
$rawHtml = "From: Seb <seb@molenaar.co.uk>\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n<style>SECRET STYLE</style><script>SECRET SCRIPT</script><p>Hello &amp; welcome</p><p>Second line</p>";
$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 <seb@molenaar.co.uk>\r\nMessage-ID: <transport@example.com>\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'] !== '<transport@example.com>') { 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, '<?php return ' . var_export($changed, true) . ';');
if (\Box\Mod\Mailgateway\RuntimeConfig::load($fixture) !== $changed) {
throw new RuntimeException('SSH operational overrides not loaded');
}
foreach ([['poll_interval_seconds' => 0], ['max_ticket_body_bytes' => 65536],
['messages_per_mailbox' => '50'], ['composer_paths' => ['relative/composer']]] as $invalid) {
file_put_contents($fixture, '<?php return ' . var_export(array_replace($runtime, $invalid), true) . ';');
$rejected = false;
try { \Box\Mod\Mailgateway\RuntimeConfig::load($fixture); } catch (RuntimeException) { $rejected = true; }
if (!$rejected) { throw new RuntimeException('Invalid SSH operational config accepted'); }
}
} finally {
unlink($fixture);
}
echo "Passed blank mailbox defaults, saved mailbox preservation, SSH configuration overrides, and operational value validation.\n";
// Activation creates tables before attempting dependencies and permits recovery
// through the settings page when Composer is unavailable.
foreach ([false, true] as $failDependencies) {
$setupDb = new class {
public array $statements = [];
public function executeStatement($sql) { $this->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";