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,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";
|
||||
Reference in New Issue
Block a user