Implement IMAP email-to-ticket gateway
This commit is contained in:
1 parent
b7e5884457
commit
6f25869345
33 files changed
+5619
-5
No files matched your search
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/tracking.php';
|
||||
|
||||
$policy = new \Box\Mod\Mailgateway\InboundPolicy();
|
||||
$cases = [
|
||||
[['auto_submitted' => '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 <list.example.com>'], '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' => '<automatic-' . $index . '@example.com>',
|
||||
// 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' => '<minimal-auto@example.com>',
|
||||
'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' => '<customer@example.com>'], ['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";
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Minimal configuration for isolated validation checks; no database or secrets.
|
||||
return ['debug_and_monitoring' => ['log_stacktrace' => false]];
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/automatic.php';
|
||||
$connection->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' => "<cooldown-mismatch-$attempt@example.com>",
|
||||
'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";
|
||||
@@ -0,0 +1,346 @@
|
||||
<?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 & 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";
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/validation.php';
|
||||
|
||||
use Box\Mod\Support\Entity\Helpdesk;
|
||||
use Box\Mod\Support\Entity\SupportTicket;
|
||||
use Box\Mod\Support\Entity\SupportTicketMessage;
|
||||
|
||||
$ticket = new SupportTicket();
|
||||
(new ReflectionProperty($ticket, 'id'))->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";
|
||||
@@ -0,0 +1,488 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/cooldown.php';
|
||||
$connection->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 <script>']]; }
|
||||
};
|
||||
$html = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $admin]);
|
||||
if (str_contains($html, 'test-only-password') || str_contains($html, 'Helpdesk <script>')
|
||||
|| !str_contains($html, 'Helpdesk <script>') || !str_contains($html, '/api/admin/mailgateway/settings_save')) {
|
||||
throw new RuntimeException('Settings page leaked credentials or failed to escape values');
|
||||
}
|
||||
$api = new \Box\Mod\Mailgateway\Api\Admin();
|
||||
foreach (['settings_get', 'settings_save', 'install_dependencies', 'setup', 'mailbox_delete', 'notifications_setup'] as $method) {
|
||||
try {
|
||||
$api->$method([]);
|
||||
throw new RuntimeException('Unauthenticated settings access accepted');
|
||||
} catch (\FOSSBilling\InformationException) {}
|
||||
}
|
||||
echo "Passed settings validation, actual encryption, secret redaction, password retention/clearing, selected helpdesk routing, Twig rendering/escaping, and unauthenticated rejection.\n";
|
||||
|
||||
// Existing single-mailbox ciphertext migrates without losing its credentials.
|
||||
$legacy = array_replace(\Box\Mod\Mailgateway\Settings::DEFAULTS, ['host' => 'legacy.example.com', 'username' => 'legacy@example.com', 'mailbox' => 'legacy@example.com', 'password' => 'legacy-password', 'support_helpdesk_id' => 2]);
|
||||
$connection->update('mailgateway_settings', ['config_encrypted' => $di['crypt']->encrypt(json_encode($legacy))], ['id' => 1]);
|
||||
if ($store->get() !== $legacy) { throw new RuntimeException('Legacy mailbox values or password changed'); }
|
||||
$secondDesk = new \Box\Mod\Support\Entity\Helpdesk();
|
||||
(new ReflectionProperty($secondDesk, 'id'))->setValue($secondDesk, 3);
|
||||
$multiSupport = new class($support, $secondDesk) {
|
||||
public function __construct(public $original, public $secondDesk) {}
|
||||
public function getHelpdeskRepository() { return $this; }
|
||||
public function find($id) { return $id === 3 ? $this->secondDesk : $this->original->find($id); }
|
||||
public function ticketCreateForGuest($data) { return $this->original->ticketCreateForGuest($data); }
|
||||
public function findOneByHash($hash) { return $this->original->findOneByHash($hash); }
|
||||
};
|
||||
unset($di['mod_service']);
|
||||
$di['mod_service'] = $di->protect(fn ($module, $sub = '') => $multiSupport);
|
||||
$new = array_replace($input, ['mailbox_id' => 'new', 'mailbox' => 'billing@example.com', 'username' => 'billing@example.com', 'support_helpdesk_id' => 3, 'password' => 'second-password']);
|
||||
$store->save($new);
|
||||
$mailboxes = $store->getAllPublic();
|
||||
$newId = array_key_last($mailboxes);
|
||||
if (count($mailboxes) !== 2 || $store->get()['password'] !== 'legacy-password' || $store->get($newId)['password'] !== 'second-password') {
|
||||
throw new RuntimeException('Adding mailbox overwrote credentials');
|
||||
}
|
||||
$new['mailbox_id'] = $newId;
|
||||
$new['password'] = '';
|
||||
$store->save($new);
|
||||
if ($store->get($newId)['password'] !== 'second-password') { throw new RuntimeException('Wrong mailbox password retained'); }
|
||||
try {
|
||||
$store->save(array_replace($new, ['mailbox_id' => 'new']));
|
||||
throw new RuntimeException('Duplicate mailbox accepted');
|
||||
} catch (\FOSSBilling\InformationException) {}
|
||||
$result = $service->import(['mailbox' => 'BILLING@example.com', 'sender_email' => 'new-guest@example.com', 'subject' => 'Second helpdesk', 'content' => 'Body']);
|
||||
if ($result['support_helpdesk_id'] !== 3) { throw new RuntimeException('Second mailbox routing failed'); }
|
||||
try {
|
||||
$service->import(['mailbox' => 'unknown@example.com', 'sender_email' => 'new-guest@example.com', 'subject' => 'Unknown mailbox', 'content' => 'Body']);
|
||||
throw new RuntimeException('Unknown mailbox routed to default');
|
||||
} catch (\FOSSBilling\InformationException) {}
|
||||
$admin = new class($store) {
|
||||
public function __construct(public $store) {}
|
||||
public function mailgateway_settings_get($data) { return ['settings' => $this->store->getPublic(), 'mailboxes' => $this->store->getAllPublic(), 'helpdesks' => [2 => 'General', 3 => 'Billing']]; }
|
||||
};
|
||||
$html = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $admin]);
|
||||
if (!str_contains($html, 'billing@example.com') || substr_count($html, '>Test Connection and Create Folders</button>') !== 2 || str_contains($html, 'second-password') || str_contains($html, 'legacy-password')) {
|
||||
throw new RuntimeException('Multiple mailbox rendering failed');
|
||||
}
|
||||
require dirname(__DIR__) . '/ImapConnectionTest.php';
|
||||
trait ImapTestCapabilities
|
||||
{
|
||||
public bool $supportsMove = true;
|
||||
public bool $failCapabilities = false;
|
||||
public function getConnection() {
|
||||
return new class($this) {
|
||||
public function __construct(public $client) {}
|
||||
public function getCapabilities() {
|
||||
if ($this->client->failCapabilities) { throw new RuntimeException('SECRET capabilities'); }
|
||||
return new class($this->client->supportsMove) {
|
||||
public function __construct(public bool $move) {}
|
||||
public function validatedData() { return $this->move ? ['IMAP4rev1', 'MOVE'] : ['IMAP4rev1']; }
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
$client = new class {
|
||||
use ImapTestCapabilities;
|
||||
public bool $disconnected = false;
|
||||
public bool $fail = false;
|
||||
public function connect() { if ($this->fail) { throw new RuntimeException('secret-second-password'); } }
|
||||
public function getFolders($hierarchical) {
|
||||
if ($hierarchical !== false) { throw new RuntimeException('Expected flat folder listing'); }
|
||||
return [(object) ['path' => 'inbox'], (object) ['path' => 'Processed']];
|
||||
}
|
||||
public function disconnect() { $this->disconnected = true; }
|
||||
};
|
||||
$test = new \Box\Mod\Mailgateway\ImapConnectionTest(function ($config) use ($client) {
|
||||
if (!$config['validate_cert'] || $config['password'] !== 'second-password' || $config['username'] !== 'billing@example.com') { throw new RuntimeException('Wrong IMAP config'); }
|
||||
return $client;
|
||||
});
|
||||
$check = $test->test($store->get($newId));
|
||||
if (!$check['connected'] || !$check['folders'][0]['exists'] || $check['folders'][2]['exists'] || !$client->disconnected) { throw new RuntimeException('IMAP folder test failed'); }
|
||||
$client->fail = true;
|
||||
$client->disconnected = false;
|
||||
$failedCheck = $test->test($store->get($newId));
|
||||
if ($failedCheck['success'] || $failedCheck['connected'] || !$client->disconnected
|
||||
|| str_contains(json_encode($failedCheck), 'secret')) {
|
||||
throw new RuntimeException('IMAP failure leaked secrets or skipped cleanup');
|
||||
}
|
||||
try { $api->test_connection([]); throw new RuntimeException('Unauthenticated IMAP test accepted'); } catch (\FOSSBilling\InformationException) {}
|
||||
echo "Passed legacy migration, multiple mailbox credential isolation, duplicate rejection, per-mailbox helpdesk routing, multi-mailbox Twig rendering, read-only IMAP checks, disconnect cleanup, and secret-safe failures.\n";
|
||||
|
||||
// Render all dependency states using the actual settings-page template.
|
||||
foreach ([
|
||||
[['installed' => true, 'available' => true], 'Mail Gateway dependencies are installed.', true],
|
||||
[['installed' => false, 'available' => true], 'Install dependencies', true],
|
||||
[['installed' => false, 'available' => false], 'Automatic installation is unavailable', false],
|
||||
] as [$dependencies, $expected, $button]) {
|
||||
$admin = new class($store, $dependencies) {
|
||||
public function __construct(public $store, public $dependencies) {}
|
||||
public function mailgateway_settings_get($data) {
|
||||
return ['settings' => $this->store->getPublic(), 'mailboxes' => $this->store->getAllPublic(),
|
||||
'helpdesks' => [2 => 'General', 3 => 'Billing'], 'dependencies' => $this->dependencies];
|
||||
}
|
||||
};
|
||||
$html = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $admin]);
|
||||
if (!str_contains($html, $expected) || str_contains($html, '/api/admin/mailgateway/install_dependencies') !== $button) {
|
||||
throw new RuntimeException('Dependency installation controls rendered incorrectly');
|
||||
}
|
||||
preg_match('/<input[^>]*type="checkbox"[^>]*name="enabled"[^>]*>/', $html, $switch);
|
||||
if ($switch === [] || !str_contains($switch[0], 'disabled')) {
|
||||
throw new RuntimeException('Import switch is not gated by installed dependencies');
|
||||
}
|
||||
if ($dependencies['installed'] && strpos($html, 'Mail Gateway dependencies are installed.') < strpos($html, '>Advanced</summary>')) {
|
||||
throw new RuntimeException('Installed dependency status is outside Advanced');
|
||||
}
|
||||
}
|
||||
require_once dirname(__DIR__) . '/DependencyInstaller.php';
|
||||
$runner = new class extends \Box\Mod\Mailgateway\DependencyInstaller {
|
||||
public function executeForTest(array $command): array { return $this->run($command); }
|
||||
};
|
||||
$result = $runner->executeForTest([PHP_BINARY, '-r', 'fwrite(STDOUT, str_repeat("secret", 30000)); fwrite(STDERR, str_repeat("secret", 30000)); exit(7);']);
|
||||
if ($result !== ['exit' => 7, 'timeout' => false]) {
|
||||
throw new RuntimeException('Dependency runner failed to drain pipes, preserve exit code or suppress sensitive output');
|
||||
}
|
||||
echo "Passed dependency controls, installer authentication, subprocess pipe draining, exit-code handling, and output redaction without running Composer.\n";
|
||||
|
||||
$reinstaller = new class extends \Box\Mod\Mailgateway\DependencyInstaller {
|
||||
public array $commands = [];
|
||||
protected function run(array $command): array {
|
||||
$this->commands[] = $command;
|
||||
return ['exit' => 0, 'timeout' => false];
|
||||
}
|
||||
};
|
||||
$reinstaller->install();
|
||||
if ($reinstaller->commands !== []) { throw new RuntimeException('Normal install did not skip existing dependencies'); }
|
||||
$reinstaller->install(true);
|
||||
if (count($reinstaller->commands) !== 1 || !in_array('install', $reinstaller->commands[0], true)
|
||||
|| !in_array('--no-scripts', $reinstaller->commands[0], true) || !in_array('--no-plugins', $reinstaller->commands[0], true)) {
|
||||
throw new RuntimeException('Forced Composer rerun did not reach the installer');
|
||||
}
|
||||
if (!str_contains($html, '>Advanced</summary>') || !str_contains($html, '/api/admin/mailgateway/setup')) {
|
||||
throw new RuntimeException('Advanced table setup control missing');
|
||||
}
|
||||
$missingDb = \Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
|
||||
$missingDi = new \Pimple\Container();
|
||||
$missingDi['dbal'] = $missingDb;
|
||||
if ((new \Box\Mod\Mailgateway\Settings($missingDi))->get()['host'] !== '') {
|
||||
throw new RuntimeException('Missing settings table prevented access to setup page');
|
||||
}
|
||||
echo "Passed Advanced rendering, forced Composer rerun, and settings recovery when module tables are missing.\n";
|
||||
|
||||
$di['mailgateway_dependency_installer'] = new class {
|
||||
public function status() { return ['installed' => false]; }
|
||||
};
|
||||
try {
|
||||
$store->save(array_replace($new, ['enabled' => '1']));
|
||||
throw new RuntimeException('Importing enabled without installed dependencies');
|
||||
} catch (\FOSSBilling\InformationException) {}
|
||||
$store->save(array_replace($new, ['enabled' => '0']));
|
||||
unset($di['mailgateway_dependency_installer']);
|
||||
|
||||
$folderClient = new class {
|
||||
use ImapTestCapabilities;
|
||||
public array $paths = ['Processed'];
|
||||
public array $created = [];
|
||||
public bool $disconnected = false;
|
||||
public bool $failCreate = false;
|
||||
public function connect() {}
|
||||
public function getFolders($hierarchical) { return array_map(fn ($path) => (object) ['path' => $path], $this->paths); }
|
||||
public function createFolder($path, $expunge) {
|
||||
if ($expunge !== false || $path === 'INBOX') { throw new RuntimeException('Unsafe folder creation'); }
|
||||
if ($this->failCreate) { throw new RuntimeException('SECRET folder failure'); }
|
||||
$this->created[] = $path;
|
||||
$this->paths[] = $path;
|
||||
}
|
||||
public function disconnect() { $this->disconnected = true; }
|
||||
};
|
||||
$folderTest = new \Box\Mod\Mailgateway\ImapConnectionTest(fn ($config) => $folderClient);
|
||||
$check = $folderTest->test($store->get($newId), true);
|
||||
if ($folderClient->created !== ['Ignored', 'Failed'] || $check['folders'][0]['exists']
|
||||
|| !$check['folders'][2]['created'] || !$check['folders'][3]['exists'] || !$folderClient->disconnected) {
|
||||
throw new RuntimeException('Destination folder creation or missing-inbox handling failed');
|
||||
}
|
||||
$folderTest->test($store->get($newId), true);
|
||||
if ($folderClient->created !== ['Ignored', 'Failed']) { throw new RuntimeException('Existing folders were recreated'); }
|
||||
$folderClient->paths = [];
|
||||
$folderClient->failCreate = true;
|
||||
$folderClient->disconnected = false;
|
||||
$folderFailure = $folderTest->test($store->get($newId), true);
|
||||
if ($folderFailure['success'] || !$folderFailure['connected'] || !$folderClient->disconnected
|
||||
|| str_contains(json_encode($folderFailure), 'SECRET')
|
||||
|| !str_contains($folderFailure['folders'][1]['error'], 'Folder creation failed')) {
|
||||
throw new RuntimeException('Folder failure did not distinguish login from folder creation');
|
||||
}
|
||||
echo "Passed dependency status placement, disabled import switches, server-side enablement guard, safe destination folder creation, repeated setup, and failure cleanup.\n";
|
||||
|
||||
// Readiness is per mailbox, persisted, and tied to the tested configuration.
|
||||
$readyClient = new class {
|
||||
use ImapTestCapabilities;
|
||||
public function connect() {}
|
||||
public function getFolders($hierarchical) { return array_map(fn ($path) => (object) ['path' => $path], ['INBOX', 'Processed', 'Ignored', 'Failed']); }
|
||||
public function disconnect() {}
|
||||
};
|
||||
$readyTest = new \Box\Mod\Mailgateway\ImapConnectionTest(fn ($config) => $readyClient);
|
||||
$readySettings = $store->get($newId);
|
||||
$readyResult = $readyTest->test($readySettings, true);
|
||||
if (!$readyResult['success']) { throw new RuntimeException('Valid mailbox did not pass readiness'); }
|
||||
$store->recordTest($newId, $readySettings, $readyResult);
|
||||
if (!$store->getAllPublic()[$newId]['test_passed'] || $store->getPublic()['test_passed']) {
|
||||
throw new RuntimeException('Readiness did not remain isolated to tested mailbox');
|
||||
}
|
||||
$store->save(array_replace($new, ['enabled' => '1']));
|
||||
if (!$store->get($newId)['enabled']) { throw new RuntimeException('Tested mailbox could not be enabled'); }
|
||||
$store->save(array_replace($new, ['enabled' => '1', 'host' => 'new-host.example.com']));
|
||||
if ($store->get($newId)['enabled'] || $store->getAllPublic()[$newId]['test_passed']) {
|
||||
throw new RuntimeException('Connection changes did not revoke readiness and disable imports');
|
||||
}
|
||||
try { $store->recordTest($newId, $readySettings, $readyResult); throw new RuntimeException('Stale test accepted'); }
|
||||
catch (\FOSSBilling\InformationException) {}
|
||||
$readySettings = $store->get($newId);
|
||||
$store->recordTest($newId, $readySettings, $readyResult);
|
||||
$store->save(array_replace($new, ['enabled' => '1', 'host' => 'new-host.example.com']));
|
||||
$store->recordTest($newId, $store->get($newId), $folderFailure);
|
||||
if ($store->get($newId)['enabled'] || $store->getAllPublic()[$newId]['test_passed']
|
||||
|| $store->getAllPublic()[$newId]['test_result'] !== $folderFailure) {
|
||||
throw new RuntimeException('Failed retest did not disable importing and persist diagnostics');
|
||||
}
|
||||
try {
|
||||
$store->save(array_replace($new, ['enabled' => '1', 'host' => 'new-host.example.com', 'tested_config_hash' => 'fake', 'test_passed' => true]));
|
||||
throw new RuntimeException('Unverified enablement or spoofed readiness accepted');
|
||||
} catch (\FOSSBilling\InformationException) {}
|
||||
$sourceSettings = $store->get($newId);
|
||||
$di['mailgateway_connection_test'] = $readyTest;
|
||||
$service->testConnection(['mailbox_id' => $newId]);
|
||||
if (!(new \Box\Mod\Mailgateway\Settings($di))->getAllPublic()[$newId]['test_passed']) {
|
||||
throw new RuntimeException('Settings-page connection test did not persist readiness');
|
||||
}
|
||||
unset($di['mailgateway_connection_test']);
|
||||
$di['mailgateway_connection_test'] = new class($folderFailure) {
|
||||
public function __construct(public $result) {}
|
||||
public function test($settings, $create) { return $this->result; }
|
||||
};
|
||||
$service->testConnection(['mailbox_id' => $newId]);
|
||||
$admin = new class($store) {
|
||||
public function __construct(public $store) {}
|
||||
public function mailgateway_settings_get($data) {
|
||||
return ['settings' => $this->store->getPublic(), 'mailboxes' => $this->store->getAllPublic(),
|
||||
'dependencies' => ['installed' => true, 'available' => true], 'helpdesks' => [2 => 'General', 3 => 'Billing']];
|
||||
}
|
||||
};
|
||||
$html = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $admin]);
|
||||
if (!str_contains($html, 'Folder creation failed') || !str_contains($html, 'Importing remains disabled')
|
||||
|| !str_contains($html, 'title="Save this mailbox, then run Test Connection')) {
|
||||
throw new RuntimeException('Persisted failure or hover explanation missing from page');
|
||||
}
|
||||
echo "Passed per-mailbox readiness, server-side enablement enforcement, configuration invalidation, stale-test rejection, failed-retest disabling, persisted test results, and explanatory UI.\n";
|
||||
|
||||
$keptMailbox = $store->get('default');
|
||||
$store->delete($newId);
|
||||
if (count($store->getAll()) !== 1 || $store->get('default') !== $keptMailbox) {
|
||||
throw new RuntimeException('Deleting a mailbox modified another mailbox');
|
||||
}
|
||||
try { $store->delete('unknown'); throw new RuntimeException('Unknown mailbox deletion accepted'); }
|
||||
catch (\FOSSBilling\InformationException) {}
|
||||
$store->delete('default');
|
||||
if ($store->getAll() !== [] || $store->getPublic()['mailbox'] !== '') {
|
||||
throw new RuntimeException('Deleting last mailbox failed');
|
||||
}
|
||||
$html = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $admin]);
|
||||
if (str_contains($html, '>Delete mailbox</button>') || !str_contains($html, 'Add mailbox')) {
|
||||
throw new RuntimeException('Empty mailbox list rendered a phantom mailbox');
|
||||
}
|
||||
$new['mailbox_id'] = 'new';
|
||||
$new['enabled'] = '0';
|
||||
$new['password'] = 'replacement-password';
|
||||
$store->save($new);
|
||||
if (count($store->getAll()) !== 1 || $store->get()['password'] !== 'replacement-password') {
|
||||
throw new RuntimeException('Adding mailbox after deleting last mailbox failed');
|
||||
}
|
||||
echo "Passed mailbox deletion, remaining credential preservation, unknown-ID rejection, empty-page rendering, and adding a replacement mailbox.\n";
|
||||
|
||||
$readyClient->supportsMove = false;
|
||||
$noMove = $readyTest->test($sourceSettings, true);
|
||||
if (!$noMove['connected'] || $noMove['success'] || $noMove['move_supported'] !== false
|
||||
|| !str_contains(implode(' ', $noMove['errors']), 'does not support native MOVE')) {
|
||||
throw new RuntimeException('Successful login without MOVE was not rejected clearly');
|
||||
}
|
||||
$readyClient->supportsMove = true;
|
||||
$readyClient->failCapabilities = true;
|
||||
$capabilityFailure = $readyTest->test($sourceSettings, true);
|
||||
if (!$capabilityFailure['connected'] || $capabilityFailure['success']
|
||||
|| !str_contains(implode(' ', $capabilityFailure['errors']), 'checking server MOVE support failed')
|
||||
|| str_contains(json_encode($capabilityFailure), 'SECRET')) {
|
||||
throw new RuntimeException('MOVE check failure did not preserve login result or hide secrets');
|
||||
}
|
||||
$readyClient->failCapabilities = false;
|
||||
echo "Passed native MOVE requirement, distinct capability failures, and secret-safe shared connection diagnostics.\n";
|
||||
|
||||
$notificationDi = new \Pimple\Container();
|
||||
$notificationEmail = new class {
|
||||
public array $templates = [];
|
||||
public function getTemplateRepository() { return $this; }
|
||||
public function findOneByActionCode($code) { return $this->templates[$code] ?? null; }
|
||||
public function templateToApiArray($template) {
|
||||
return ['enabled' => $template->isEnabled(), 'subject' => $template->getSubject()];
|
||||
}
|
||||
public int $updates = 0;
|
||||
public function updateTemplate($template, $enabled = null, $category = null, $subject = null): bool {
|
||||
if ($enabled !== null) $template->setEnabled($enabled);
|
||||
if ($subject !== null) $template->setSubject($subject);
|
||||
++$this->updates;
|
||||
return true;
|
||||
}
|
||||
public function templateCreate($code, $subject, $content, $enabled, $category) {
|
||||
return $this->templates[$code] = (new \Box\Mod\Email\Entity\EmailTemplate($code))
|
||||
->setSubject($subject)->setContent($content)->setEnabled((bool) $enabled);
|
||||
}
|
||||
};
|
||||
foreach (['mod_support_ticket_open', 'mod_support_ticket_staff_reply',
|
||||
'mod_support_ticket_staff_open', 'mod_support_ticket_staff_close'] as $code) {
|
||||
$notificationEmail->templates[$code] = (new \Box\Mod\Email\Entity\EmailTemplate($code))
|
||||
->setSubject('[Company] [Ticket #{{ ticket.id }}] {{ ticket.subject }}');
|
||||
}
|
||||
$notificationDi['mod_service'] = $notificationDi->protect(fn ($module) => $notificationEmail);
|
||||
$notificationStore = new class {
|
||||
public int $saves = 0;
|
||||
public function save($data): bool { ++$this->saves; return true; }
|
||||
};
|
||||
$notificationDi['mailgateway_settings'] = $notificationStore;
|
||||
$notificationService = new \Box\Mod\Mailgateway\Service();
|
||||
$notificationService->setDi($notificationDi);
|
||||
if (!$notificationService->getNotificationStatus()['ready']) {
|
||||
throw new RuntimeException('Valid ticket notification templates were rejected');
|
||||
}
|
||||
$notificationService->saveSettings(['enabled' => '1']);
|
||||
$notificationEmail->templates['mod_support_ticket_open']->setEnabled(false);
|
||||
$notificationService->saveSettings(['enabled' => '1']);
|
||||
if (!$notificationEmail->templates['mod_support_ticket_open']->isEnabled() || $notificationStore->saves !== 2) {
|
||||
throw new RuntimeException('Acknowledgement was not enabled automatically');
|
||||
}
|
||||
$notificationService->saveSettings(['enabled' => '0']);
|
||||
$notificationEmail->templates['mod_support_ticket_open']->setEnabled(true);
|
||||
$notificationEmail->templates['mod_support_ticket_staff_reply']->setSubject('Reply without marker');
|
||||
if ($notificationService->getNotificationStatus()['ready']) {
|
||||
throw new RuntimeException('Missing staff reply ticket marker accepted');
|
||||
}
|
||||
$notificationEmail->templates['mod_support_ticket_staff_reply']->setEnabled(false);
|
||||
if (!$notificationService->getNotificationStatus()['ready']) {
|
||||
throw new RuntimeException('Optional disabled staff notification rejected');
|
||||
}
|
||||
unset($notificationEmail->templates['mod_support_ticket_open']);
|
||||
if ($notificationService->getNotificationStatus()['ready']) {
|
||||
throw new RuntimeException('Missing acknowledgement template accepted');
|
||||
}
|
||||
$notificationEmail->templates['mod_support_ticket_staff_reply']->setEnabled(true)->setContent('Keep this body');
|
||||
$notificationService->saveSettings(['enabled' => '1']);
|
||||
if (!$notificationService->getNotificationStatus()['ready']
|
||||
|| $notificationEmail->templates['mod_support_ticket_staff_reply']->getSubject() !== '[Ticket #{{ ticket.id }}] Reply without marker'
|
||||
|| $notificationEmail->templates['mod_support_ticket_staff_reply']->getContent() !== 'Keep this body') {
|
||||
throw new RuntimeException('Automatic notification repair failed to preserve wording');
|
||||
}
|
||||
$updates = $notificationEmail->updates;
|
||||
$notificationService->configureNotifications();
|
||||
if ($notificationEmail->updates !== $updates) throw new RuntimeException('Repeated notification repair changed valid templates');
|
||||
echo "Passed notification readiness, automatic repair, preserved wording, and repeatability.\n";
|
||||
|
||||
$notificationAdmin = new class($store) {
|
||||
public bool $ready = true;
|
||||
public function __construct(public $store) {}
|
||||
public function mailgateway_settings_get($data) {
|
||||
return ['settings' => $this->store->getPublic(), 'mailboxes' => $this->store->getAllPublic(),
|
||||
'helpdesks' => [2 => 'General'], 'dependencies' => ['installed' => true, 'available' => false],
|
||||
'notifications' => ['ready' => $this->ready, 'errors' => $this->ready ? [] : ['Template <needs repair>']]];
|
||||
}
|
||||
};
|
||||
$notificationHtml = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $notificationAdmin]);
|
||||
if (!preg_match('/id="mg-notification-banner"[^>]*hidden/', $notificationHtml)
|
||||
|| !str_contains($notificationHtml, 'mailgateway/notifications_setup')) {
|
||||
throw new RuntimeException('Ready notification banner was not hidden or Advanced setup was missing');
|
||||
}
|
||||
$notificationAdmin->ready = false;
|
||||
$notificationHtml = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $notificationAdmin]);
|
||||
if (preg_match('/id="mg-notification-banner"[^>]*hidden/', $notificationHtml)
|
||||
|| !str_contains($notificationHtml, 'Template <needs repair>')) {
|
||||
throw new RuntimeException('Notification warnings were hidden or not escaped');
|
||||
}
|
||||
echo "Passed notification banner visibility, Advanced setup rendering, and warning escaping.\n";
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/replies.php';
|
||||
require dirname(__DIR__) . '/MessageTracker.php';
|
||||
|
||||
$connection = \Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
|
||||
$connection->executeStatement('CREATE TABLE mailgateway_message (message_key TEXT PRIMARY KEY, mailbox TEXT, message_id TEXT, payload_hash TEXT, ticket_id INTEGER, result_json TEXT)');
|
||||
$connection->executeStatement('CREATE TABLE test_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER)');
|
||||
$trackingService = new class($connection) extends \Box\Mod\Mailgateway\Service {
|
||||
public bool $fail = false;
|
||||
public bool $mismatch = false;
|
||||
public int $notifications = 0;
|
||||
public function __construct(private \Doctrine\DBAL\Connection $connection) {}
|
||||
public function import(array $data): array
|
||||
{
|
||||
$this->connection->insert('test_messages', ['ticket_id' => 41]);
|
||||
if ($this->fail) {
|
||||
throw new RuntimeException('Failure after ticket write');
|
||||
}
|
||||
return ['ticket_id' => 41, 'client_id' => 7];
|
||||
}
|
||||
public function reply(array $data): array
|
||||
{
|
||||
if ($data['ticket_id'] === 999) {
|
||||
throw new \FOSSBilling\InformationException('Ticket not found');
|
||||
}
|
||||
if ($this->mismatch) {
|
||||
throw new \FOSSBilling\InformationException('Sender does not own this ticket');
|
||||
}
|
||||
$this->connection->insert('test_messages', ['ticket_id' => $data['ticket_id']]);
|
||||
return ['ticket_id' => $data['ticket_id'], 'message_id' => 12];
|
||||
}
|
||||
public function notifySenderMismatch(string $sender, int $ticketId): void { ++$this->notifications; }
|
||||
};
|
||||
$trackingDi = new \Pimple\Container();
|
||||
$trackingDi['em'] = new class {
|
||||
public int $clears = 0;
|
||||
public function clear(): void { ++$this->clears; }
|
||||
};
|
||||
$trackingDi['mod_service'] = $trackingDi->protect(fn ($module) => new class {
|
||||
public function getTicketById($id): void {}
|
||||
});
|
||||
$trackingService->setDi($trackingDi);
|
||||
$tracker = new \Box\Mod\Mailgateway\MessageTracker($connection);
|
||||
$data = ['mailbox' => 'support@dev.sebs.space', 'message_id' => '<initial@example.com>',
|
||||
'sender_email' => 'customer@example.com', 'subject' => 'Test', 'content' => 'Test content'];
|
||||
$initial = $tracker->process($data, $trackingService);
|
||||
$duplicate = $tracker->process($data, $trackingService);
|
||||
if ($initial['action'] !== 'ticket' || $initial['duplicate'] || !$duplicate['duplicate']
|
||||
|| (int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages') !== 1) {
|
||||
throw new RuntimeException('Duplicate created another ticket');
|
||||
}
|
||||
$expectRejected = function (array $input) use ($tracker, $trackingService): void {
|
||||
try {
|
||||
$tracker->process($input, $trackingService);
|
||||
throw new RuntimeException('Invalid tracked message accepted');
|
||||
} catch (\FOSSBilling\InformationException) {}
|
||||
};
|
||||
$expectRejected(array_replace($data, ['content' => 'Changed contents']));
|
||||
foreach (['bad-id', "<id@example.com>\r\nInjected", '<id example.com>'] as $id) {
|
||||
$expectRejected(array_replace($data, ['message_id' => $id]));
|
||||
}
|
||||
$replyData = array_replace($data, ['message_id' => '<reply@example.com>', 'subject' => 'Re: [Ticket #41] Test']);
|
||||
$result = $tracker->process($replyData, $trackingService);
|
||||
if ($result['action'] !== 'reply' || $result['ticket_id'] !== 41) {
|
||||
throw new RuntimeException('Subject failed to match ticket');
|
||||
}
|
||||
foreach ([
|
||||
'Re: [Ticket #41] Updated subject',
|
||||
'Fwd: [ticket #41] Test',
|
||||
'[Ticket #41] Re: [Ticket #41] Test',
|
||||
] as $index => $subject) {
|
||||
$result = $tracker->process(array_replace($data, ['message_id' => '<subject-' . $index . '@example.com>', 'subject' => $subject]), $trackingService);
|
||||
if ($result['action'] !== 'reply' || $result['ticket_id'] !== 41) {
|
||||
throw new RuntimeException('Valid subject format failed');
|
||||
}
|
||||
}
|
||||
foreach (['[Ticket #0]', '[Ticket #999999999999999999999999]', '[Ticket #41] [Ticket #42]', '[Ticket #bad]', '[Ticket #41', '[Ticket #999]', "Bad\r\nSubject"] as $index => $subject) {
|
||||
$expectRejected(array_replace($data, ['message_id' => '<invalid-subject-' . $index . '@example.com>', 'subject' => $subject]));
|
||||
}
|
||||
$unthreaded = $tracker->process(array_replace($data, [
|
||||
'message_id' => '<reference-removed@example.com>', 'subject' => 'A new request',
|
||||
'in_reply_to' => '<initial@example.com>', 'references' => '<unknown@example.com>',
|
||||
]), $trackingService);
|
||||
if ($unthreaded['action'] !== 'ticket') {
|
||||
throw new RuntimeException('Removed subject reference did not create a new request');
|
||||
}
|
||||
$duplicateReply = $tracker->process($replyData, $trackingService);
|
||||
if (!$duplicateReply['duplicate'] || $duplicateReply['action'] !== 'reply') {
|
||||
throw new RuntimeException('Repeated reply was not deduplicated');
|
||||
}
|
||||
$isolation = $tracker->process(array_replace($data, ['mailbox' => 'different@example.com']), $trackingService);
|
||||
if ($isolation['duplicate']) {
|
||||
throw new RuntimeException('Separate mailboxes incorrectly share deduplication state');
|
||||
}
|
||||
|
||||
$count = (int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages');
|
||||
$trackingService->fail = true;
|
||||
$failed = array_replace($data, ['message_id' => '<retry@example.com>']);
|
||||
try {
|
||||
$tracker->process($failed, $trackingService);
|
||||
throw new LogicException('Simulated failure was swallowed');
|
||||
} catch (RuntimeException $error) {
|
||||
if ($error->getMessage() !== 'Failure after ticket write') {
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
if ((int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages') !== $count) {
|
||||
throw new RuntimeException('Partial write survived rollback');
|
||||
}
|
||||
$trackingService->fail = false;
|
||||
$tracker->process($failed, $trackingService);
|
||||
$tracker->process($failed, $trackingService);
|
||||
if ((int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages') !== $count + 1) {
|
||||
throw new RuntimeException('Retry created duplicate writes');
|
||||
}
|
||||
$trackingService->mismatch = true;
|
||||
$expectRejected(array_replace($replyData, ['message_id' => '<mismatch@example.com>']));
|
||||
if ($trackingService->notifications !== 1 || $connection->isTransactionActive()) {
|
||||
throw new RuntimeException('Mismatch notification or transaction cleanup failed');
|
||||
}
|
||||
echo "Passed persistent deduplication, conflicting payload rejection, subject matching, mailbox isolation, malformed/conflicting/missing ticket rejection, removed references, rollback/retry, and mismatch notifications.\n";
|
||||
|
||||
// Exercise the real rejection path with a queue spy that cannot be rolled back.
|
||||
$rejectionConnection = \Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
|
||||
$rejectionConnection->executeStatement('CREATE TABLE mailgateway_message (message_key TEXT PRIMARY KEY, mailbox TEXT, message_id TEXT, payload_hash TEXT, ticket_id INTEGER, result_json TEXT)');
|
||||
$rejectionConnection->executeStatement('CREATE TABLE mailgateway_notification (sender_key TEXT PRIMARY KEY, last_notified_at INTEGER NOT NULL)');
|
||||
$rejectionConnection->executeStatement('CREATE TABLE client (id INTEGER PRIMARY KEY, email TEXT)');
|
||||
$rejectionDi = new \Pimple\Container();
|
||||
$rejectionDi['dbal'] = $rejectionConnection;
|
||||
$rejectionEm = new class {
|
||||
public int $clears = 0;
|
||||
public function clear(): void { ++$this->clears; }
|
||||
};
|
||||
$rejectionDi['em'] = $rejectionEm;
|
||||
$rejectionDi['logger'] = new class {
|
||||
public function warning(...$args): void {}
|
||||
};
|
||||
$rejectionEmail = new class {
|
||||
public array $attempts = [];
|
||||
public bool $fail = false;
|
||||
public function sendTemplate($data): bool {
|
||||
$this->attempts[] = $data;
|
||||
if ($this->fail) throw new RuntimeException('Queue unavailable');
|
||||
return true;
|
||||
}
|
||||
};
|
||||
$rejectionDi['mod_service'] = $rejectionDi->protect(fn ($module) => $module === 'email' ? $rejectionEmail : $support);
|
||||
$rejectionLimiter = new class($rejectionConnection, $rejectionEm) {
|
||||
public array $entries = [];
|
||||
public function __construct(private $connection, private $em) {}
|
||||
public function run($sender, $queue): bool {
|
||||
$this->entries[] = ['active' => $this->connection->isTransactionActive(),
|
||||
'tracked' => (int) $this->connection->fetchOne('SELECT COUNT(*) FROM mailgateway_message'),
|
||||
'clears' => $this->em->clears];
|
||||
return (new \Box\Mod\Mailgateway\NotificationCooldown($this->connection))->run($sender, $queue);
|
||||
}
|
||||
};
|
||||
$rejectionDi['mailgateway_notification_cooldown'] = $rejectionLimiter;
|
||||
$rejectionService = new \Box\Mod\Mailgateway\Service();
|
||||
$rejectionService->setDi($rejectionDi);
|
||||
$rejectionTracker = new \Box\Mod\Mailgateway\MessageTracker($rejectionConnection);
|
||||
foreach (['guest', 'customer', 'ambiguous', 'queue-failure'] as $index => $case) {
|
||||
$ticket->setClientId($case === 'guest' || $case === 'queue-failure' ? null : 7);
|
||||
$ticket->setAuthorEmail('owner@example.com');
|
||||
$rejectionConnection->executeStatement('DELETE FROM client');
|
||||
$rejectionConnection->executeStatement('DELETE FROM mailgateway_notification');
|
||||
$rejectionConnection->insert('client', ['id' => 8, 'email' => 'other@example.com']);
|
||||
if ($case === 'ambiguous') {
|
||||
$rejectionConnection->insert('client', ['id' => 7, 'email' => 'other@example.com']);
|
||||
}
|
||||
$rejectionEmail->fail = $case === 'queue-failure';
|
||||
$input = ['mailbox' => 'support@example.com', 'message_id' => "<rejection-$index@example.com>",
|
||||
'sender_email' => 'other@example.com', 'subject' => '[Ticket #4] Reply', 'content' => 'Rejected reply'];
|
||||
try {
|
||||
$rejectionTracker->process($input, $rejectionService);
|
||||
throw new RuntimeException('Real mismatched reply accepted');
|
||||
} catch (\FOSSBilling\InformationException $error) {
|
||||
$expected = $ticket->isGuestTicket() ? 'Sender does not own this ticket' : 'Sender does not uniquely match the ticket owner';
|
||||
if ($error->getMessage() !== $expected) throw $error;
|
||||
}
|
||||
if (count($rejectionEmail->attempts) !== $index + 1
|
||||
|| count($rejectionLimiter->entries) !== $index + 1
|
||||
|| $rejectionLimiter->entries[$index] !== ['active' => false, 'tracked' => 0, 'clears' => $index + 1]
|
||||
|| $rejectionConnection->isTransactionActive()
|
||||
|| (int) $rejectionConnection->fetchOne('SELECT COUNT(*) FROM mailgateway_notification') !== ($rejectionEmail->fail ? 0 : 1)
|
||||
|| $rejectionEmail->attempts[$index] !== ['to' => 'other@example.com',
|
||||
'code' => 'mailgateway_sender_mismatch', 'sender_email' => 'other@example.com', 'ticket_id' => 4]) {
|
||||
throw new RuntimeException('Rejected reply did not attempt exactly one safe notification after rollback');
|
||||
}
|
||||
}
|
||||
echo "Passed real guest/customer/ambiguous rejection paths, single post-rollback notification attempts, and queue-failure cleanup.\n";
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__, 3) . '/vendor/autoload.php';
|
||||
define('PATH_CONFIG', __DIR__ . '/config.php');
|
||||
define('DEBUG', false);
|
||||
require dirname(__DIR__) . '/RuntimeConfig.php';
|
||||
require dirname(__DIR__) . '/DependencyInstaller.php';
|
||||
require dirname(__DIR__) . '/Service.php';
|
||||
require dirname(__DIR__) . '/InboundPolicy.php';
|
||||
require dirname(__DIR__) . '/NotificationCooldown.php';
|
||||
require dirname(__DIR__) . '/NotificationEmailProxy.php';
|
||||
require dirname(__DIR__) . '/Settings.php';
|
||||
require dirname(__DIR__) . '/Api/Admin.php';
|
||||
|
||||
$service = new \Box\Mod\Mailgateway\Service();
|
||||
$valid = ['sender_email' => 'customer@example.com', 'subject' => 'Test', 'content' => 'Test body'];
|
||||
$cases = [
|
||||
[],
|
||||
array_replace($valid, ['sender_email' => ['customer@example.com']]),
|
||||
array_replace($valid, ['sender_email' => 'Name <customer@example.com>']),
|
||||
array_replace($valid, ['subject' => "Header\r\nInjected"]),
|
||||
array_replace($valid, ['subject' => str_repeat('x', 256)]),
|
||||
array_replace($valid, ['subject' => "\xFF"]),
|
||||
array_replace($valid, ['content' => str_repeat('x', 65536)]),
|
||||
array_replace($valid, ['content' => "\xFF"]),
|
||||
array_replace($valid, ['content' => "\0"]),
|
||||
array_replace($valid, ['sender_name' => ['Invalid']]),
|
||||
array_replace($valid, ['sender_name' => "Name\nInjected"]),
|
||||
array_replace($valid, ['sender_name' => str_repeat('x', 256)]),
|
||||
];
|
||||
foreach ($cases as $index => $data) {
|
||||
try {
|
||||
$service->import($data);
|
||||
throw new \RuntimeException('Invalid input accepted: ' . $index);
|
||||
} catch (\FOSSBilling\InformationException) {
|
||||
// Must reject before accessing any database services.
|
||||
}
|
||||
}
|
||||
|
||||
// The production API exposes only the settings page operations.
|
||||
$methods = array_filter((new ReflectionClass(\Box\Mod\Mailgateway\Api\Admin::class))->getMethods(ReflectionMethod::IS_PUBLIC),
|
||||
fn ($method) => $method->getDeclaringClass()->getName() === \Box\Mod\Mailgateway\Api\Admin::class);
|
||||
$names = array_map(fn ($method) => $method->getName(), $methods);
|
||||
sort($names);
|
||||
if ($names !== ['install_dependencies', 'mailbox_delete', 'notifications_setup', 'settings_get', 'settings_save', 'setup', 'test_connection']) {
|
||||
throw new RuntimeException('Unexpected production API endpoints');
|
||||
}
|
||||
echo 'Passed ', count($cases), " invalid-input checks and production API surface check.\n";
|
||||
|
||||
// Exercise the import orchestration with real ticket entities and a simulated
|
||||
// Support autoresponse, without requiring a database or sending email.
|
||||
$client = new class extends \Model_Client {
|
||||
public function __get($property)
|
||||
{
|
||||
return $property === 'id' ? 7 : null;
|
||||
}
|
||||
};
|
||||
foreach (['on_hold' => 1, 'open' => 0, 'closed' => 0] as $status => $expectedFlushes) {
|
||||
$ticket = new \Box\Mod\Support\Entity\SupportTicket();
|
||||
$ticket->setStatus($status);
|
||||
$helpdesk = new \Box\Mod\Support\Entity\Helpdesk();
|
||||
$support = new class($ticket, $helpdesk, $client) {
|
||||
public function __construct(public $ticket, public $helpdesk, public $client) {}
|
||||
public function getHelpdeskRepository() { return $this; }
|
||||
public function findBy($criteria, $order, $limit) { return [$this->helpdesk]; }
|
||||
public function ticketCreateForClient($client, $helpdesk, $data): int
|
||||
{
|
||||
if ($client !== $this->client || $helpdesk !== $this->helpdesk || $data['content'] !== 'Test body') {
|
||||
throw new \RuntimeException('Incorrect customer or message passed to Support');
|
||||
}
|
||||
return 42;
|
||||
}
|
||||
public function getTicketById(int $id) { return $this->ticket; }
|
||||
public function toApiArray($ticket, $deep, $identity)
|
||||
{
|
||||
if ($identity !== $this->client) {
|
||||
throw new \RuntimeException('Notification uses incorrect customer');
|
||||
}
|
||||
return ['id' => 42];
|
||||
}
|
||||
};
|
||||
$em = new class {
|
||||
public int $flushes = 0;
|
||||
public function flush(): void { ++$this->flushes; }
|
||||
};
|
||||
$di = new \Pimple\Container();
|
||||
$di['dbal'] = new class {
|
||||
public function fetchFirstColumn($sql, $params) { return [7]; }
|
||||
};
|
||||
$di['db'] = new class($client) {
|
||||
public function __construct(public $client) {}
|
||||
public function getExistingModelById($type, $id) { return $this->client; }
|
||||
};
|
||||
$di['em'] = $em;
|
||||
$di['mailgateway_settings'] = new class {
|
||||
public function get(): array { return ['support_helpdesk_id' => null]; }
|
||||
};
|
||||
$di['mailgateway_notification_cooldown'] = new class {
|
||||
public function run($sender, $queue): bool { return $queue() !== false; }
|
||||
};
|
||||
$di['auth'] = new class {
|
||||
public function isClientLoggedIn(): bool { return false; }
|
||||
};
|
||||
$email = new class {
|
||||
public array $messages = [];
|
||||
public function sendTemplate($data) { $this->messages[] = $data; }
|
||||
};
|
||||
$di['mod_service'] = $di->protect(fn ($module) => $module === 'email' ? $email : $support);
|
||||
$service->setDi($di);
|
||||
$result = $service->import($valid);
|
||||
$expectedStatus = $status === 'on_hold' ? 'open' : $status;
|
||||
if ($ticket->getStatus() !== $expectedStatus || $em->flushes !== $expectedFlushes
|
||||
|| $result['ticket_id'] !== 42 || $result['client_id'] !== 7) {
|
||||
throw new \RuntimeException('Import status regression: ' . $status);
|
||||
}
|
||||
if ($email->messages !== [['to_client' => 7, 'code' => 'mod_support_ticket_open', 'ticket' => ['id' => 42]]]) {
|
||||
throw new \RuntimeException('Customer acknowledgement missing or incorrect');
|
||||
}
|
||||
}
|
||||
echo "Passed autoresponse status check, existing Open/Closed status checks, and customer identity forwarding.\n";
|
||||
echo "Passed customer acknowledgement recipient, template, and ticket-number checks.\n";
|
||||
|
||||
$guestSupport = new class {
|
||||
public array $received = [];
|
||||
public bool $enabled = true;
|
||||
public function getHelpdeskRepository() { return $this; }
|
||||
public function findBy($criteria, $order, $limit)
|
||||
{
|
||||
$helpdesk = new \Box\Mod\Support\Entity\Helpdesk();
|
||||
(new \ReflectionProperty($helpdesk, 'id'))->setValue($helpdesk, 3);
|
||||
return [$helpdesk];
|
||||
}
|
||||
public function ticketCreateForGuest($data): string
|
||||
{
|
||||
if (!$this->enabled) {
|
||||
throw new \FOSSBilling\InformationException('Guest tickets disabled');
|
||||
}
|
||||
$this->received[] = $data;
|
||||
return 'private-access-hash';
|
||||
}
|
||||
public function findOneByHash($hash)
|
||||
{
|
||||
if ($hash !== 'private-access-hash') {
|
||||
throw new \RuntimeException('Guest hash lookup incorrect');
|
||||
}
|
||||
$ticket = new \Box\Mod\Support\Entity\SupportTicket();
|
||||
(new \ReflectionProperty($ticket, 'id'))->setValue($ticket, 43);
|
||||
return $ticket;
|
||||
}
|
||||
};
|
||||
$guestDi = new \Pimple\Container();
|
||||
$lookup = new class {
|
||||
public array $ids = [];
|
||||
public function fetchFirstColumn($sql, $params) { return $this->ids; }
|
||||
};
|
||||
$guestDi['dbal'] = $lookup;
|
||||
$guestDi['mailgateway_settings'] = new class {
|
||||
public function get(): array { return ['support_helpdesk_id' => null]; }
|
||||
};
|
||||
$guestDi['mod_service'] = $guestDi->protect(fn ($module) => $guestSupport);
|
||||
$service->setDi($guestDi);
|
||||
foreach ([null, 'Guest Sender'] as $name) {
|
||||
$data = $valid;
|
||||
if ($name !== null) {
|
||||
$data['sender_name'] = $name;
|
||||
}
|
||||
$result = $service->import($data);
|
||||
$received = $guestSupport->received[array_key_last($guestSupport->received)];
|
||||
if ($result !== ['ticket_id' => 43, 'client_id' => null, 'support_helpdesk_id' => 3]
|
||||
|| $received !== ['name' => $name ?? $valid['sender_email'], 'email' => $valid['sender_email'],
|
||||
'subject' => 'Test', 'content' => 'Test body', 'support_helpdesk_id' => 3]) {
|
||||
throw new \RuntimeException('Guest import or attribution incorrect');
|
||||
}
|
||||
}
|
||||
$guestSupport->enabled = false;
|
||||
try {
|
||||
$service->import($valid);
|
||||
throw new \RuntimeException('Disabled guest policy bypassed');
|
||||
} catch (\FOSSBilling\InformationException) {
|
||||
}
|
||||
$lookup->ids = [7, 8];
|
||||
try {
|
||||
$service->import($valid);
|
||||
throw new \RuntimeException('Ambiguous customer accepted');
|
||||
} catch (\FOSSBilling\InformationException) {
|
||||
}
|
||||
if (count($guestSupport->received) !== 2) {
|
||||
throw new \RuntimeException('Unexpected guest creation');
|
||||
}
|
||||
echo "Passed guest routing, sender name fallback, helpdesk mapping, private hash handling, disabled guest policy, and ambiguous customer rejection.\n";
|
||||
Reference in New Issue
Block a user