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,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