'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' => '', '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', "\r\nInjected", ''] as $id) { $expectRejected(array_replace($data, ['message_id' => $id])); } $replyData = array_replace($data, ['message_id' => '', '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' => $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' => '', 'subject' => $subject])); } $unthreaded = $tracker->process(array_replace($data, [ 'message_id' => '', 'subject' => 'A new request', 'in_reply_to' => '', 'references' => '', ]), $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' => '']); 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' => ''])); 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' => "", '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";