Implement IMAP email-to-ticket gateway

This commit is contained in:
macktic committed 2026-10-10 16:22:56 +01:00
1 parent b7e5884457
commit 6f25869345
33 files changed
+5619 -5

No files matched your search

-1
View File
@@ -4,5 +4,4 @@
.env .env
.env.* .env.*
/vendor/ /vendor/
/composer.lock
*.log *.log
+69 -4
View File
@@ -6,12 +6,77 @@ namespace Box\Mod\Mailgateway\Api;
class Admin extends \FOSSBilling\Api\AbstractApi class Admin extends \FOSSBilling\Api\AbstractApi
{ {
public function ping(array $data): array public function settings_get(array $data): array
{ {
if (!$this->getIdentity() instanceof \Model_Admin) {
throw new \FOSSBilling\InformationException('Administrator authentication required');
}
$this->checkPermissions('mailgateway', 'manage_settings');
return [ return [
'status' => 'ok', 'settings' => $this->getService()->getSettings(),
'module' => 'mailgateway', 'dependencies' => $this->getService()->getDependencyStatus(),
'version' => '0.1.0', 'notifications' => $this->getService()->getNotificationStatus(),
'mailboxes' => $this->getService()->getMailboxes(),
'helpdesks' => $this->getDi()['mod_service']('support')->getHelpdeskRepository()->getPairs(),
]; ];
} }
public function settings_save(array $data): bool
{
if (!$this->getIdentity() instanceof \Model_Admin) {
throw new \FOSSBilling\InformationException('Administrator authentication required');
}
$this->checkPermissions('mailgateway', 'manage_settings');
return $this->getService()->saveSettings($data);
}
public function test_connection(array $data): array
{
if (!$this->getIdentity() instanceof \Model_Admin) {
throw new \FOSSBilling\InformationException('Administrator authentication required');
}
$this->checkPermissions('mailgateway', 'manage_settings');
return $this->getService()->testConnection($data);
}
public function install_dependencies(array $data): array
{
if (!$this->getIdentity() instanceof \Model_Admin) {
throw new \FOSSBilling\InformationException('Administrator authentication required');
}
$this->checkPermissions('mailgateway', 'manage_settings');
$force = $data['force'] ?? false;
if (!in_array($force, [true, false, 0, 1, '0', '1'], true)) {
throw new \FOSSBilling\InformationException('Invalid dependency installation option');
}
return $this->getService()->installDependencies(in_array($force, [true, 1, '1'], true));
}
public function setup(array $data): bool
{
if (!$this->getIdentity() instanceof \Model_Admin) {
throw new \FOSSBilling\InformationException('Administrator authentication required');
}
$this->checkPermissions('mailgateway', 'manage_settings');
return $this->getService()->setup();
}
public function notifications_setup(array $data): array
{
if (!$this->getIdentity() instanceof \Model_Admin) {
throw new \FOSSBilling\InformationException('Administrator authentication required');
}
$this->checkPermissions('mailgateway', 'manage_settings');
return $this->getService()->configureNotifications();
}
public function mailbox_delete(array $data): bool
{
if (!$this->getIdentity() instanceof \Model_Admin) {
throw new \FOSSBilling\InformationException('Administrator authentication required');
}
$this->checkPermissions('mailgateway', 'manage_settings');
return $this->getService()->deleteMailbox($data);
}
} }
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Box\Mod\Mailgateway;
use FOSSBilling\InformationException;
class DependencyInstaller
{
public function status(): array
{
return ['installed' => $this->installed(), 'available' => function_exists('proc_open')
&& $this->php() !== null && $this->composer() !== null && is_writable(__DIR__),
'request_timeout_ms' => (RuntimeConfig::get('composer_timeout_seconds') + 30) * 1000];
}
public function install(bool $force = false): array
{
if (!$force && $this->installed()) {
return ['installed' => true];
}
$php = $this->php();
$composer = $this->composer();
if (!function_exists('proc_open') || $php === null || $composer === null || !is_writable(__DIR__)) {
throw new InformationException('Web installation is unavailable. Run composer install from the Mailgateway directory using SSH.');
}
if (!is_file(__DIR__ . '/composer.lock')) {
throw new InformationException('The module composer.lock is missing. Restore it before installing dependencies.');
}
$command = [$php, $composer, 'install', '--no-dev', '--prefer-dist', '--no-interaction',
'--no-progress', '--no-scripts', '--no-plugins', '--optimize-autoloader'];
$result = $this->run($command);
if ($result['timeout']) {
throw new InformationException('Dependency installation exceeded its configured timeout. Run composer install from the Mailgateway directory using SSH.');
}
if ($result['exit'] !== 0 || !$this->installed()) {
throw new InformationException('Composer installation failed. Run composer install from the Mailgateway directory using SSH to see the detailed error.');
}
return ['installed' => true];
}
private function installed(): bool
{
return is_file(__DIR__ . '/vendor/autoload.php')
&& is_file(__DIR__ . '/vendor/webklex/php-imap/src/ClientManager.php');
}
private function php(): ?string
{
foreach (RuntimeConfig::get('php_cli_paths') as $path) {
if (is_file($path) && is_executable($path)) {
return $path;
}
}
return null;
}
private function composer(): ?string
{
foreach (RuntimeConfig::get('composer_paths') as $path) {
if (is_file($path) && is_readable($path)) {
return $path;
}
}
return null;
}
protected function run(array $command): array
{
// PHP may terminate a web request before Composer completes otherwise.
@set_time_limit(RuntimeConfig::get('composer_timeout_seconds') + 15);
$process = proc_open($command, [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $pipes, __DIR__);
if (!is_resource($process)) {
throw new InformationException('Composer could not be started. Run composer install using SSH.');
}
fclose($pipes[0]);
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$deadline = microtime(true) + RuntimeConfig::get('composer_timeout_seconds');
$exit = -1;
$timeout = false;
try {
while (true) {
// Drain both pipes to avoid deadlock. Never expose Composer output:
// repository authentication and environment details can appear there.
stream_get_contents($pipes[1]);
stream_get_contents($pipes[2]);
$status = proc_get_status($process);
if (!$status['running']) {
$exit = $status['exitcode'];
break;
}
if (microtime(true) >= $deadline) {
$timeout = true;
proc_terminate($process, 9);
break;
}
usleep(100000);
}
} finally {
fclose($pipes[1]);
fclose($pipes[2]);
$closed = proc_close($process);
if ($exit === -1) {
$exit = $closed;
}
}
return ['exit' => $exit, 'timeout' => $timeout];
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Box\Mod\Mailgateway;
use FOSSBilling\InformationException;
class ImapConnectionTest
{
public function __construct(private ?\Closure $factory = null) {}
public function test(array $settings, bool $createFolders = false): array
{
if ($settings['password'] === '') {
throw new InformationException('Save a mailbox password before testing the connection');
}
$client = null;
$connected = false;
$moveSupported = null;
$folders = [];
$errors = [];
try {
if ($this->factory === null) {
if (!is_file(__DIR__ . '/vendor/autoload.php')) {
throw new InformationException('Install the Mail Gateway Composer dependencies first');
}
require_once __DIR__ . '/vendor/autoload.php';
}
$config = [
'host' => $settings['host'], 'port' => (int) $settings['port'],
'protocol' => 'imap', 'encryption' => $settings['encryption'],
'validate_cert' => true, 'username' => $settings['username'],
'password' => $settings['password'], 'authentication' => null,
'timeout' => RuntimeConfig::get('imap_timeout_seconds'),
];
$client = $this->factory !== null ? ($this->factory)($config)
: (new \Webklex\PHPIMAP\ClientManager(['options' => ['debug' => false]]))->make($config);
$client->connect();
$connected = true;
try {
$capabilities = $client->getConnection()->getCapabilities()->validatedData();
$moveSupported = in_array('MOVE', array_map('strtoupper', $capabilities), true);
if (!$moveSupported) {
$errors[] = 'IMAP login succeeded, but the server does not support native MOVE. Importing requires MOVE to archive individual messages safely.';
}
} catch (\Throwable) {
$errors[] = 'IMAP login succeeded, but checking server MOVE support failed. Check server access and try again.';
}
$paths = $this->paths($client);
foreach (['inbox_folder', 'processed_folder', 'ignored_folder', 'failed_folder'] as $field) {
$path = $settings[$field];
$exists = $this->exists($path, $paths);
$created = false;
$error = null;
if (!$exists && $field === 'inbox_folder') {
$error = 'The configured inbox does not exist. Correct its path or create it on the mail server.';
} elseif (!$exists && $createFolders) {
try {
// Never expunge messages while creating a destination folder.
$client->createFolder($path, false);
} catch (\Throwable) {
$error = 'Folder creation failed. Check the folder path, parent folders, and mailbox permissions or quota.';
}
if ($error === null) {
try {
$paths = $this->paths($client);
$exists = $this->exists($path, $paths);
$created = $exists;
if (!$exists) {
$error = 'The server accepted folder creation, but the folder could not be found afterwards.';
}
} catch (\Throwable) {
$error = 'The folder creation request completed, but listing folders to verify it failed. Check server access and try again.';
}
}
} elseif (!$exists) {
$error = 'The configured destination folder does not exist.';
}
$folders[] = ['path' => $path, 'exists' => $exists, 'created' => $created, 'error' => $error];
}
} catch (\Throwable) {
// Never return IMAP exception messages or traces (may contain credentials).
$errors[] = $connected
? 'IMAP login succeeded, but listing folders failed. Check mailbox permissions and server access.'
: 'IMAP connection or login failed. Check the saved host, port, security, username, password, TLS certificate, and network access.';
} finally {
if ($client !== null) {
try { $client->disconnect(); } catch (\Throwable) {}
}
}
$success = $connected && $errors === [] && count($folders) === 4
&& array_filter($folders, fn ($folder) => !$folder['exists'] || $folder['error'] !== null) === [];
return ['success' => $success, 'connected' => $connected, 'move_supported' => $moveSupported, 'folders' => $folders, 'errors' => $errors];
}
private function paths(object $client): array
{
$paths = [];
foreach ($client->getFolders(false) as $folder) {
$paths[] = $folder->path;
$paths[] = $folder->full_name ?? $folder->path;
}
return $paths;
}
private function exists(string $path, array $paths): bool
{
return in_array($path, $paths, true)
|| (strcasecmp($path, 'INBOX') === 0 && in_array('INBOX', array_map('strtoupper', $paths), true));
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Box\Mod\Mailgateway;
use FOSSBilling\InformationException;
use Webklex\PHPIMAP\IMAP;
/** IMAP transport; deliberately never uses Webklex's MOVE fallback (global EXPUNGE). */
class ImapMailbox
{
private $client;
private $inbox;
private array $folders = [];
public function __construct(private array $settings, private ?\Closure $factory = null) {}
public function connect(): void
{
if ($this->settings['password'] === '') {
throw new InformationException('Mailbox password is missing');
}
if ($this->factory === null) {
if (!is_file(__DIR__ . '/vendor/autoload.php')) {
throw new InformationException('Mail Gateway Composer dependencies are missing');
}
require_once __DIR__ . '/vendor/autoload.php';
}
$config = [
'host' => $this->settings['host'], 'port' => (int) $this->settings['port'],
'protocol' => 'imap', 'encryption' => $this->settings['encryption'],
'validate_cert' => true, 'username' => $this->settings['username'],
'password' => $this->settings['password'], 'authentication' => null, 'timeout' => RuntimeConfig::get('imap_timeout_seconds'),
];
$this->client = $this->factory !== null ? ($this->factory)($config)
: (new \Webklex\PHPIMAP\ClientManager(['options' => ['debug' => false]]))->make($config);
$this->client->connect();
$capabilities = $this->client->getConnection()->getCapabilities()->validatedData();
if (!in_array('MOVE', array_map('strtoupper', $capabilities), true)) {
throw new InformationException('IMAP server must support MOVE');
}
foreach (['inbox_folder', 'processed_folder', 'ignored_folder', 'failed_folder'] as $field) {
$folder = $this->client->getFolderByPath($this->settings[$field]);
if ($folder === null || $folder->no_select) {
throw new InformationException('A configured IMAP folder is missing or cannot be selected');
}
$this->folders[$field] = $folder;
}
$this->inbox = $this->folders['inbox_folder'];
}
public function uids(int $limit): array
{
$query = $this->inbox->query()->leaveUnread()->setSequence(IMAP::ST_UID)->whereUndeleted();
$uids = array_map('intval', $query->search()->toArray());
sort($uids, SORT_NUMERIC);
return array_slice($uids, 0, $limit);
}
public function read(int $uid): array
{
$message = $this->inbox->query()->leaveUnread()->fetchBody(false)->setFetchFlags(false)->getMessageByUid($uid);
$header = $message->getHeader();
$headers = self::rawHeaders($header->raw);
$policy = new InboundPolicy();
$ignored = $policy->ignoreReason($policy->headers(['headers' => $headers])) !== null;
if (!$ignored) {
if ((int) $message->getSize() > RuntimeConfig::get('max_message_bytes')) {
throw new InformationException('Email exceeds the configured import size limit');
}
// Webklex parseBody() fetches RFC822.TEXT then restores Seen flags.
// Fetch with true PEEK instead: even a crash cannot mark mail as read.
$bodies = $this->client->getConnection()->fetch(['BODY.PEEK[TEXT]', 'UID'], [$uid], null, IMAP::ST_UID)->validatedData();
$body = $bodies[$uid]['BODY[TEXT]'] ?? null;
if (!is_string($body)) {
throw new \RuntimeException('IMAP body was not returned');
}
$message->parseRawBody($body);
}
return self::data($message, $this->settings['mailbox'], $headers, $ignored);
}
/** Keep repeated values and the literal null Return-Path lost by address parsers. */
public static function rawHeaders(string $raw): array
{
$raw = preg_replace('/\r?\n[ \t]+/', ' ', $raw);
$headers = [];
foreach (preg_split('/\r?\n/', $raw) as $line) {
if (preg_match('/^([A-Za-z0-9-]+):[ \t]*(.*)$/D', $line, $match)) {
$headers[strtolower($match[1])][] = trim($match[2]);
}
}
return $headers;
}
public static function data(object $message, string $mailbox, array $headers, bool $ignored): array
{
$ids = $headers['message-id'] ?? [];
if (count($ids) > 1) {
throw new InformationException('Email has multiple Message-ID headers');
}
// A stable MIME fingerprint also deduplicates messages without a Message-ID.
$id = $ids[0] ?? '<' . hash('sha256', $message->getHeader()->raw . ($ignored ? '' : $message->getRawBody())) . '@mailgateway.invalid>';
$data = ['mailbox' => $mailbox, 'message_id' => $id, 'headers' => $headers];
if ($ignored) {
return $data;
}
$senders = $message->getFrom()->toArray();
if (count($headers['from'] ?? []) !== 1 || count($senders) !== 1) {
throw new InformationException('Email must have exactly one From address');
}
$sender = $senders[0];
$content = trim($message->getTextBody());
if ($content === '') {
$html = preg_replace('/<(script|style)\b[^>]*>.*?<\/\1\s*>/is', '', $message->getHTMLBody());
$html = preg_replace('/<br\s*\/?\s*>|<\/(?:p|div|li|tr|h[1-6])\s*>/i', "\n", $html);
$content = trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
$subject = trim(mb_decode_mimeheader((string) $message->getSubject()));
return $data + [
'sender_email' => $sender->mail,
'sender_name' => trim(mb_decode_mimeheader($sender->personal)) ?: $sender->mail,
'subject' => $subject === '' ? '(No subject)' : $subject,
'content' => $content,
];
}
public function move(int $uid, string $destination): void
{
$this->client->openFolder($this->inbox->path);
$protocol = $this->client->getConnection();
// Native UID MOVE touches only this message. No COPY/STORE/EXPUNGE fallback.
$response = $protocol->requestAndResponse('UID MOVE', [(string) $uid,
$protocol->escapeString($this->folders[$destination]->path)], true);
if (!$response->boolean()) {
throw new \RuntimeException('IMAP MOVE failed');
}
}
public function disconnect(): void
{
if ($this->client !== null) {
$this->client->disconnect();
}
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Box\Mod\Mailgateway;
use FOSSBilling\InformationException;
class InboundPolicy
{
private const FIELDS = [
'auto-submitted', 'precedence', 'return-path', 'content-type',
'x-auto-response-suppress', 'x-autoreply', 'x-autorespond', 'list-id',
];
/** Normalize parsed MIME headers and supported flat header fields. */
public function headers(array $data): array
{
$raw = $data['headers'] ?? [];
if (!is_array($raw)) {
throw new InformationException('headers must be an array of header values');
}
foreach (self::FIELDS as $field) {
$flat = str_replace('-', '_', $field);
if (array_key_exists($flat, $data)) {
$raw[$field] = array_merge((array) ($raw[$field] ?? []), (array) $data[$flat]);
}
}
$headers = [];
foreach ($raw as $name => $values) {
if (!is_string($name)) {
throw new InformationException('Header names must be strings');
}
$name = strtolower(str_replace('_', '-', $name));
if (!in_array($name, self::FIELDS, true)) {
continue;
}
$values = is_array($values) ? $values : [$values];
if (count($values) > 20) {
throw new InformationException('Too many repeated header values');
}
foreach ($values as $value) {
if (!is_string($value) || strlen($value) > 16384) {
throw new InformationException('Header values must be strings of at most 16384 bytes');
}
$value = preg_replace('/\r?\n[ \t]+/', ' ', $value);
if (preg_match('/[\x00-\x08\x0A-\x1F\x7F]/', $value)) {
throw new InformationException('Invalid control character in header value');
}
$headers[$name][] = strtolower(trim($value));
}
}
ksort($headers);
return $headers;
}
public function ignoreReason(array $headers): ?string
{
foreach ($headers['auto-submitted'] ?? [] as $value) {
if (trim(explode(';', $value, 2)[0]) !== 'no') {
return 'auto_submitted';
}
}
foreach ($headers['return-path'] ?? [] as $value) {
if (preg_match('/^<\s*>$/D', $value)) {
return 'null_return_path';
}
}
foreach ($headers['content-type'] ?? [] as $value) {
if (preg_match('/^multipart\/report\s*(?:;|$)/', $value)
|| preg_match('/^message\/(?:delivery-status|disposition-notification|global-delivery-status)\s*(?:;|$)/', $value)) {
return 'delivery_report';
}
}
foreach ($headers['precedence'] ?? [] as $value) {
if (in_array(trim(explode(';', $value, 2)[0]), ['bulk', 'list', 'junk'], true)) {
return 'bulk_mail';
}
}
foreach ($headers['list-id'] ?? [] as $value) {
if ($value !== '') {
return 'mailing_list';
}
}
foreach ($headers['x-auto-response-suppress'] ?? [] as $value) {
if (preg_match('/(?:^|[\s,;])(?:all|autoreply|oof|dr|ndr|rn|nrn)(?:$|[\s,;])/', $value)) {
return 'response_suppressed';
}
}
foreach (['x-autoreply', 'x-autorespond'] as $field) {
foreach ($headers[$field] ?? [] as $value) {
if (!in_array($value, ['', 'no', 'false', '0'], true)) {
return 'legacy_autoresponder';
}
}
}
return null;
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Box\Mod\Mailgateway;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use FOSSBilling\InformationException;
class MessageTracker
{
public function __construct(private Connection $connection) {}
public function process(array $data, Service $service): array
{
$mailbox = $this->mailbox($data);
$messageId = $this->messageId($data['message_id'] ?? null);
$policy = new InboundPolicy();
$headers = $policy->headers($data);
$ignoreReason = $policy->ignoreReason($headers);
$ticketId = $ignoreReason === null ? $this->subjectTicketId($data['subject'] ?? null) : null;
// Hash only accepted inputs, independent of request field order.
$payload = [];
foreach (['sender_email', 'sender_name', 'subject', 'content'] as $field) {
$value = $data[$field] ?? null;
if ($value !== null && !is_string($value)) {
throw new InformationException($field . ' must be a string');
}
$payload[$field] = $value;
}
$payload['parents'] = []; // Preserve fingerprints of earlier new-ticket imports.
if ($headers !== []) {
$payload['headers'] = $headers;
}
$hash = hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
$key = $this->key($mailbox, $messageId);
$this->assertStandaloneTransaction();
$this->connection->beginTransaction();
try {
$this->connection->insert('mailgateway_message', [
'message_key' => $key, 'mailbox' => $mailbox, 'message_id' => $messageId,
'payload_hash' => $hash,
]);
} catch (UniqueConstraintViolationException) {
$this->connection->rollBack();
$row = $this->connection->fetchAssociative('SELECT * FROM mailgateway_message WHERE message_key = ?', [$key]);
if (!$row || $row['payload_hash'] !== $hash || !$row['result_json']) {
throw new InformationException('Message-ID is already registered with different contents');
}
return array_merge(json_decode($row['result_json'], true, 512, JSON_THROW_ON_ERROR), ['duplicate' => true]);
} catch (\Throwable $error) {
$this->connection->rollBack();
throw $error;
}
try {
if ($ignoreReason !== null) {
$result = ['action' => 'ignored', 'reason' => $ignoreReason];
} elseif ($ticketId !== null) {
$data['ticket_id'] = $ticketId;
$result = $service->reply($data);
$result['action'] = 'reply';
} else {
$result = $service->import($data);
$result['action'] = 'ticket';
}
$result['duplicate'] = false;
$this->connection->update('mailgateway_message', [
'ticket_id' => $result['ticket_id'] ?? null,
'result_json' => json_encode($result, JSON_THROW_ON_ERROR),
], ['message_key' => $key]);
$this->connection->commit();
return $result;
} catch (\Throwable $error) {
if ($this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
// Drop entities whose writes were rolled back, including queued mail.
$service->getDi()['em']->clear();
// Queue rejection notifications only after the import has rolled back.
if ($error instanceof InformationException && in_array($error->getMessage(), [
'Sender does not own this ticket', 'Sender does not uniquely match the ticket owner',
], true)) {
$service->notifySenderMismatch($data['sender_email'], (int) $data['ticket_id']);
}
throw $error;
}
}
private function subjectTicketId(mixed $subject): ?int
{
if (!is_string($subject) || trim($subject) === '' || strlen($subject) > 998
|| !mb_check_encoding($subject, 'UTF-8') || preg_match('/[\x00-\x1F\x7F]/', $subject)) {
throw new InformationException('A non-empty UTF-8 subject without control characters is required (maximum 998 bytes)');
}
preg_match_all('/\[Ticket\b[^\]]*\]/i', $subject, $matches);
if (preg_match('/\[Ticket\b/i', str_replace($matches[0], '', $subject))) {
throw new InformationException('Incomplete ticket reference in subject');
}
$ids = [];
foreach ($matches[0] as $reference) {
if (!preg_match('/^\[Ticket #([1-9][0-9]*)\]$/iD', $reference, $match)
|| filter_var($match[1], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) === false) {
throw new InformationException('Invalid ticket reference in subject; expected [Ticket #123]');
}
$ids[(int) $match[1]] = true;
}
if (count($ids) > 1) {
throw new InformationException('Subject refers to multiple tickets');
}
return $ids ? array_key_first($ids) : null;
}
private function assertStandaloneTransaction(): void
{
if ($this->connection->isTransactionActive()) {
throw new \LogicException('Tracked import requires its own transaction');
}
}
private function mailbox(array $data): string
{
$mailbox = $data['mailbox'] ?? null;
if (!is_string($mailbox) || strlen($mailbox) > 255 || !filter_var($mailbox, FILTER_VALIDATE_EMAIL)) {
throw new InformationException('A valid mailbox address is required');
}
return strtolower($mailbox);
}
private function messageId(mixed $value): string
{
if (!is_string($value) || strlen($value) > 998
|| !preg_match('/^<[^<>\s@]+@[^<>\s@]+>$/D', $value)
|| preg_match('/[^\x21-\x7E]/', $value)) {
throw new InformationException('Message-ID must be a single ASCII <id@domain> value');
}
return $value;
}
private function key(string $mailbox, string $messageId): string
{
return hash('sha256', $mailbox . "\0" . $messageId);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Box\Mod\Mailgateway;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
class NotificationCooldown
{
private int $seconds;
public function __construct(private Connection $connection, ?int $seconds = null)
{
$this->seconds = $seconds ?? RuntimeConfig::get('notification_cooldown_seconds');
}
/** Atomically reserve a sender's notification slot and queue the email. */
public function run(string $sender, callable $queue): bool
{
$key = hash('sha256', strtolower(trim($sender)));
$now = time();
return $this->connection->transactional(function () use ($key, $now, $queue): bool {
try {
$this->connection->insert('mailgateway_notification', ['sender_key' => $key, 'last_notified_at' => 0]);
} catch (UniqueConstraintViolationException) {
// Existing sender; the conditional update decides who owns the slot.
}
$claimed = $this->connection->executeStatement(
'UPDATE mailgateway_notification SET last_notified_at = ? WHERE sender_key = ? AND last_notified_at <= ?',
[$now, $key, $now - $this->seconds],
);
if ($claimed !== 1) {
return false;
}
if ($queue() === false) {
// Disabled templates do not consume the sender's notification slot.
$this->connection->delete('mailgateway_notification', ['sender_key' => $key]);
return false;
}
return true;
});
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Box\Mod\Mailgateway;
/** Used only while Support creates a ticket for an imported message. */
class NotificationEmailProxy
{
public function __construct(private object $email, private \Closure $acknowledge) {}
public function sendTemplate(array $data): mixed
{
if (($data['code'] ?? '') === 'mod_support_ticket_open') {
return ($this->acknowledge)($data);
}
// The canned reply remains in the ticket. Send the ticket-number
// acknowledgement instead of a second automatic canned-reply email.
if (($data['code'] ?? '') === 'mod_support_ticket_staff_reply') {
return false;
}
return $this->email->sendTemplate($data);
}
public function __call(string $method, array $arguments): mixed
{
return $this->email->$method(...$arguments);
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Box\Mod\Mailgateway;
use Pimple\Container;
class NotificationSettings
{
private const CODES = ['mod_support_ticket_open', 'mod_support_ticket_staff_reply',
'mod_support_ticket_staff_open', 'mod_support_ticket_staff_close'];
public function __construct(private Container $di) {}
private function hasMarker(string $subject): bool
{
return (bool) preg_match('/\[Ticket\s+#\s*{{\s*ticket\.id\s*}}\s*\]/i', $subject);
}
public function repair(): array
{
$email = $this->di['mod_service']('email');
foreach (self::CODES as $code) {
$template = $email->getTemplateRepository()->findOneByActionCode($code);
if ($template === null) {
$source = file_get_contents(dirname(__DIR__) . '/Support/templates/email/' . $code . '.html.twig');
if (!is_string($source)
|| !preg_match('/{%\s*block subject\s*%}(.*?){%\s*endblock\s*%}/s', $source, $subject)
|| !preg_match('/{%\s*block content\s*%}(.*?){%\s*endblock\s*%}/s', $source, $content)) {
throw new \FOSSBilling\InformationException("Cannot configure $code: its default template is missing or invalid.");
}
$template = $email->templateCreate($code, trim($subject[1]), trim($content[1]),
$code === 'mod_support_ticket_open' ? 1 : 0, 'support');
}
$data = $email->templateToApiArray($template);
if ($code !== 'mod_support_ticket_open' && !$data['enabled']) {
continue;
}
$subject = $data['subject'] ?? '';
$enabled = $code === 'mod_support_ticket_open' ? true : null;
if (!$this->hasMarker($subject)) {
// Keep the existing subject and body, adding only the routing marker.
$email->updateTemplate($template, $enabled, null, '[Ticket #{{ ticket.id }}] ' . $subject);
} elseif ($code === 'mod_support_ticket_open' && !$data['enabled']) {
$email->updateTemplate($template, true);
}
}
return $this->status();
}
public function status(): array
{
$email = $this->di['mod_service']('email');
$errors = [];
foreach (self::CODES as $code) {
$template = $email->getTemplateRepository()->findOneByActionCode($code);
if ($template === null) {
$errors[] = "Open FOSSBilling Email settings and configure $code before enabling importing.";
continue;
}
$data = $email->templateToApiArray($template);
if (!$data['enabled']) {
if ($code === 'mod_support_ticket_open') {
$errors[] = 'Enable mod_support_ticket_open in FOSSBilling Email settings to send ticket acknowledgements.';
}
continue;
}
// Check the documented dynamic marker without executing custom template code.
if (!$this->hasMarker($data['subject'] ?? '')) {
$errors[] = "Include [Ticket #{{ ticket.id }}] in the subject of $code in FOSSBilling Email settings.";
}
}
return ['ready' => $errors === [], 'errors' => $errors];
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Box\Mod\Mailgateway;
use FOSSBilling\InformationException;
use Pimple\Container;
class Poller
{
private const LOCK = 'fossbilling_mailgateway_poll';
public function __construct(
private Container $di,
private Service $service,
private ?\Closure $factory = null,
private ?\Closure $clock = null,
private ?\Closure $lock = null,
) {}
public function poll(bool $force = false): array
{
if (!$this->locking(true)) {
return ['status' => 'busy', 'mailboxes' => []];
}
try {
$store = $this->di['mailgateway_settings'] ?? new Settings($this->di);
$mailboxes = array_filter($store->getAll(), fn ($settings) => $settings['enabled']);
if ($mailboxes === []) {
return ['status' => 'disabled', 'mailboxes' => []];
}
$system = $this->di['mod_service']('system');
$now = $this->now();
$last = (int) $system->getParamValue('mailgateway_last_poll', 0);
if (!$force && $last > $now - RuntimeConfig::get('poll_interval_seconds')) {
return ['status' => 'not_due', 'mailboxes' => []];
}
// Record the attempt before network work, so a failing server is not hammered.
$system->setParamValue('mailgateway_last_poll', (string) $now, true);
$results = [];
$deadline = microtime(true) + RuntimeConfig::get('poll_budget_seconds');
foreach ($mailboxes as $id => $settings) {
if (microtime(true) >= $deadline) {
break;
}
$results[$id] = $this->mailbox($id, $settings, $deadline);
}
return ['status' => 'completed', 'mailboxes' => $results];
} finally {
$this->locking(false);
}
}
private function mailbox(string $id, array $settings, float $deadline): array
{
$counts = ['ticket' => 0, 'reply' => 0, 'ignored' => 0, 'duplicate' => 0, 'failed' => 0, 'retry_pending' => 0, 'move_pending' => 0];
$transport = null;
$errors = [];
$stage = 'connecting';
try {
$transport = $this->factory !== null ? ($this->factory)($settings) : new ImapMailbox($settings);
$transport->connect();
$stage = 'listing messages';
foreach ($transport->uids(RuntimeConfig::get('messages_per_mailbox')) as $uid) {
if (microtime(true) >= $deadline) {
break;
}
try {
$stage = 'reading email';
$data = $transport->read((int) $uid);
$stage = 'importing email';
$result = (new MessageTracker($this->di['dbal']))->process($data, $this->service);
$counts[$result['duplicate'] ? 'duplicate' : $result['action']]++;
$destination = $result['action'] === 'ignored' ? 'ignored_folder' : 'processed_folder';
} catch (InformationException $error) {
$errors[] = $this->diagnostic($error, $stage, (int) $uid);
$counts['failed']++;
$destination = 'failed_folder';
$this->warning($id, (int) $uid, 'message rejected; moving to Failed');
} catch (\Throwable $error) {
$errors[] = $this->diagnostic($error, $stage, (int) $uid);
// Infrastructure/fetch failures stay in the inbox for a later attempt.
$counts['retry_pending']++;
$this->warning($id, (int) $uid, 'import unavailable; left in inbox for retry');
continue;
}
try {
$stage = 'moving email';
$transport->move((int) $uid, $destination);
} catch (\Throwable $error) {
$errors[] = $this->diagnostic($error, $stage, (int) $uid);
// Import already committed. Tracking prevents a second ticket on retry.
$counts['move_pending']++;
$this->warning($id, (int) $uid, 'folder move failed; retry will use message tracking');
}
}
return ['status' => 'completed'] + $counts + ($errors === [] ? [] : ['errors' => $errors]);
} catch (\Throwable $error) {
$errors[] = $this->diagnostic($error, $stage, null);
$this->warning($id, null, 'mailbox poll failed; check credentials, TLS, folders, network access, and server MOVE support');
return ['status' => 'failed'] + $counts + ['errors' => $errors];
} finally {
if ($transport !== null) {
try { $transport->disconnect(); } catch (\Throwable) {
$this->warning($id, null, 'IMAP logout failed');
}
}
}
}
private function diagnostic(\Throwable $error, string $stage, ?int $uid): array
{
// A location identifies code faults without exposing exception messages/arguments.
return ['uid' => $uid, 'stage' => $stage, 'exception' => $error::class,
'file' => basename($error->getFile()), 'line' => $error->getLine()];
}
private function warning(string $id, ?int $uid, string $text): void
{
// Never log IMAP exception messages/traces, credentials, sender, subject or body.
$this->di['logger']->warning('Mail Gateway mailbox ' . $id . ($uid !== null ? ' UID ' . $uid : '') . ': ' . $text);
}
private function now(): int
{
return $this->clock !== null ? ($this->clock)() : time();
}
private function locking(bool $acquire): bool
{
if ($this->lock !== null) {
return ($this->lock)($acquire);
}
$sql = $acquire ? 'SELECT GET_LOCK(?, 0)' : 'SELECT RELEASE_LOCK(?)';
return (int) $this->di['dbal']->fetchOne($sql, [self::LOCK]) === 1;
}
}
+190
View File
@@ -0,0 +1,190 @@
# Mail Gateway for FOSSBilling
Mail Gateway imports email into FOSSBilling support tickets using the existing
FOSSBilling cron. Configure multiple IMAP mailboxes and route each one to a
separate helpdesk.
## Features
- Create customer tickets from recognised email addresses and guest tickets from unknown senders.
- Add replies to existing tickets using the ticket number in the subject.
- Check that the sender owns the ticket before accepting a reply.
- Filter recognised automatic messages and prevent duplicate imports.
- Move handled emails into Processed, Ignored or Failed folders.
- Manage mailbox connections and test them from the admin settings page.
## Requirements
- FOSSBilling with the Support module enabled and its cron running regularly.
- An IMAP server supporting native `MOVE` and a valid TLS certificate when using TLS.
- A receiving mail server with appropriate email authentication and anti-spoofing policies.
- The `webklex/php-imap` Composer package and its required PHP extensions.
The native PHP IMAP extension is not required. Composer checks dependency
requirements when installing. For installation through the settings page, the
web server also needs access to PHP CLI, Composer, `proc_open`, a writable module
directory and package downloads. If web installation is unavailable or the module
directory is deliberately not writable by the web server, installation over SSH
is the preferred fallback. Run this from the module directory as its owning user:
```bash
composer install --no-dev --prefer-dist --no-interaction --no-scripts --no-plugins --optimize-autoloader
```
This uses the supplied lock file. The web server still needs read access to the
installed dependencies; it does not need write access to them for importing.
## Installation
1. Place the module in `modules/Mailgateway`.
2. Activate Mail Gateway in FOSSBilling. Activation creates the module tables,
registers its sender-mismatch email template and attempts to install dependencies.
3. Open Mail Gateway's settings from the Extensions page. If dependencies were
not installed automatically, use **Install dependencies** or run
`composer install` in the module directory.
4. Configure the receiving email address, helpdesk, IMAP connection details and
inbox and destination folders, then save.
5. Select **Test Connection and Create Folders**. This verifies the connection
and creates missing destination folders without processing emails.
6. After a successful test, enable mailbox importing and save again. This also
configures ticket acknowledgement and notification subjects automatically.
**Notification template changes persist:** enabling importing or running
**Configure ticket notifications** enables the ticket acknowledgement and adds
missing ticket references to existing FOSSBilling notification subjects. These
changes apply to those templates throughout FOSSBilling. Disabling or uninstalling
Mail Gateway does not restore their original subjects or enabled state. If you
want to restore them later, keep a copy of the original settings and edit the
templates manually in FOSSBilling's Email settings.
Use **Add mailbox** to configure additional addresses. Each mailbox has its own
connection details, folders and helpdesk. All four folder paths must be distinct;
the inbox must already exist.
Importing remains disabled until dependencies are installed and the mailbox has
passed its connection and folder test. Changing connection details or folder
paths requires another successful test. The settings page shows the results and
explains any failures.
No additional cron entry is needed. The module polls every five minutes by
default, provided the FOSSBilling cron runs that often.
## Tickets and replies
Email from a customer's primary email address creates a customer ticket.
Unknown senders create guest tickets when guest tickets are enabled in Support.
New tickets go to the mailbox's selected helpdesk.
Replies are matched by a subject marker such as `[Ticket #123]`. The sender must
own the referenced ticket. Emails without a ticket marker create a new ticket;
reply headers are not used for matching.
Sender matching uses the email's From address; it does not prove that the sender
controls that address. Mail Gateway does not independently verify SPF, DKIM or
DMARC, and relies on the receiving mail server to apply authentication and
anti-spoofing policies before mail reaches the imported inbox. Configure those
policies to reject or quarantine suspicious messages outside that inbox.
A forged message accepted into the inbox could impersonate a customer or the
owner of a guest ticket. Guest reply ownership is based on the original sender's
email address, without an authenticated customer account.
Enabling mailbox importing automatically enables the `mod_support_ticket_open`
acknowledgement and adds `[Ticket #{{ ticket.id }}]` to its subject and enabled
staff notification subjects where missing. Existing wording is preserved and
disabled optional staff notifications stay disabled. Notifications are delivered
through FOSSBilling's email queue.
For an existing mailbox, use **Advanced → Configure ticket notifications** to run
the same setup. Notification status is shown in Advanced, with any problems also
displayed at the top of the page. Setup does not send test emails. You can edit
notification wording in FOSSBilling's Email settings; keep the ticket marker in
subjects so replies can be matched.
The included `mailgateway_sender_mismatch` template uses your company name and
email signature to tell senders when their address does not match the referenced
ticket. It is installed only if missing. Its wording can be edited or the
notification disabled in Email settings. Existing custom templates are preserved.
Messages use plain text when available, with HTML converted to text otherwise.
Quoted reply history is retained. Attachments stay in the archived email and are
not attached to tickets.
## Email handling
| Result | Destination |
| --- | --- |
| Imported ticket, reply or duplicate | Processed |
| Recognised automatic message | Ignored |
| Invalid message or rejected reply | Failed |
| Temporary connection or database failure | Inbox, for retry |
Only the configured inbox is imported, including already-read messages.
Duplicate tracking prevents repeated imports, including when a folder move needs
to be retried. To retry a rejected email after resolving its cause, move it from
Failed back to the inbox.
Recognised automatic messages receive no response. Unmarked automatic messages
cannot always be identified; a notification cooldown limits automated
acknowledgements and sender-mismatch notices to one per sender per hour by default.
## Configuration and maintenance
Mailbox settings are stored in the database, with passwords encrypted using
FOSSBilling's encryption helper. Leaving a password blank keeps the saved value;
use the password-clearing option to remove it.
Operational settings are in `config.php`, including polling limits, timeouts,
message size limits, notification cooldown and executable paths. Edit this file
over SSH; changes take effect on the next cron run or web request. By default,
each run handles up to 50 messages per mailbox, with a shared 90-second time
budget. Complete emails are limited to 5 MiB and ticket bodies to 65,535 bytes.
The settings page's **Advanced** section provides notification setup, Composer
installation and table setup. Table setup creates missing tables and the default
mismatch template without deleting existing data or replacing customised templates.
Deleting a mailbox removes its configuration while keeping emails, folders,
tickets and message tracking.
The following commands are available from the module directory:
```bash
# Test a saved mailbox without processing emails.
php tools/test-imap.php
# Test another mailbox and create missing destination folders.
php tools/test-imap.php --mailbox=billing@example.com --create-folders
# Import mail immediately.
php tools/poll.php --force
# Create missing tables and register the default email template.
php tools/setup.php
```
A connection test with `--create-folders` records the mailbox's readiness, just as
the settings-page test does. A read-only test does not change readiness.
## Compatibility note
The module loads its own Composer dependencies into the same PHP process as
FOSSBilling. Shared Symfony or Illuminate packages are not isolated by the module's
separate `vendor` directory. Check installation, connection testing and importing
on your FOSSBilling version before deploying; compatibility across clean
FOSSBilling installations and versions has not yet been established.
During imported ticket creation, the module temporarily wraps FOSSBilling's
`mod_service` factory to intercept email calls. This applies the acknowledgement
cooldown and suppresses the separate canned-response email while retaining its
ticket history. Other module lookups are delegated to the original factory,
which is restored in a `finally` block, including when creation fails.
Ticket-creation hooks also see this wrapped factory: email services obtained
during that scope are proxies, and matching notification codes are intercepted.
The installed FOSSBilling notification interfaces provide no per-ticket
suppression option. This integration should be reviewed when upgrading
FOSSBilling or adding hooks that send emails during ticket creation.
## License
Apache-2.0.
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Box\Mod\Mailgateway;
class RuntimeConfig
{
public static function get(string $key): mixed
{
static $settings = null;
$settings ??= self::load(__DIR__ . '/config.php');
return $settings[$key] ?? throw new \RuntimeException('Unknown Mail Gateway operational setting: ' . $key);
}
public static function load(string $path): array
{
$settings = require $path;
if (!is_array($settings)) {
throw new \RuntimeException('Mail Gateway config.php must return an array');
}
foreach (['poll_interval_seconds', 'messages_per_mailbox', 'poll_budget_seconds',
'imap_timeout_seconds', 'max_message_bytes', 'max_ticket_body_bytes',
'notification_cooldown_seconds', 'composer_timeout_seconds'] as $key) {
if (!isset($settings[$key]) || !is_int($settings[$key]) || $settings[$key] < 1) {
throw new \RuntimeException('Mail Gateway config.php requires a positive integer for ' . $key);
}
}
if ($settings['max_ticket_body_bytes'] > 65535) {
throw new \RuntimeException('Mail Gateway ticket body limit cannot exceed 65535 bytes');
}
foreach (['php_cli_paths', 'composer_paths'] as $key) {
if (!isset($settings[$key]) || !is_array($settings[$key]) || $settings[$key] === []) {
throw new \RuntimeException('Mail Gateway config.php requires executable paths for ' . $key);
}
foreach ($settings[$key] as $value) {
if (!is_string($value) || !str_starts_with($value, '/') || str_contains($value, "\0")) {
throw new \RuntimeException('Mail Gateway executable paths must be absolute paths');
}
}
}
return $settings;
}
}
+402
View File
@@ -0,0 +1,402 @@
<?php
declare(strict_types=1);
namespace Box\Mod\Mailgateway;
use Box\Mod\Support\Entity\Helpdesk;
use Box\Mod\Support\Entity\SupportTicket;
use FOSSBilling\InformationException;
use FOSSBilling\Tools;
use Pimple\Container;
class Service implements \FOSSBilling\InjectionAwareInterface
{
protected ?Container $di = null;
public function setDi(Container $di): void
{
$this->di = $di;
}
public function getDi(): ?Container
{
return $this->di;
}
public function getModulePermissions(): array
{
return ['manage_settings' => [
'type' => 'bool', 'display_name' => 'Manage Mail Gateway settings',
'description' => 'Configure the inbound mailbox and ticket routing.',
]];
}
public function getSettings(): array
{
return $this->settingsStore()->getPublic();
}
public function getMailboxes(): array
{
return $this->settingsStore()->getAllPublic();
}
public function getDependencyStatus(): array
{
return ($this->di['mailgateway_dependency_installer'] ?? new DependencyInstaller())->status();
}
public function installDependencies(bool $force = false): array
{
// Share the poller's lock so imports cannot run while vendor is being written.
if ((int) $this->di['dbal']->fetchOne('SELECT GET_LOCK(?, 0)', ['fossbilling_mailgateway_poll']) !== 1) {
throw new InformationException('Mail Gateway is busy. Try installing dependencies again after polling finishes.');
}
try {
return ($this->di['mailgateway_dependency_installer'] ?? new DependencyInstaller())->install($force);
} finally {
$this->di['dbal']->fetchOne('SELECT RELEASE_LOCK(?)', ['fossbilling_mailgateway_poll']);
}
}
public function testConnection(array $data): array
{
$id = $data['mailbox_id'] ?? 'default';
if (!is_string($id)) {
throw new \FOSSBilling\InformationException('Mailbox not found');
}
$store = $this->settingsStore();
$settings = $store->get($id);
$result = ($this->di['mailgateway_connection_test'] ?? new ImapConnectionTest())->test($settings, true);
$store->recordTest($id, $settings, $result);
return $result;
}
public function saveSettings(array $data): bool
{
if (in_array($data['enabled'] ?? false, [true, 1, '1'], true)) {
$status = $this->configureNotifications();
if (!$status['ready']) {
throw new InformationException(implode(' ', $status['errors']));
}
}
return $this->settingsStore()->save($data);
}
public function getNotificationStatus(): array
{
return (new NotificationSettings($this->di))->status();
}
public function configureNotifications(): array
{
return (new NotificationSettings($this->di))->repair();
}
public function deleteMailbox(array $data): bool
{
$id = $data['mailbox_id'] ?? null;
if (!is_string($id) || $id === '') {
throw new InformationException('Mailbox ID is required');
}
// Prevent a removed mailbox from being imported by an already-running poll.
if ((int) $this->di['dbal']->fetchOne('SELECT GET_LOCK(?, 0)', ['fossbilling_mailgateway_poll']) !== 1) {
throw new InformationException('Mail Gateway is busy. Try deleting the mailbox after polling finishes.');
}
try {
return $this->settingsStore()->delete($id);
} finally {
$this->di['dbal']->fetchOne('SELECT RELEASE_LOCK(?)', ['fossbilling_mailgateway_poll']);
}
}
private function settingsStore(): object
{
return $this->di['mailgateway_settings'] ?? new Settings($this->di);
}
public function poll(bool $force = false): array
{
return (new Poller($this->di, $this))->poll($force);
}
public static function onBeforeAdminCronRun(\Box_Event $event): void
{
$di = $event->getDi();
try {
$result = $di['mod_service']('mailgateway')->poll();
if ($result['status'] === 'completed') {
$di['logger']->info('Mail Gateway polling: ' . json_encode($result, JSON_THROW_ON_ERROR));
}
} catch (\Throwable) {
// A mail gateway outage must not interrupt billing or the outgoing queue.
$di['logger']->warning('Mail Gateway polling unavailable; FOSSBilling cron will continue');
}
}
private function setupTables(): void
{
foreach (explode(';', file_get_contents(__DIR__ . '/schema.sql')) as $statement) {
if (trim($statement) !== '') {
$this->di['dbal']->executeStatement($statement);
}
}
}
private function setupTemplates(): void
{
($this->di['mailgateway_template_installer'] ?? new TemplateInstaller($this->di))->install();
}
public function setup(): bool
{
if ((int) $this->di['dbal']->fetchOne('SELECT GET_LOCK(?, 0)', ['fossbilling_mailgateway_poll']) !== 1) {
throw new InformationException('Mail Gateway is busy. Try setup again after polling finishes.');
}
try {
$this->setupTables();
$this->setupTemplates();
return true;
} finally {
$this->di['dbal']->fetchOne('SELECT RELEASE_LOCK(?)', ['fossbilling_mailgateway_poll']);
}
}
public function install(): bool
{
$this->setupTables();
$this->setupTemplates();
try {
$this->installDependencies();
} catch (\Throwable) {
// Keep the settings page reachable if the host cannot install Composer packages.
// Dependency status there provides the install button or SSH instructions.
$this->di['logger']->warning('Mail Gateway tables are ready, but dependencies could not be installed automatically. Open Mail Gateway settings to finish installation.');
}
return true;
}
/** Create a customer or guest ticket from a decoded inbound message. */
public function import(array $data): array
{
$policy = new InboundPolicy();
$reason = $policy->ignoreReason($policy->headers($data));
if ($reason !== null) {
return ['action' => 'ignored', 'reason' => $reason];
}
$sender = $this->senderEmail($data);
$subject = $this->requiredString($data, 'subject');
if (!mb_check_encoding($subject, 'UTF-8') || mb_strlen($subject, 'UTF-8') > 255
|| preg_match('/[\x00-\x1F\x7F]/', $subject)) {
throw new InformationException('Subject must be valid UTF-8, at most 255 characters, with no control characters');
}
$content = $this->messageContent($data);
$senderName = isset($data['sender_name']) ? $this->requiredString($data, 'sender_name') : $sender;
if (!mb_check_encoding($senderName, 'UTF-8') || mb_strlen($senderName, 'UTF-8') > 255
|| preg_match('/[\x00-\x1F\x7F]/', $senderName)) {
throw new InformationException('Sender name must be valid UTF-8, at most 255 characters, with no control characters');
}
// Exact mailbox matching, case-insensitive; never use LIKE or choose the first match.
$clients = $this->di['dbal']->fetchFirstColumn(
'SELECT id FROM client WHERE LOWER(email) = LOWER(:email) LIMIT 2',
['email' => $sender],
);
if (count($clients) > 1) {
throw new InformationException('Multiple customers match sender_email');
}
$support = $this->di['mod_service']('support');
$store = $this->settingsStore();
$settings = isset($data['mailbox']) && $store instanceof Settings
? $store->forAddress($data['mailbox']) : $store->get();
$helpdeskId = $settings['support_helpdesk_id'];
$helpdesks = $helpdeskId !== null
? [$support->getHelpdeskRepository()->find((int) $helpdeskId)]
: $support->getHelpdeskRepository()->findBy(['name' => 'General'], null, 2);
if (count($helpdesks) !== 1 || !$helpdesks[0] instanceof Helpdesk) {
throw new InformationException('Choose an existing helpdesk in Mail Gateway settings, or configure exactly one General helpdesk');
}
if ($clients === []) {
// Guest creation sends its own acknowledgement without a client
// session. Keep its access hash private; return only the ticket ID.
$hash = $this->withNotificationCooldown($sender, fn () => $support->ticketCreateForGuest([
'name' => $senderName,
'email' => $sender,
'subject' => $subject,
'content' => $content,
'support_helpdesk_id' => $helpdesks[0]->getId(),
]));
$ticket = $support->findOneByHash($hash);
return ['ticket_id' => $ticket->getId(), 'client_id' => null, 'support_helpdesk_id' => $helpdesks[0]->getId()];
}
$client = $this->di['db']->getExistingModelById('Client', (int) $clients[0]);
if (!$client instanceof \Model_Client) {
throw new InformationException('Customer identity could not be loaded');
}
$id = $this->withNotificationCooldown($sender, fn () => $support->ticketCreateForClient($client, $helpdesks[0], [
'subject' => $subject,
'content' => $content,
]));
// A canned acknowledgement counts as an admin reply in Support and
// puts the ticket on hold. Incoming mail still needs staff attention.
$ticket = $support->getTicketById($id);
if ($ticket->getStatus() === SupportTicket::STATUS_ONHOLD) {
$ticket->setStatus(SupportTicket::STATUS_OPEN);
$this->di['em']->flush();
}
// Support's normal notification hook requires a customer session.
// Cron imports have no customer session.
if (!$this->di['auth']->isClientLoggedIn()) {
$this->queueAutomaticTemplate($sender, [
'to_client' => (int) $client->id,
'code' => 'mod_support_ticket_open',
'ticket' => $support->toApiArray($ticket, true, $client),
]);
}
return ['ticket_id' => $id, 'client_id' => (int) $client->id, 'support_helpdesk_id' => $helpdesks[0]->getId()];
}
/** Reply to an explicitly identified ticket; never infer ownership from a subject. */
public function reply(array $data): array
{
$policy = new InboundPolicy();
$reason = $policy->ignoreReason($policy->headers($data));
if ($reason !== null) {
return ['action' => 'ignored', 'reason' => $reason];
}
$sender = $this->senderEmail($data);
$content = $this->messageContent($data);
$rawId = $data['ticket_id'] ?? null;
if ((!is_int($rawId) && !is_string($rawId))
|| filter_var($rawId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) === false) {
throw new InformationException('A positive integer ticket_id is required');
}
$support = $this->di['mod_service']('support');
$ticket = $support->getTicketById((int) $rawId);
if ($ticket->isGuestTicket()) {
if (strcasecmp($sender, (string) $ticket->getAuthorEmail()) !== 0) {
throw new InformationException('Sender does not own this ticket');
}
$identity = new \FOSSBilling\Identity\Guest();
} else {
$ids = $this->di['dbal']->fetchFirstColumn(
'SELECT id FROM client WHERE LOWER(email) = LOWER(:email) LIMIT 2',
['email' => $sender],
);
if (count($ids) !== 1 || (int) $ids[0] !== $ticket->getClientId()) {
throw new InformationException('Sender does not uniquely match the ticket owner');
}
$identity = $this->di['db']->getExistingModelById('Client', (int) $ids[0]);
if (!$identity instanceof \Model_Client) {
throw new InformationException('Customer identity could not be loaded');
}
}
if (!$support->canBeReopened($ticket)) {
throw new InformationException('Ticket cannot be reopened');
}
$messageId = $support->ticketReply($ticket, $identity, $content);
return ['ticket_id' => $ticket->getId(), 'message_id' => $messageId];
}
public function notifySenderMismatch(string $sender, int $ticketId): void
{
try {
$this->queueAutomaticTemplate($sender, [
'to' => $sender,
'code' => 'mailgateway_sender_mismatch',
'sender_email' => $sender,
// Only echo the reference supplied by the sender; do not pass
// the ticket's details, owner identity, or guest access hash.
'ticket_id' => $ticketId,
]);
} catch (\Exception) {
// Notification failure must never permit the rejected reply.
$this->di['logger']->warning('Mail Gateway could not queue the sender mismatch notification');
}
}
private function senderEmail(array $data): string
{
$sender = $this->requiredString($data, 'sender_email');
if (strlen($sender) > 255 || !filter_var($sender, FILTER_VALIDATE_EMAIL)) {
throw new InformationException('A valid sender_email is required');
}
return $sender;
}
private function queueAutomaticTemplate(string $sender, array $data): bool
{
// An injectable limiter permits isolated tests without a live database.
$limiter = $this->di['mailgateway_notification_cooldown'] ?? new NotificationCooldown($this->di['dbal']);
return $limiter->run($sender, fn () => $this->di['mod_service']('email')->sendTemplate($data));
}
private function withNotificationCooldown(string $sender, callable $create): mixed
{
$lookup = $this->di['mod_service'];
$proxy = null;
unset($this->di['mod_service']);
$this->di['mod_service'] = $this->di->protect(function ($module, $sub = '') use ($lookup, $sender, &$proxy) {
if (strtolower($module) !== 'email' || $sub !== '') {
return $lookup($module, $sub);
}
if ($proxy === null) {
$email = $lookup($module, $sub);
$proxy = new NotificationEmailProxy($email, function ($data) use ($sender, $email) {
$limiter = $this->di['mailgateway_notification_cooldown'] ?? new NotificationCooldown($this->di['dbal']);
return $limiter->run($sender, fn () => $email->sendTemplate($data));
});
}
return $proxy;
});
try {
return $create();
} finally {
unset($this->di['mod_service']);
$this->di['mod_service'] = $this->di->protect($lookup);
}
}
private function messageContent(array $data): string
{
$content = $this->requiredString($data, 'content');
// Support stores this in a TEXT column (maximum 65,535 bytes).
if (!mb_check_encoding($content, 'UTF-8') || strlen($content) > RuntimeConfig::get('max_ticket_body_bytes')) {
throw new InformationException('Content must be valid UTF-8 and within the configured ticket body limit');
}
$content = Tools::sanitizeMarkdownContent($content);
if ($content === '') {
throw new InformationException('Content must not be empty');
}
return $content;
}
private function requiredString(array $data, string $key): string
{
if (!isset($data[$key]) || !is_string($data[$key]) || trim($data[$key]) === '') {
throw new InformationException($key . ' must be a non-empty string');
}
return trim($data[$key]);
}
}
+267
View File
@@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
namespace Box\Mod\Mailgateway;
use Box\Mod\Support\Entity\Helpdesk;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use FOSSBilling\InformationException;
use Pimple\Container;
class Settings
{
public const DEFAULTS = [
'enabled' => false, 'host' => '', 'port' => 993,
'encryption' => 'ssl', 'validate_cert' => true,
'username' => '', 'password' => '',
'mailbox' => '', 'inbox_folder' => 'INBOX',
'processed_folder' => 'Processed', 'ignored_folder' => 'Ignored',
'failed_folder' => 'Failed', 'support_helpdesk_id' => null,
'tested_config_hash' => null, 'test_result' => null,
];
public function __construct(private Container $di) {}
private function all(): array
{
try {
$encrypted = $this->di['dbal']->fetchOne('SELECT config_encrypted FROM mailgateway_settings WHERE id = 1');
} catch (\Doctrine\DBAL\Exception\TableNotFoundException) {
// Keep Advanced setup reachable if the module tables need creating.
return ['default' => self::DEFAULTS];
}
if ($encrypted === false) {
return ['default' => self::DEFAULTS];
}
$plain = $this->di['crypt']->decrypt($encrypted);
if (!is_string($plain)) {
throw new InformationException('Mail Gateway settings could not be decrypted');
}
$decoded = json_decode($plain, true, 512, JSON_THROW_ON_ERROR);
$mailboxes = $decoded['mailboxes'] ?? ['default' => $decoded];
return array_map(function ($settings) {
// Older configurations stored this unused operational value per mailbox.
unset($settings['poll_interval_minutes']);
$settings = array_replace(self::DEFAULTS, $settings);
if (!$this->verified($settings)) {
$settings['enabled'] = false;
}
return $settings;
}, $mailboxes);
}
/** Internal polling configuration, including encrypted-at-rest credentials. */
public function getAll(): array
{
return $this->all();
}
public function get(?string $id = null): array
{
$mailboxes = $this->all();
if ($id === null) {
return $mailboxes['default'] ?? (reset($mailboxes) ?: self::DEFAULTS);
}
return $mailboxes[$id] ?? throw new InformationException('Mailbox not found');
}
public function getAllPublic(): array
{
return array_map(fn ($settings) => $this->publicSettings($settings), $this->all());
}
public function delete(string $id): bool
{
return $this->di['dbal']->transactional(function ($connection) use ($id): bool {
if ($connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) {
$connection->fetchOne('SELECT id FROM mailgateway_settings WHERE id = 1 FOR UPDATE');
}
$mailboxes = $this->all();
if (!isset($mailboxes[$id])) {
throw new InformationException('Mailbox not found');
}
unset($mailboxes[$id]);
$encrypted = $this->di['crypt']->encrypt(json_encode(['mailboxes' => $mailboxes], JSON_THROW_ON_ERROR));
$connection->update('mailgateway_settings', ['config_encrypted' => $encrypted], ['id' => 1]);
return true;
});
}
private function publicSettings(array $settings): array
{
$settings['password_configured'] = $settings['password'] !== '';
$settings['test_passed'] = $this->verified($settings);
unset($settings['password'], $settings['tested_config_hash']);
return $settings;
}
private function fingerprint(array $settings): string
{
$connection = array_intersect_key($settings, array_flip(['host', 'port', 'encryption', 'username',
'password', 'mailbox', 'inbox_folder', 'processed_folder', 'ignored_folder', 'failed_folder']));
ksort($connection);
return hash('sha256', json_encode($connection, JSON_THROW_ON_ERROR));
}
private function verified(array $settings): bool
{
return is_string($settings['tested_config_hash'])
&& hash_equals($settings['tested_config_hash'], $this->fingerprint($settings));
}
public function recordTest(string $id, array $testedSettings, array $result): bool
{
return $this->di['dbal']->transactional(function ($connection) use ($id, $testedSettings, $result): bool {
if ($connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) {
$connection->fetchOne('SELECT id FROM mailgateway_settings WHERE id = 1 FOR UPDATE');
}
$mailboxes = $this->all();
if (!isset($mailboxes[$id]) || $this->fingerprint($mailboxes[$id]) !== $this->fingerprint($testedSettings)) {
throw new InformationException('Mailbox settings changed during the test. Save and test again.');
}
$mailboxes[$id]['tested_config_hash'] = $result['success'] ? $this->fingerprint($testedSettings) : null;
$mailboxes[$id]['test_result'] = $result;
if (!$result['success']) {
$mailboxes[$id]['enabled'] = false;
}
$encrypted = $this->di['crypt']->encrypt(json_encode(['mailboxes' => $mailboxes], JSON_THROW_ON_ERROR));
$connection->update('mailgateway_settings', ['config_encrypted' => $encrypted], ['id' => 1]);
return true;
});
}
public function forAddress(string $address): array
{
foreach ($this->all() as $settings) {
if (strcasecmp($settings['mailbox'], $address) === 0) {
return $settings;
}
}
throw new InformationException('Receiving mailbox is not configured');
}
public function getPublic(): array
{
return $this->publicSettings($this->get());
}
public function save(array $data): bool
{
return $this->di['dbal']->transactional(function ($connection) use ($data): bool {
// Lock before reading so concurrent saves cannot overwrite another mailbox.
if ($connection->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\AbstractMySQLPlatform) {
$connection->fetchOne('SELECT id FROM mailgateway_settings WHERE id = 1 FOR UPDATE');
}
return $this->saveMailbox($data);
});
}
private function saveMailbox(array $data): bool
{
$mailboxes = $this->all();
$id = $data['mailbox_id'] ?? 'default';
if (!is_string($id) || ($id !== 'new' && !isset($mailboxes[$id]))) {
throw new InformationException('Mailbox not found');
}
$old = $id === 'new' ? self::DEFAULTS : $mailboxes[$id];
$settings = self::DEFAULTS;
$settings['enabled'] = $this->boolean($data['enabled'] ?? false);
if ($settings['enabled'] && !($this->di['mailgateway_dependency_installer'] ?? new DependencyInstaller())->status()['installed']) {
throw new InformationException('Install Mail Gateway dependencies before enabling mailbox importing');
}
foreach (['host', 'username', 'mailbox', 'inbox_folder', 'processed_folder', 'ignored_folder', 'failed_folder'] as $field) {
if (!isset($data[$field]) || !is_string($data[$field])) {
throw new InformationException($field . ' is required');
}
$value = trim($data[$field]);
if ($value === '' || mb_strlen($value, 'UTF-8') > 255 || !mb_check_encoding($value, 'UTF-8')
|| preg_match('/[\x00-\x1F\x7F]/', $value)) {
throw new InformationException($field . ' must be a non-empty value of at most 255 characters, without control characters');
}
$settings[$field] = $value;
}
if (!filter_var($settings['host'], FILTER_VALIDATE_IP)
&& !filter_var($settings['host'], FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) {
throw new InformationException('IMAP host must be a hostname or IP address');
}
if (!filter_var($settings['mailbox'], FILTER_VALIDATE_EMAIL) || strlen($settings['mailbox']) > 255) {
throw new InformationException('A valid mailbox email address is required');
}
$settings['mailbox'] = strtolower($settings['mailbox']);
$settings['port'] = $this->integer($data['port'] ?? null, 1, 65535, 'port');
$settings['encryption'] = $data['encryption'] ?? null;
if (!in_array($settings['encryption'], ['ssl', 'tls'], true)) {
throw new InformationException('Choose implicit TLS or STARTTLS');
}
$folders = array_map(fn ($key) => mb_strtolower($settings[$key], 'UTF-8'),
['inbox_folder', 'processed_folder', 'ignored_folder', 'failed_folder']);
if (count(array_unique($folders)) !== 4) {
throw new InformationException('Inbox, processed, ignored, and failed folders must be different');
}
$settings['support_helpdesk_id'] = $this->integer($data['support_helpdesk_id'] ?? null, 1, PHP_INT_MAX, 'support_helpdesk_id');
if (!$this->di['mod_service']('support')->getHelpdeskRepository()->find($settings['support_helpdesk_id']) instanceof Helpdesk) {
throw new InformationException('Choose an existing helpdesk');
}
$password = $data['password'] ?? '';
if (!is_string($password) || strlen($password) > 4096 || str_contains($password, "\0")) {
throw new InformationException('Invalid mailbox password');
}
$clear = $this->boolean($data['clear_password'] ?? false);
if ($clear && $password !== '') {
throw new InformationException('Choose either a new password or clear the saved password');
}
$settings['password'] = $clear ? '' : ($password !== '' ? $password : $old['password']);
if ($settings['enabled'] && $settings['password'] === '') {
throw new InformationException('Save a mailbox password before enabling imports');
}
$changed = $this->fingerprint($settings) !== $this->fingerprint($old);
$settings['tested_config_hash'] = $changed ? null : $old['tested_config_hash'];
$settings['test_result'] = $changed ? null : $old['test_result'];
if ($settings['enabled'] && !$this->verified($settings)) {
if ($changed && $this->verified($old)) {
// Saving changed connection details disables imports until retested.
$settings['enabled'] = false;
} else {
throw new InformationException('Save this mailbox and successfully run Test Connection and Create Folders before enabling importing');
}
}
foreach ($mailboxes as $otherId => $other) {
if ($otherId !== $id && strcasecmp($other['mailbox'], $settings['mailbox']) === 0) {
throw new InformationException('This mailbox email address is already configured');
}
}
if ($id === 'new') {
$id = bin2hex(random_bytes(16));
}
$mailboxes[$id] = $settings;
$encrypted = $this->di['crypt']->encrypt(json_encode(['mailboxes' => $mailboxes], JSON_THROW_ON_ERROR));
$this->di['dbal']->transactional(function ($connection) use ($encrypted): void {
// MySQL serializes concurrent settings saves on this singleton row.
try {
$connection->insert('mailgateway_settings', ['id' => 1, 'config_encrypted' => $encrypted]);
} catch (UniqueConstraintViolationException) {
// Existing singleton configuration; update under its row lock.
}
$connection->update('mailgateway_settings', ['config_encrypted' => $encrypted], ['id' => 1]);
});
return true;
}
private function integer(mixed $value, int $min, int $max, string $field): int
{
if ((!is_string($value) && !is_int($value))
|| filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => $min, 'max_range' => $max]]) === false) {
throw new InformationException($field . ' is invalid');
}
return (int) $value;
}
private function boolean(mixed $value): bool
{
if (!in_array($value, [true, false, 0, 1, '0', '1'], true)) {
throw new InformationException('Invalid checkbox value');
}
return in_array($value, [true, 1, '1'], true);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Box\Mod\Mailgateway;
use Pimple\Container;
class TemplateInstaller
{
public function __construct(private Container $di) {}
public function install(): void
{
$code = 'mailgateway_sender_mismatch';
$email = $this->di['mod_service']('email');
// Preserve existing custom content and enabled/disabled preference.
if ($email->getTemplateRepository()->findOneByActionCode($code) !== null) {
return;
}
$source = file_get_contents(__DIR__ . '/templates/email/' . $code . '.html.twig');
if (!is_string($source)
|| !preg_match('/{%\s*block subject\s*%}(.*?){%\s*endblock\s*%}/s', $source, $subject)
|| !preg_match('/{%\s*block content\s*%}(.*?){%\s*endblock\s*%}/s', $source, $content)) {
throw new \RuntimeException('Mail Gateway sender-mismatch template is missing or invalid');
}
// This existing custom action code does not use the mod_* discovery convention.
$email->templateCreate($code, trim($subject[1]), trim($content[1]), 1, 'mailgateway');
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"require": {
"webklex/php-imap": "^6.2"
}
}
Generated
+1704
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
// Operational settings: edit this file over SSH, then allow the next cron/request
// to load it. Mailbox credentials and helpdesk mappings stay in the settings page.
return [
'poll_interval_seconds' => 300,
'messages_per_mailbox' => 50,
'poll_budget_seconds' => 90,
'imap_timeout_seconds' => 15,
'max_message_bytes' => 5 * 1024 * 1024,
'max_ticket_body_bytes' => 65535, // Cannot exceed Support's TEXT column limit.
'notification_cooldown_seconds' => 3600,
'composer_timeout_seconds' => 60,
'php_cli_paths' => [PHP_BINDIR . '/php' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION, PHP_BINDIR . '/php'],
'composer_paths' => ['/usr/local/bin/composer', '/usr/bin/composer'],
];
+20
View File
@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS mailgateway_message (
message_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY,
mailbox VARCHAR(255) NOT NULL,
message_id VARCHAR(998) NOT NULL,
payload_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
ticket_id INT DEFAULT NULL,
result_json TEXT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX mailgateway_ticket (ticket_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS mailgateway_notification (
sender_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY,
last_notified_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS mailgateway_settings (
id SMALLINT NOT NULL PRIMARY KEY,
config_encrypted TEXT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,192 @@
{% extends request.ajax ? 'layout_blank.html.twig' : 'layout_default.html.twig' %}
{% block meta_title %}{{ 'Mail Gateway Settings'|trans }}{% endblock %}
{% set active_menu = 'system' %}
{% set result = admin.mailgateway_settings_get({}) %}
{% set params = result.settings %}
{% block content %}
{% if result.notifications is defined %}
<div id="mg-notification-banner" data-ready="{{ result.notifications.ready ? '1' : '0' }}" class="alert {{ result.notifications.ready ? 'alert-success' : 'alert-warning' }}" {% if result.notifications.ready %}hidden{% endif %}>
{% if result.notifications.ready %}
{{ 'Ticket acknowledgement and enabled staff notification subjects are configured for email replies.'|trans }}
{% else %}
<p>{{ 'Ticket notifications need configuration. Enabling mailbox importing will configure them automatically, or use Advanced below.'|trans }}</p>
<ul>{% for error in result.notifications.errors %}<li>{{ error }}</li>{% endfor %}</ul>
{% endif %}
</div>
{% endif %}
{% if result.dependencies is defined and not result.dependencies.installed %}
<div class="card mb-3">
<div class="card-body">
{% if result.dependencies.available %}
<p>{{ 'Install the required mail library before testing connections or enabling imports.'|trans }}</p>
<form action="{{ 'mailgateway/install_dependencies'|api_url(role: 'admin') }}" {{ fb_api_form({message: 'Mail Gateway dependencies installed'|trans, reload: true, timeoutMs: result.dependencies.request_timeout_ms|default(90000), loading: {button: 'Installing dependencies…'|trans}, preventNavigation: true}) }}>
<button class="btn btn-primary" type="submit">{{ 'Install dependencies'|trans }}</button>
</form>
{% else %}
<p>{{ 'Automatic installation is unavailable on this server. Run composer install from the Mailgateway directory using SSH.'|trans }}</p>
{% endif %}
</div>
</div>
{% endif %}
<button class="btn btn-primary mb-3" type="button" onclick="document.getElementById('mg-new').hidden = false; this.hidden = true">{{ 'Add mailbox'|trans }}</button>
{% set mailboxes = result.mailboxes is defined ? result.mailboxes : {'default': result.settings} %}
{% set newMailbox = result.settings|merge({'password_configured': false, 'enabled': false, 'mailbox': '', 'username': '', 'test_passed': false, 'test_result': null}) %}
{% for mailboxId, params in mailboxes|merge({'new': newMailbox}) %}
{% set canEnable = result.dependencies.installed|default(false) and params.test_passed|default(false) %}
{% set disabledReason = not result.dependencies.installed|default(false) ? 'Install dependencies before enabling mailbox importing.'|trans : 'Save this mailbox, then run Test Connection and Create Folders successfully before enabling importing. Connection, password, or folder changes require another test.'|trans %}
<div class="card mb-3" id="mg-{{ mailboxId }}" {% if mailboxId == 'new' %}hidden{% endif %}>
<div class="card-header"><h3 class="card-title">{{ mailboxId == 'new' ? 'New mailbox'|trans : params.mailbox }}</h3></div>
<form method="post" action="{{ 'mailgateway/settings_save'|api_url(role: 'admin') }}" {{ fb_api_form({message: 'Mail Gateway settings saved'|trans, reload: true}) }}>
<input type="hidden" name="mailbox_id" value="{{ mailboxId }}">
<div class="card-body">
<p class="text-secondary">{{ 'Each enabled mailbox routes new tickets to its selected helpdesk. The existing FOSSBilling cron polls mailboxes at the interval defined in the module configuration file.'|trans }}</p>
<div class="mb-3">
<label class="form-check form-switch" id="mg-enable-label-{{ mailboxId }}" {% if not canEnable %}title="{{ disabledReason }}"{% endif %}>
<input type="hidden" name="enabled" value="0">
<input class="form-check-input" id="mg-enabled-{{ mailboxId }}" type="checkbox" name="enabled" value="1" aria-describedby="mg-enable-hint-{{ mailboxId }}" {% if params.enabled and canEnable %}checked{% endif %} {% if not canEnable %}disabled{% endif %}>
<span class="form-check-label">{{ 'Enable mailbox importing'|trans }}</span>
</label>
<small class="form-hint" id="mg-enable-hint-{{ mailboxId }}">{% if not canEnable %}{{ disabledReason }}{% endif %}</small>
</div>
<div class="row g-3">
<div class="col-md-8"><label class="form-label" for="mg-{{ mailboxId }}-host">{{ 'IMAP host'|trans }}</label><input class="form-control" id="mg-{{ mailboxId }}-host" name="host" value="{{ params.host }}" maxlength="255" required></div>
<div class="col-md-4"><label class="form-label" for="mg-{{ mailboxId }}-port">{{ 'Port'|trans }}</label><input class="form-control" id="mg-{{ mailboxId }}-port" name="port" type="number" min="1" max="65535" value="{{ params.port }}" required></div>
<div class="col-md-6">
<label class="form-label" for="mg-{{ mailboxId }}-encryption">{{ 'Connection security'|trans }}</label>
<select class="form-select" id="mg-{{ mailboxId }}-encryption" name="encryption"><option value="ssl" {% if params.encryption == 'ssl' %}selected{% endif %}>{{ 'Implicit TLS (usually port 993)'|trans }}</option><option value="tls" {% if params.encryption == 'tls' %}selected{% endif %}>{{ 'STARTTLS (usually port 143)'|trans }}</option></select>
<small class="form-hint">{{ 'TLS certificate validation is always enabled.'|trans }}</small>
</div>
<div class="col-md-6"><label class="form-label" for="mg-{{ mailboxId }}-mailbox">{{ 'Mailbox email address'|trans }}</label><input class="form-control" id="mg-{{ mailboxId }}-mailbox" name="mailbox" type="email" value="{{ params.mailbox }}" maxlength="255" required></div>
<div class="col-md-6"><label class="form-label" for="mg-{{ mailboxId }}-username">{{ 'IMAP username'|trans }}</label><input class="form-control" id="mg-{{ mailboxId }}-username" name="username" value="{{ params.username }}" maxlength="255" autocomplete="off" required></div>
<div class="col-md-6">
<label class="form-label" for="mg-{{ mailboxId }}-password">{{ 'Mailbox password'|trans }}</label>
<input class="form-control" id="mg-{{ mailboxId }}-password" name="password" type="password" autocomplete="new-password" maxlength="4096">
<small class="form-hint">{% if params.password_configured %}{{ 'A password is saved. Leave blank to keep it.'|trans }}{% else %}{{ 'No password is saved.'|trans }}{% endif %}</small>
<label class="form-check mt-2"><input class="form-check-input" name="clear_password" type="checkbox" value="1"><span class="form-check-label">{{ 'Clear the saved password'|trans }}</span></label>
</div>
<div class="col-12">
<label class="form-label" for="mg-{{ mailboxId }}-helpdesk">{{ 'Support helpdesk'|trans }}</label>
<select class="form-select" id="mg-{{ mailboxId }}-helpdesk" name="support_helpdesk_id" required>
<option value="">{{ 'Choose a helpdesk'|trans }}</option>
{% for id, name in result.helpdesks %}<option value="{{ id }}" {% if params.support_helpdesk_id == id or (params.support_helpdesk_id is null and name == 'General') %}selected{% endif %}>{{ name }}</option>{% endfor %}
</select>
</div>
{% for field, label in {'inbox_folder': 'Inbox folder', 'processed_folder': 'Processed folder', 'ignored_folder': 'Ignored folder', 'failed_folder': 'Failed folder'} %}
<div class="col-md-6"><label class="form-label" for="mg-{{ mailboxId }}-{{ field }}">{{ label|trans }}</label><input class="form-control" id="mg-{{ mailboxId }}-{{ field }}" name="{{ field }}" value="{{ attribute(params, field) }}" maxlength="255" required></div>
{% endfor %}
</div>
<p class="form-hint mt-3">{{ 'Only the configured inbox is imported. Keep spam in a separate folder. Enabled mailboxes are imported automatically by FOSSBilling cron.'|trans }}</p>
</div>
<div class="card-footer"><button class="btn btn-primary" type="submit">{{ 'Save settings'|trans }}</button></div>
</form>
{% if mailboxId != 'new' %}
<div class="card-footer">
<form action="{{ 'mailgateway/mailbox_delete'|api_url(role: 'admin') }}" {{ fb_api_form({message: 'Mailbox removed'|trans, reload: true}) }} data-confirm="{{ 'Remove the saved configuration for %mailbox%? Emails, folders, tickets, and message tracking will be kept.'|trans|replace({'%mailbox%': params.mailbox}) }}" onsubmit="if (!confirm(this.dataset.confirm)) { event.preventDefault(); event.stopImmediatePropagation(); return false; }">
<input type="hidden" name="mailbox_id" value="{{ mailboxId }}">
<button class="btn btn-outline-danger" type="submit">{{ 'Delete mailbox'|trans }}</button>
</form>
</div>
<div class="card-footer">
<button class="btn btn-secondary" type="button" onclick="mailgatewayTest('{{ mailboxId }}')" {% if not result.dependencies.installed|default(false) %}disabled{% endif %}>{{ 'Test Connection and Create Folders'|trans }}</button>
<small class="text-secondary">{{ 'Tests saved settings and creates missing destination folders. Save changes first. No mail is imported or moved.'|trans }}</small>
<pre class="mt-2 mb-0" style="white-space: pre-wrap" id="mg-result-{{ mailboxId }}" role="status">{% if params.test_result|default(null) %}{{ params.test_result.connected ? 'IMAP login succeeded.'|trans : 'IMAP login failed.'|trans }}
{% if params.test_result.move_supported is defined and params.test_result.move_supported %}{{ 'Native MOVE support: available.'|trans }}
{% endif %}{% for folder in params.test_result.folders %}{{ folder.path }}: {{ folder.error ?: (folder.created ? 'created'|trans : 'found'|trans) }}
{% endfor %}{% for error in params.test_result.errors %}{{ error }}
{% endfor %}{{ params.test_result.success ? 'Test passed. You can enable importing and save.'|trans : 'Importing remains disabled until all checks pass.'|trans }}{% endif %}</pre>
</div>
{% endif %}
</div>
{% endfor %}
<details class="card mb-3">
<summary class="card-header">{{ 'Advanced'|trans }}</summary>
<div class="card-body">
<p class="text-secondary">{{ 'Maintenance actions preserve saved mailbox settings and existing ticket data.'|trans }}</p>
{% if result.notifications is defined %}
<div class="mb-4">
<h4>{{ 'Ticket notifications'|trans }}</h4>
{% if result.notifications.ready %}
<p class="text-success">{{ 'Ticket acknowledgement and enabled staff notification subjects are configured for email replies.'|trans }}</p>
{% else %}
<ul>{% for error in result.notifications.errors %}<li>{{ error }}</li>{% endfor %}</ul>
{% endif %}
<p>{{ 'Setup enables the ticket acknowledgement and adds ticket numbers to its subject and enabled staff notification subjects. Existing wording is kept.'|trans }}</p>
<form action="{{ 'mailgateway/notifications_setup'|api_url(role: 'admin') }}" {{ fb_api_form({message: 'Ticket notifications configured'|trans, reload: true}) }}>
<button class="btn btn-secondary" type="submit">{{ 'Configure ticket notifications'|trans }}</button>
</form>
</div>
{% endif %}
<div class="mb-4">
<h4>{{ 'Composer dependencies'|trans }}</h4>
{% if result.dependencies.installed|default(false) %}
<p class="text-success">{{ 'Mail Gateway dependencies are installed.'|trans }}</p>
{% else %}
<p>{{ 'Mail Gateway dependencies are not installed.'|trans }}</p>
{% endif %}
<p>{{ 'Run composer install again using the module lock file, even when dependencies are already installed.'|trans }}</p>
{% if result.dependencies is defined and result.dependencies.available %}
<form action="{{ 'mailgateway/install_dependencies'|api_url(role: 'admin') }}" {{ fb_api_form({message: 'Composer install completed'|trans, reload: true, timeoutMs: result.dependencies.request_timeout_ms|default(90000), loading: {button: 'Running Composer…'|trans}, preventNavigation: true}) }}>
<input type="hidden" name="force" value="1">
<button class="btn btn-secondary" type="submit">{{ 'Run composer install'|trans }}</button>
</form>
{% else %}
<p>{{ 'Web installation is unavailable. Run composer install from the Mailgateway directory using SSH.'|trans }}</p>
{% endif %}
</div>
<div>
<h4>{{ 'Module table setup'|trans }}</h4>
<p>{{ 'Runs the same setup as php tools/setup.php. Creates missing module tables and adds the default mismatch email template if absent. Existing records and templates are kept. Composer runs separately.'|trans }}</p>
<form action="{{ 'mailgateway/setup'|api_url(role: 'admin') }}" {{ fb_api_form({message: 'Mail Gateway table setup completed'|trans, reload: true, loading: {button: 'Running setup…'|trans}}) }}>
<button class="btn btn-secondary" type="submit">{{ 'Run table setup'|trans }}</button>
</form>
</div>
</div>
</details>
<script>
(function() {
const banner = document.getElementById('mg-notification-banner');
if (!banner) return;
try {
const key = 'mailgateway.notificationErrors:' + location.pathname;
if (banner.dataset.ready === '0') {
sessionStorage.setItem(key, '1');
} else {
banner.hidden = sessionStorage.getItem(key) !== '1';
sessionStorage.removeItem(key);
}
} catch (error) { /* The persistent status remains available in Advanced. */ }
})();
document.querySelectorAll('input[name="mailbox_id"]').forEach(function(hidden) {
const form = hidden.form;
const id = hidden.value;
form.addEventListener('input', function(event) {
if (!['host', 'port', 'encryption', 'username', 'password', 'clear_password', 'mailbox', 'inbox_folder', 'processed_folder', 'ignored_folder', 'failed_folder'].includes(event.target.name)) return;
const checkbox = document.getElementById('mg-enabled-' + id);
checkbox.disabled = true;
checkbox.checked = false;
const reason = 'Save the changed mailbox settings, then run Test Connection and Create Folders again.';
document.getElementById('mg-enable-label-' + id).title = reason;
document.getElementById('mg-enable-hint-' + id).textContent = reason;
});
});
function mailgatewayTest(id) {
const output = document.getElementById('mg-result-' + id);
output.textContent = 'Testing connection and creating missing destination folders…';
FOSSBilling.api.admin.post('mailgateway/test_connection', {mailbox_id: id}, function(result) {
output.textContent = (result.connected ? 'IMAP login succeeded.' : 'IMAP login failed.') + '\n'
+ (result.move_supported ? 'Native MOVE support: available.\n' : '')
+ result.folders.map(function(folder) {
return folder.path + ': ' + (folder.error || (folder.created ? 'created' : (folder.exists ? 'found' : 'MISSING')));
}).concat(result.errors).join('\n') + '\n'
+ (result.success ? 'Test passed. You can enable importing and save.' : 'Importing remains disabled until all checks pass.');
const checkbox = document.getElementById('mg-enabled-' + id);
checkbox.disabled = !result.success;
if (!result.success) checkbox.checked = false;
const reason = result.success ? '' : 'Fix the reported issues, then run Test Connection and Create Folders again.';
document.getElementById('mg-enable-label-' + id).title = reason;
document.getElementById('mg-enable-hint-' + id).textContent = reason;
}, function(error) { output.textContent = error.message || 'Connection test or folder creation failed.'; });
}
</script>
{% endblock %}
@@ -0,0 +1,89 @@
{% block subject %}[{{ guest.system_company.name }}] Unable to process your support reply{% endblock %}
{% block content %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style type="text/css">
body {
font-family: Arial, sans-serif;
font-size: 14px;
color: #333333;
line-height: 1.6;
}
h1 {
font-size: 24px;
font-weight: bold;
margin: 0 0 20px;
}
p {
margin: 0 0 15px;
}
.notice {
background-color: #f7f7f7;
border-left: 4px solid #cc9900;
padding: 15px;
margin: 20px 0;
}
.signature {
font-style: italic;
color: #999999;
margin-top: 20px;
border-top: 1px solid #cccccc;
padding-top: 10px;
}
</style>
</head>
<body>
<h1>We couldn't process your reply</h1>
<p>Hello,</p>
<p>
Thank you for contacting {{ guest.system_company.name }}.
</p>
<p>
Unfortunately, we couldn't add your email to the support ticket
you replied to.
</p>
<div class="notice">
<p>
<strong>Please reply from the email address associated
with your account.</strong>
</p>
<p>
For security reasons, we can only accept email replies
from an address authorised to access the support ticket.
</p>
</div>
<p>
Your message has not been added to the ticket.
Please resend it using your registered email address.
</p>
<p>
If you no longer have access to that email address,
please log in to your account to update your details
or contact us for assistance.
</p>
<p>
Kind regards,
</p>
<div class="signature">
{{ guest.system_email.signature }}
</div>
</body>
</html>
{% endblock %}
+82
View File
@@ -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";
+6
View File
@@ -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]];
+115
View File
@@ -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";
+346
View File
@@ -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 &amp; welcome</p><p>Second line</p>";
$message = \Webklex\PHPIMAP\Message::fromString($rawHtml, $config);
$headers = \Box\Mod\Mailgateway\ImapMailbox::rawHeaders($message->getHeader()->raw);
$parsed = \Box\Mod\Mailgateway\ImapMailbox::data($message, 'support@example.com', $headers, false);
$again = \Box\Mod\Mailgateway\ImapMailbox::data($message, 'support@example.com', $headers, false);
if ($parsed['content'] !== "Hello & welcome\nSecond line" || $parsed['subject'] !== '(No subject)' || $parsed['message_id'] !== $again['message_id']) { throw new RuntimeException('HTML fallback or missing-ID fingerprint failed'); }
$headers = \Box\Mod\Mailgateway\ImapMailbox::rawHeaders("Return-Path: <>\r\nAuto-Submitted: no\r\nAuto-Submitted: auto-replied;\r\n reason=vacation\r\n");
$policy = new \Box\Mod\Mailgateway\InboundPolicy();
if (count($headers['auto-submitted']) !== 2 || $headers['return-path'] !== ['<>'] || $policy->ignoreReason($policy->headers(['headers' => $headers])) === null) { throw new RuntimeException('Raw automatic headers lost'); }
$event = new \Box_Event(null, 'onBeforeAdminCronRun');
$hookDi = new \Pimple\Container();
$hookDi['logger'] = $pollDi['logger'];
$hookDi['mod_service'] = $hookDi->protect(fn ($module) => new class { public function poll() { throw new RuntimeException('SECRET'); } });
$event->setDi($hookDi);
\Box\Mod\Mailgateway\Service::onBeforeAdminCronRun($event);
echo "Passed cron isolation, five-minute cadence, overlap lock, disabled mailboxes, folder routing, failure retry, move-failure deduplication, real MIME/HTML parsing, stable missing-ID tracking, raw autoresponder headers, and secret-safe logs.\n";
// Exercise the real transport orchestration without opening a network connection.
$imapBody = "Hello from the inbox.";
$imapHeader = "From: Seb <seb@molenaar.co.uk>\r\nMessage-ID: <transport@example.com>\r\nSubject: Inbox test\r\nContent-Type: text/plain; charset=UTF-8\r\n";
$transportMessage = new class($imapHeader, $config) {
public $message;
public string $body = '';
public function __construct(public string $raw, public $config) { $this->parseRawBody(''); }
public function parseRawBody($body) { $this->body = $body; $this->message = \Webklex\PHPIMAP\Message::fromString($this->raw . "\r\n" . $body, $this->config); }
public function getHeader() { return $this->message->getHeader(); }
public function getFrom() { return $this->message->getFrom(); }
public function getSubject() { return $this->message->getSubject(); }
public function getTextBody() { return $this->message->getTextBody(); }
public function getHTMLBody() { return $this->message->getHTMLBody(); }
public function getRawBody() { return $this->body; }
public function getSize() { return 1000; }
};
$query = new class($transportMessage) {
public bool $peek = false;
public bool $undeleted = false;
public function __construct(public $message) {}
public function leaveUnread() { $this->peek = true; return $this; }
public function setSequence($value) { if ($value !== \Webklex\PHPIMAP\IMAP::ST_UID) { throw new RuntimeException('Expected UID search'); } return $this; }
public function whereUndeleted() { $this->undeleted = true; return $this; }
public function search() { return new \Illuminate\Support\Collection([39, 37, 38]); }
public function fetchBody($value) { if ($value !== false) { throw new RuntimeException('Body fetched before size/policy checks'); } return $this; }
public function setFetchFlags($value) { return $this; }
public function getMessageByUid($uid) { if ($uid !== 37 || !$this->peek) { throw new RuntimeException('Unsafe message fetch'); } return $this->message; }
};
$protocol = new class($imapBody) {
public array $commands = [];
public bool $supportsMove = true;
public function __construct(public string $body) {}
public function getCapabilities() { return new class($this->supportsMove) { public function __construct(public bool $move) {} public function validatedData() { return $this->move ? ['IMAP4rev1', 'MOVE'] : ['IMAP4rev1']; } }; }
public function fetch($items, $uids, $to, $sequence) {
if ($items !== ['BODY.PEEK[TEXT]', 'UID'] || $uids !== [37] || $sequence !== \Webklex\PHPIMAP\IMAP::ST_UID) { throw new RuntimeException('Unsafe body fetch'); }
$this->commands[] = 'BODY.PEEK[TEXT]';
return new class($this->body) { public function __construct(public string $body) {} public function validatedData() { return [37 => ['UID' => 37, 'BODY[TEXT]' => $this->body]]; } };
}
public function escapeString($path) { return '"' . $path . '"'; }
public function requestAndResponse($command, $tokens, $dontParse) {
if ($command !== 'UID MOVE' || $tokens !== ['37', '"Processed"'] || !$dontParse) { throw new RuntimeException('Unsafe move or expunge'); }
$this->commands[] = $command;
return new class { public function boolean() { return true; } };
}
};
$client = new class($protocol, $query) {
public bool $disconnected = false;
public function __construct(public $protocol, public $query) {}
public function connect() {}
public function disconnect() { $this->disconnected = true; }
public function getConnection() { return $this->protocol; }
public function getFolderByPath($path) { return new class($path, $this->query) { public bool $no_select = false; public function __construct(public string $path, public $q) {} public function query() { return $this->q; } }; }
public function openFolder($path) { if ($path !== 'INBOX') { throw new RuntimeException('Move from wrong inbox'); } }
};
$imap = new \Box\Mod\Mailgateway\ImapMailbox($enabled, function ($config) use ($client) {
if (!$config['validate_cert'] || $config['password'] !== 'TEST-SECRET' || $config['timeout'] !== 15) { throw new RuntimeException('Unsafe client config'); }
return $client;
});
$imap->connect();
if ($imap->uids(2) !== [37, 38] || !$query->undeleted) { throw new RuntimeException('UID ordering or undeleted filtering failed'); }
$transportData = $imap->read(37);
if ($transportData['content'] !== $imapBody || $transportData['message_id'] !== '<transport@example.com>') { throw new RuntimeException('Transport MIME conversion failed'); }
$imap->move(37, 'processed_folder');
$imap->disconnect();
if (!$client->disconnected || $protocol->commands !== ['BODY.PEEK[TEXT]', 'UID MOVE']) { throw new RuntimeException('Unsafe transport side effects'); }
$protocol->supportsMove = false;
try { $imap->connect(); throw new RuntimeException('Unsafe MOVE fallback enabled'); } catch (\FOSSBilling\InformationException) {} finally { $imap->disconnect(); }
echo "Passed transport configuration, UID snapshot order, deleted-mail exclusion, true BODY.PEEK reads, native UID MOVE, and rejection of servers requiring an EXPUNGE fallback.\n";
// Verify option setters against the installed API, rather than only a transport mock.
$offlineClient = (new \Webklex\PHPIMAP\ClientManager(['options' => ['debug' => false]]))->make([
'host' => 'example.invalid', 'port' => 993, 'protocol' => 'imap',
'encryption' => 'ssl', 'validate_cert' => true,
'username' => 'offline@example.invalid', 'password' => 'test-only',
]);
$realQuery = new \Webklex\PHPIMAP\Query\WhereQuery($offlineClient);
$realQuery->leaveUnread()->fetchBody(false)->setFetchFlags(false);
if ($realQuery->getFetchBody() || $realQuery->getFetchFlags()
|| $realQuery->getFetchOptions() !== \Webklex\PHPIMAP\IMAP::FT_PEEK) {
throw new RuntimeException('Installed IMAP query options differ from importer assumptions');
}
echo "Passed installed WhereQuery option setters without connecting to IMAP.\n";
// Validate SSH configuration overrides and reject invalid storage limits.
$runtime = require dirname(__DIR__) . '/config.php';
$fixture = tempnam(sys_get_temp_dir(), 'mailgateway-runtime-');
try {
$changed = array_replace($runtime, ['poll_interval_seconds' => 600, 'messages_per_mailbox' => 10,
'imap_timeout_seconds' => 25, 'max_ticket_body_bytes' => 32000]);
file_put_contents($fixture, '<?php return ' . var_export($changed, true) . ';');
if (\Box\Mod\Mailgateway\RuntimeConfig::load($fixture) !== $changed) {
throw new RuntimeException('SSH operational overrides not loaded');
}
foreach ([['poll_interval_seconds' => 0], ['max_ticket_body_bytes' => 65536],
['messages_per_mailbox' => '50'], ['composer_paths' => ['relative/composer']]] as $invalid) {
file_put_contents($fixture, '<?php return ' . var_export(array_replace($runtime, $invalid), true) . ';');
$rejected = false;
try { \Box\Mod\Mailgateway\RuntimeConfig::load($fixture); } catch (RuntimeException) { $rejected = true; }
if (!$rejected) { throw new RuntimeException('Invalid SSH operational config accepted'); }
}
} finally {
unlink($fixture);
}
echo "Passed blank mailbox defaults, saved mailbox preservation, SSH configuration overrides, and operational value validation.\n";
// Activation creates tables before attempting dependencies and permits recovery
// through the settings page when Composer is unavailable.
foreach ([false, true] as $failDependencies) {
$setupDb = new class {
public array $statements = [];
public function executeStatement($sql) { $this->statements[] = $sql; }
};
$setupLogger = new class {
public array $warnings = [];
public function warning($message) { $this->warnings[] = $message; }
};
$setupDi = new \Pimple\Container();
$setupDi['dbal'] = $setupDb;
$setupDi['logger'] = $setupLogger;
$setupDi['mailgateway_template_installer'] = new class { public function install() {} };
$setupService = new class($setupDb, $failDependencies) extends \Box\Mod\Mailgateway\Service {
public int $installs = 0;
public function __construct(public $db, public bool $fail) {}
public function installDependencies(bool $force = false): array {
++$this->installs;
if (count($this->db->statements) !== 3) { throw new LogicException('Dependencies attempted before table setup'); }
if ($this->fail) { throw new RuntimeException('SECRET Composer failure'); }
return ['installed' => true];
}
};
$setupService->setDi($setupDi);
if (!$setupService->install() || $setupService->installs !== 1 || count($setupDb->statements) !== 3
|| count($setupLogger->warnings) !== ($failDependencies ? 1 : 0)
|| str_contains(implode(' ', $setupLogger->warnings), 'SECRET')) {
throw new RuntimeException('Activation dependency setup or recoverable failure handling failed');
}
}
echo "Passed activation table setup, automatic dependency install attempt, and recoverable installation failures without running Composer.\n";
$repairDb = new class {
public array $statements = [];
public array $locks = [];
public bool $busy = false;
public function fetchOne($sql, $params) { $this->locks[] = $sql; return str_contains($sql, 'GET_LOCK') && $this->busy ? 0 : 1; }
public function executeStatement($sql) {
if (!str_starts_with(trim($sql), 'CREATE TABLE IF NOT EXISTS')) { throw new RuntimeException('Unexpected table repair SQL'); }
$this->statements[] = $sql;
}
};
$repairDi = new \Pimple\Container();
$repairDi['dbal'] = $repairDb;
$repairDi['mailgateway_template_installer'] = new class { public function install() {} };
$repairService = new class extends \Box\Mod\Mailgateway\Service {
public function installDependencies(bool $force = false): array { throw new RuntimeException('Table repair must not invoke Composer'); }
};
$repairService->setDi($repairDi);
$repairService->setup();
$repairService->setup();
if (count($repairDb->statements) !== 6 || count($repairDb->locks) !== 4
|| !str_contains($repairDb->locks[3], 'RELEASE_LOCK')) { throw new RuntimeException('Repeatable table repair or overlap lock failed'); }
$repairDb->busy = true;
try { $repairService->setup(); throw new RuntimeException('Repair allowed while polling'); } catch (\FOSSBilling\InformationException) {}
echo "Passed repeatable table setup, shared polling lock, and separate setup/Composer actions.\n";
require dirname(__DIR__) . '/TemplateInstaller.php';
$templateDi = new \Pimple\Container();
$templateEmail = new class {
public array $templates = [];
public int $creates = 0;
public function getTemplateRepository() { return $this; }
public function findOneByActionCode($code) { return $this->templates[$code] ?? null; }
public function templateCreate($code, $subject, $content, $enabled, $category) {
++$this->creates;
return $this->templates[$code] = (new \Box\Mod\Email\Entity\EmailTemplate($code))
->setSubject($subject)->setContent($content)->setEnabled((bool) $enabled)->setCategory($category)->setIsCustom(true);
}
};
$templateDi['mod_service'] = $templateDi->protect(fn ($module) => $templateEmail);
$templateInstaller = new \Box\Mod\Mailgateway\TemplateInstaller($templateDi);
$templateInstaller->install();
$templateInstaller->install();
$template = $templateEmail->templates['mailgateway_sender_mismatch'];
if ($templateEmail->creates !== 1 || !$template->isEnabled() || $template->getCategory() !== 'mailgateway') {
throw new RuntimeException('Fresh mismatch template was not installed exactly once');
}
$templateTwig = new \Twig\Environment(new \Twig\Loader\ArrayLoader(), ['autoescape' => 'html', 'strict_variables' => true]);
$templateGlobals = ['guest' => ['system_company' => ['name' => 'Example Company'],
'system_email' => ['signature' => 'Example signature']]];
$subject = $templateTwig->createTemplate($template->getSubject())->render($templateGlobals);
$body = $templateTwig->createTemplate($template->getContent())->render($templateGlobals);
if ($subject !== '[Example Company] Unable to process your support reply'
|| !str_contains($body, 'Thank you for contacting Example Company.')
|| !str_contains($body, 'Example signature')
|| !str_contains($body, 'Your message has not been added to the ticket.')) {
throw new RuntimeException('Packaged mismatch template did not render the supplied wording with normal globals');
}
$template->setSubject('My custom subject')->setContent('My custom content')->setEnabled(false);
$templateInstaller->install();
if ($templateEmail->creates !== 1 || $template->getSubject() !== 'My custom subject'
|| $template->getContent() !== 'My custom content' || $template->isEnabled()) {
throw new RuntimeException('Existing mismatch template content or disabled setting was overwritten');
}
echo "Passed fresh mismatch-template installation, repeatability, Twig rendering with minimal variables, and preservation of custom content/disabled state.\n";
+152
View File
@@ -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";
+488
View File
@@ -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 &lt;script&gt;') || !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 &lt;needs repair&gt;')) {
throw new RuntimeException('Notification warnings were hidden or not escaped');
}
echo "Passed notification banner visibility, Advanced setup rendering, and warning escaping.\n";
+194
View File
@@ -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";
+192
View File
@@ -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";
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
if (count($argv) > 2 || (isset($argv[1]) && $argv[1] !== '--force')) {
fwrite(STDERR, "Usage: php tools/poll.php [--force]\n");
exit(1);
}
require dirname(__DIR__, 3) . '/load.php';
try {
$result = $di['mod_service']('mailgateway')->poll(($argv[1] ?? '') === '--force');
echo json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), PHP_EOL;
$failed = array_filter($result['mailboxes'], fn ($mailbox) => $mailbox['status'] !== 'completed'
|| $mailbox['failed'] > 0 || $mailbox['retry_pending'] > 0 || $mailbox['move_pending'] > 0);
exit($failed === [] ? 0 : 2);
} catch (Throwable) {
fwrite(STDERR, "Mail Gateway polling failed. Check the module tables, configuration, and cron logs.\n");
exit(1);
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
require dirname(__DIR__, 3) . '/load.php';
$di['mod_service']('mailgateway')->setup();
echo "Mail Gateway tables and sender-mismatch email template are ready.\n";
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(404);
exit;
}
$createFolders = false;
$mailboxId = null;
foreach (array_slice($argv, 1) as $argument) {
if ($argument === '--create-folders') {
$createFolders = true;
} elseif (str_starts_with($argument, '--mailbox=')) {
$mailboxId = substr($argument, strlen('--mailbox='));
} else {
fwrite(STDERR, "Usage: php tools/test-imap.php [--mailbox=email-or-id] [--create-folders]\n");
exit(1);
}
}
require dirname(__DIR__, 3) . '/load.php';
try {
$store = new \Box\Mod\Mailgateway\Settings($di);
$mailboxes = $store->getAll();
$id = $mailboxId;
if ($id === null) {
$id = isset($mailboxes['default']) ? 'default' : array_key_first($mailboxes);
} elseif (str_contains($id, '@')) {
$id = null;
foreach ($mailboxes as $candidate => $mailbox) {
if (strcasecmp($mailbox['mailbox'], $mailboxId) === 0) {
$id = $candidate;
break;
}
}
}
if ($id === null) {
throw new \FOSSBilling\InformationException('Mailbox not found');
}
$settings = $store->get($id);
printf("Checking %s at %s:%d using %s with certificate validation enabled...\n",
$settings['mailbox'], $settings['host'], $settings['port'],
$settings['encryption'] === 'ssl' ? 'implicit TLS' : 'STARTTLS');
$result = (new \Box\Mod\Mailgateway\ImapConnectionTest())->test($settings, $createFolders);
// A read-only check does not change the saved readiness state.
if ($createFolders) {
$store->recordTest($id, $settings, $result);
}
echo $result['connected'] ? "IMAP login succeeded.\n" : "IMAP login failed.\n";
if ($result['connected']) {
echo 'Native MOVE support: ', $result['move_supported'] === true ? 'available' : 'unavailable or unverified', PHP_EOL;
}
foreach ($result['folders'] as $folder) {
printf("%s — %s\n", $folder['path'], $folder['error']
?? ($folder['created'] ? 'created' : ($folder['exists'] ? 'found' : 'MISSING')));
}
foreach ($result['errors'] as $error) {
fwrite(STDERR, $error . PHP_EOL);
}
echo $createFolders ? "Folder setup finished. " : "Read-only check finished. ";
echo "No messages were fetched, marked, moved, deleted, or imported.\n";
exit($result['success'] ? 0 : ($result['connected'] && $result['move_supported'] === true ? 2 : 1));
} catch (\FOSSBilling\InformationException $error) {
fwrite(STDERR, $error->getMessage() . PHP_EOL);
exit(1);
} catch (\Throwable $error) {
fwrite(STDERR, 'IMAP check could not complete (' . $error::class . "). Check the module configuration and tables.\n");
exit(1);
}