Implement IMAP email-to-ticket gateway
This commit is contained in:
1 parent
b7e5884457
commit
6f25869345
33 files changed
+5619
-5
No files matched your search
@@ -4,5 +4,4 @@
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
/vendor/
|
/vendor/
|
||||||
/composer.lock
|
|
||||||
*.log
|
*.log
|
||||||
+69
-4
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"require": {
|
||||||
|
"webklex/php-imap": "^6.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1704
File diff suppressed because it is too large
Load Diff
+18
@@ -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
@@ -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 %}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/tracking.php';
|
||||||
|
|
||||||
|
$policy = new \Box\Mod\Mailgateway\InboundPolicy();
|
||||||
|
$cases = [
|
||||||
|
[['auto_submitted' => 'auto-replied'], 'auto_submitted'],
|
||||||
|
[['auto_submitted' => 'Auto-Generated; owner-email="support@example.com"'], 'auto_submitted'],
|
||||||
|
[['headers' => ['AUTO-SUBMITTED' => ['no', 'auto-replied']]], 'auto_submitted'],
|
||||||
|
[['return_path' => '<>'], 'null_return_path'],
|
||||||
|
[['content_type' => 'multipart/report; report-type=delivery-status; boundary=abc'], 'delivery_report'],
|
||||||
|
[['content_type' => 'message/disposition-notification'], 'delivery_report'],
|
||||||
|
[['precedence' => 'bulk'], 'bulk_mail'],
|
||||||
|
[['precedence' => 'LIST'], 'bulk_mail'],
|
||||||
|
[['list_id' => 'Announcements <list.example.com>'], 'mailing_list'],
|
||||||
|
[['x_auto_response_suppress' => 'All'], 'response_suppressed'],
|
||||||
|
[['x_auto_response_suppress' => 'DR, OOF, AutoReply'], 'response_suppressed'],
|
||||||
|
[['x_autoreply' => 'yes'], 'legacy_autoresponder'],
|
||||||
|
[['x_autorespond' => 'true'], 'legacy_autoresponder'],
|
||||||
|
];
|
||||||
|
$messageCount = (int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages');
|
||||||
|
$notifications = $trackingService->notifications;
|
||||||
|
foreach ($cases as $index => [$fields, $reason]) {
|
||||||
|
$input = array_merge([
|
||||||
|
'mailbox' => 'support@dev.sebs.space',
|
||||||
|
'message_id' => '<automatic-' . $index . '@example.com>',
|
||||||
|
// Would normally reject this mismatched reply and send a notification.
|
||||||
|
'subject' => 'Re: [Ticket #41] Test',
|
||||||
|
'sender_email' => 'wrong@example.com',
|
||||||
|
'content' => 'Away from the office',
|
||||||
|
], $fields);
|
||||||
|
$result = $tracker->process($input, $trackingService);
|
||||||
|
if ($result !== ['action' => 'ignored', 'reason' => $reason, 'duplicate' => false]) {
|
||||||
|
throw new RuntimeException('Automatic message was not ignored: ' . $index);
|
||||||
|
}
|
||||||
|
$repeat = $tracker->process($input, $trackingService);
|
||||||
|
if (!$repeat['duplicate'] || $repeat['action'] !== 'ignored') {
|
||||||
|
throw new RuntimeException('Ignored message retry was not deduplicated');
|
||||||
|
}
|
||||||
|
$row = $connection->fetchAssociative('SELECT ticket_id FROM mailgateway_message WHERE message_id = ?', [$input['message_id']]);
|
||||||
|
if (!$row || $row['ticket_id'] !== null) {
|
||||||
|
throw new RuntimeException('Ignored mail acquired a ticket association');
|
||||||
|
}
|
||||||
|
// The underlying service methods must also skip without database or mail access.
|
||||||
|
$guardedService = new \Box\Mod\Mailgateway\Service();
|
||||||
|
foreach (['import', 'reply'] as $method) {
|
||||||
|
if ($guardedService->$method($fields) !== ['action' => 'ignored', 'reason' => $reason]) {
|
||||||
|
throw new RuntimeException('Service method responded to automatic mail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$minimal = $tracker->process([
|
||||||
|
'mailbox' => 'support@dev.sebs.space', 'message_id' => '<minimal-auto@example.com>',
|
||||||
|
'auto_submitted' => 'auto-replied',
|
||||||
|
], $trackingService);
|
||||||
|
if ($minimal['action'] !== 'ignored'
|
||||||
|
|| (int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages') !== $messageCount
|
||||||
|
|| $trackingService->notifications !== $notifications) {
|
||||||
|
throw new RuntimeException('Automatic mail created a message or sent a notification');
|
||||||
|
}
|
||||||
|
foreach ([[], ['auto_submitted' => 'NO'], ['auto_submitted' => 'no; extension=value'],
|
||||||
|
['return_path' => '<customer@example.com>'], ['content_type' => 'text/plain; charset=utf-8'],
|
||||||
|
['x_auto_response_suppress' => 'None'], ['x_autoreply' => 'false']] as $fields) {
|
||||||
|
if ($policy->ignoreReason($policy->headers($fields)) !== null) {
|
||||||
|
throw new RuntimeException('Normal message classified as automatic');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$folded = $policy->headers(['headers' => ['Auto-Submitted' => "auto-replied;\r\n owner=test"]]);
|
||||||
|
if ($policy->ignoreReason($folded) !== 'auto_submitted') {
|
||||||
|
throw new RuntimeException('Folded header failed');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$policy->headers(['auto_submitted' => "no\r\nInjected: value"]);
|
||||||
|
throw new RuntimeException('Header injection accepted');
|
||||||
|
} catch (\FOSSBilling\InformationException) {}
|
||||||
|
try {
|
||||||
|
$tracker->process(array_merge($input, ['auto_submitted' => 'no']), $trackingService);
|
||||||
|
throw new RuntimeException('Changed detection headers bypassed deduplication');
|
||||||
|
} catch (\FOSSBilling\InformationException) {}
|
||||||
|
echo "Passed automatic-message filtering, repeated/mixed/folded headers, ignored-message deduplication, no ticket writes or notifications, service method protection, and normal-mail exceptions.\n";
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Minimal configuration for isolated validation checks; no database or secrets.
|
||||||
|
return ['debug_and_monitoring' => ['log_stacktrace' => false]];
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/automatic.php';
|
||||||
|
$connection->executeStatement('CREATE TABLE mailgateway_notification (sender_key TEXT PRIMARY KEY, last_notified_at INTEGER NOT NULL)');
|
||||||
|
$sent = 0;
|
||||||
|
$send = function () use (&$sent): bool { ++$sent; return true; };
|
||||||
|
$limiter = new \Box\Mod\Mailgateway\NotificationCooldown($connection);
|
||||||
|
$newRun = new \Box\Mod\Mailgateway\NotificationCooldown($connection);
|
||||||
|
if (!$limiter->run('Guest@example.com', $send) || $newRun->run('guest@example.com', $send) || $sent !== 1) {
|
||||||
|
throw new RuntimeException('New polling run reset cooldown');
|
||||||
|
}
|
||||||
|
$key = hash('sha256', 'guest@example.com');
|
||||||
|
$connection->update('mailgateway_notification', ['last_notified_at' => time() - 300], ['sender_key' => $key]);
|
||||||
|
if ($newRun->run('guest@example.com', $send)) {
|
||||||
|
throw new RuntimeException('Five-minute poll bypassed cooldown');
|
||||||
|
}
|
||||||
|
$connection->update('mailgateway_notification', ['last_notified_at' => time() - 3601], ['sender_key' => $key]);
|
||||||
|
if (!$newRun->run('guest@example.com', $send)) {
|
||||||
|
throw new RuntimeException('Expired cooldown did not permit notification');
|
||||||
|
}
|
||||||
|
if ($limiter->run('disabled@example.com', fn () => false) || !$limiter->run('disabled@example.com', $send)) {
|
||||||
|
throw new RuntimeException('Disabled template consumed cooldown');
|
||||||
|
}
|
||||||
|
$before = (int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages');
|
||||||
|
try {
|
||||||
|
$limiter->run('failure@example.com', function () use ($connection): void {
|
||||||
|
$connection->insert('test_messages', ['ticket_id' => 41]);
|
||||||
|
throw new RuntimeException('Queue failed');
|
||||||
|
});
|
||||||
|
throw new LogicException('Queue failure swallowed');
|
||||||
|
} catch (RuntimeException $error) {
|
||||||
|
if ($error->getMessage() !== 'Queue failed') { throw $error; }
|
||||||
|
}
|
||||||
|
if ((int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages') !== $before || !$limiter->run('failure@example.com', $send)) {
|
||||||
|
throw new RuntimeException('Failed queue did not roll back');
|
||||||
|
}
|
||||||
|
$connection->beginTransaction();
|
||||||
|
$limiter->run('transaction@example.com', $send);
|
||||||
|
$connection->rollBack();
|
||||||
|
if (!$limiter->run('transaction@example.com', $send)) {
|
||||||
|
throw new RuntimeException('Outer rollback retained cooldown');
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection->executeStatement('CREATE TABLE client (id INTEGER PRIMARY KEY, email TEXT)');
|
||||||
|
$di = new \Pimple\Container();
|
||||||
|
$di['dbal'] = $connection;
|
||||||
|
$di['mailgateway_settings'] = new class {
|
||||||
|
public function get(): array { return ['support_helpdesk_id' => null]; }
|
||||||
|
};
|
||||||
|
$di['em'] = new class { public function flush(): void {} public function clear(): void {} };
|
||||||
|
$di['logger'] = new class { public function warning(...$args): void {} };
|
||||||
|
$mail = new class {
|
||||||
|
public array $emails = [];
|
||||||
|
public function sendTemplate($data): bool { $this->emails[] = $data; return true; }
|
||||||
|
};
|
||||||
|
$ticket = new \Box\Mod\Support\Entity\SupportTicket();
|
||||||
|
(new ReflectionProperty($ticket, 'id'))->setValue($ticket, 50);
|
||||||
|
$ticket->setAccessHash('guest-hash');
|
||||||
|
$ticket->setAuthorEmail('guest-owner@example.com');
|
||||||
|
$support = new class($di, $ticket) {
|
||||||
|
public int $created = 0;
|
||||||
|
public int $replies = 0;
|
||||||
|
public bool $fail = false;
|
||||||
|
public function __construct(public $di, public $ticket) {}
|
||||||
|
public function getHelpdeskRepository() { return $this; }
|
||||||
|
public function findBy($criteria, $order, $limit)
|
||||||
|
{
|
||||||
|
$desk = new \Box\Mod\Support\Entity\Helpdesk();
|
||||||
|
(new ReflectionProperty($desk, 'id'))->setValue($desk, 1);
|
||||||
|
return [$desk];
|
||||||
|
}
|
||||||
|
public function ticketCreateForGuest($data)
|
||||||
|
{
|
||||||
|
++$this->created;
|
||||||
|
foreach (['mod_support_ticket_open', 'mod_support_ticket_staff_reply'] as $code) {
|
||||||
|
$this->di['mod_service']('email')->sendTemplate(['code' => $code, 'to' => $data['email']]);
|
||||||
|
}
|
||||||
|
if ($this->fail) { throw new RuntimeException('Support failed'); }
|
||||||
|
return 'guest-hash';
|
||||||
|
}
|
||||||
|
public function findOneByHash($hash) { return $this->ticket; }
|
||||||
|
public function getTicketById($id) { return $this->ticket; }
|
||||||
|
public function canBeReopened($ticket): bool { return true; }
|
||||||
|
public function ticketReply($ticket, $identity, $content): int { return ++$this->replies; }
|
||||||
|
};
|
||||||
|
$lookup = fn ($module, $sub = '') => $module === 'email' ? $mail : $support;
|
||||||
|
$di['mod_service'] = $di->protect($lookup);
|
||||||
|
$service = new \Box\Mod\Mailgateway\Service();
|
||||||
|
$service->setDi($di);
|
||||||
|
$input = ['sender_email' => 'guest-owner@example.com', 'subject' => 'Test', 'content' => 'Test body'];
|
||||||
|
$service->import($input);
|
||||||
|
$service->import($input);
|
||||||
|
if ($support->created !== 2 || count($mail->emails) !== 1 || $di['mod_service'] !== $lookup) {
|
||||||
|
throw new RuntimeException('Guest notification cooldown or lookup restoration failed');
|
||||||
|
}
|
||||||
|
$service->reply(['sender_email' => $input['sender_email'], 'ticket_id' => 50, 'content' => 'Human reply']);
|
||||||
|
if ($support->replies !== 1) { throw new RuntimeException('Human reply blocked by cooldown'); }
|
||||||
|
foreach ([1, 2] as $attempt) {
|
||||||
|
try {
|
||||||
|
$tracker->process(['mailbox' => 'support@example.com', 'message_id' => "<cooldown-mismatch-$attempt@example.com>",
|
||||||
|
'sender_email' => 'wrong@example.com', 'subject' => '[Ticket #50] Reply', 'content' => 'Mismatch'], $service);
|
||||||
|
throw new RuntimeException('Mismatch accepted');
|
||||||
|
} catch (\FOSSBilling\InformationException) {}
|
||||||
|
}
|
||||||
|
if (count($mail->emails) !== 2 || $mail->emails[1]['code'] !== 'mailgateway_sender_mismatch') {
|
||||||
|
throw new RuntimeException('Mismatch cooldown failed');
|
||||||
|
}
|
||||||
|
$support->fail = true;
|
||||||
|
try { $service->import($input); } catch (RuntimeException $error) {
|
||||||
|
if ($error->getMessage() !== 'Support failed') { throw $error; }
|
||||||
|
}
|
||||||
|
if ($di['mod_service'] !== $lookup) { throw new RuntimeException('Failure left email interception active'); }
|
||||||
|
echo "Passed persistent cooldown, five-minute polls, expiry, failed/disabled queues, nested rollback, guest acknowledgements, mismatch notifications, human replies, and service restoration.\n";
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/settings.php';
|
||||||
|
require dirname(__DIR__) . '/Poller.php';
|
||||||
|
require dirname(__DIR__) . '/ImapMailbox.php';
|
||||||
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
$pollDb = \Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
|
||||||
|
$pollDb->executeStatement('CREATE TABLE mailgateway_message (message_key TEXT PRIMARY KEY, mailbox TEXT, message_id TEXT, payload_hash TEXT, ticket_id INTEGER, result_json TEXT)');
|
||||||
|
$pollDb->executeStatement('CREATE TABLE imported (id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT)');
|
||||||
|
$pollService = new class($pollDb) extends \Box\Mod\Mailgateway\Service {
|
||||||
|
public function __construct(public $connection) {}
|
||||||
|
public function import(array $data): array {
|
||||||
|
if ($data['content'] === 'database failure') { throw new RuntimeException('SECRET infrastructure failure'); }
|
||||||
|
$this->connection->insert('imported', ['kind' => 'ticket']);
|
||||||
|
return ['ticket_id' => 101];
|
||||||
|
}
|
||||||
|
public function reply(array $data): array {
|
||||||
|
if ($data['ticket_id'] === 999) { throw new \FOSSBilling\InformationException('Ticket not found'); }
|
||||||
|
$this->connection->insert('imported', ['kind' => 'reply']);
|
||||||
|
return ['ticket_id' => $data['ticket_id'], 'message_id' => 77];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$pollDi = new \Pimple\Container();
|
||||||
|
$pollDi['dbal'] = $pollDb;
|
||||||
|
$pollDi['em'] = new class { public function clear() {} };
|
||||||
|
$pollDi['logger'] = new class {
|
||||||
|
public array $logs = [];
|
||||||
|
public function warning($text) { $this->logs[] = $text; }
|
||||||
|
public function info($text) { $this->logs[] = $text; }
|
||||||
|
};
|
||||||
|
$system = new class {
|
||||||
|
public $last = 0;
|
||||||
|
public function getParamValue($key, $default) { return $this->last; }
|
||||||
|
public function setParamValue($key, $value, $create) { $this->last = $value; }
|
||||||
|
};
|
||||||
|
$enabled = array_replace(\Box\Mod\Mailgateway\Settings::DEFAULTS, ['host' => 'mail.example.com', 'username' => 'support@example.com', 'mailbox' => 'support@example.com', 'enabled' => true, 'password' => 'TEST-SECRET']);
|
||||||
|
$pollStore = new class($enabled) {
|
||||||
|
public function __construct(public $settings) {}
|
||||||
|
public function getAll() { return ['default' => $this->settings, 'disabled' => array_replace($this->settings, ['enabled' => false]), 'other' => array_replace($this->settings, ['mailbox' => 'other@example.com'])]; }
|
||||||
|
};
|
||||||
|
$pollDi['mailgateway_settings'] = $pollStore;
|
||||||
|
$pollService->setDi($pollDi);
|
||||||
|
$data = ['mailbox' => $enabled['mailbox'], 'sender_email' => 'guest@example.com', 'subject' => 'New email', 'content' => 'Body'];
|
||||||
|
$transport = new class($data) {
|
||||||
|
public array $messages;
|
||||||
|
public array $moves = [];
|
||||||
|
public int $connections = 0;
|
||||||
|
public bool $disconnected = false;
|
||||||
|
public bool $failMove = true;
|
||||||
|
public function __construct($data) {
|
||||||
|
$this->messages = [
|
||||||
|
1 => $data + ['message_id' => '<poll-1@example.com>'],
|
||||||
|
2 => array_replace($data, ['message_id' => '<poll-2@example.com>', 'subject' => 'Re: [Ticket #101] New email']),
|
||||||
|
3 => ['mailbox' => $data['mailbox'], 'message_id' => '<poll-3@example.com>', 'headers' => ['Auto-Submitted' => 'auto-replied']],
|
||||||
|
4 => array_replace($data, ['message_id' => '<poll-4@example.com>', 'subject' => '[Ticket #999] Unknown']),
|
||||||
|
5 => array_replace($data, ['message_id' => '<poll-5@example.com>', 'content' => 'database failure']),
|
||||||
|
6 => new RuntimeException('SECRET fetch failure'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
public function connect() { ++$this->connections; }
|
||||||
|
public function uids($limit) { if ($limit !== 50) { throw new RuntimeException('Batch unbounded'); } return array_keys($this->messages); }
|
||||||
|
public function read($uid) { $data = $this->messages[$uid]; if ($data instanceof Throwable) { throw $data; } return $data; }
|
||||||
|
public function move($uid, $destination) {
|
||||||
|
if ($uid === 1 && $this->failMove) { throw new RuntimeException('SECRET move failure'); }
|
||||||
|
$this->moves[$uid] = $destination;
|
||||||
|
unset($this->messages[$uid]);
|
||||||
|
}
|
||||||
|
public function disconnect() { $this->disconnected = true; }
|
||||||
|
};
|
||||||
|
$other = new class {
|
||||||
|
public bool $disconnected = false;
|
||||||
|
public function connect() { throw new RuntimeException('TEST-SECRET login failure'); }
|
||||||
|
public function disconnect() { $this->disconnected = true; }
|
||||||
|
};
|
||||||
|
$clock = 10000;
|
||||||
|
$locked = false;
|
||||||
|
$releases = 0;
|
||||||
|
$locker = function ($acquire) use (&$locked, &$releases) {
|
||||||
|
if ($acquire) { if ($locked) { return false; } $locked = true; return true; }
|
||||||
|
$locked = false; ++$releases; return true;
|
||||||
|
};
|
||||||
|
$factory = fn ($settings) => $settings['mailbox'] === 'other@example.com' ? $other : $transport;
|
||||||
|
$poller = new \Box\Mod\Mailgateway\Poller($pollDi, $pollService, $factory, function () use (&$clock) { return $clock; }, $locker);
|
||||||
|
$pollDi['mod_service'] = $pollDi->protect(fn ($module) => $module === 'system' ? $system : $pollService);
|
||||||
|
$result = $poller->poll();
|
||||||
|
$counts = $result['mailboxes']['default'];
|
||||||
|
$diagnostics = $counts['errors'];
|
||||||
|
unset($counts['errors']);
|
||||||
|
if ($diagnostics[0]['stage'] !== 'moving email' || $diagnostics[1]['stage'] !== 'importing email'
|
||||||
|
|| $diagnostics[3]['stage'] !== 'reading email'
|
||||||
|
|| str_contains(json_encode($diagnostics), 'SECRET')) {
|
||||||
|
throw new RuntimeException('Missing or unsafe poll diagnostics');
|
||||||
|
}
|
||||||
|
if ($counts !== ['status' => 'completed', 'ticket' => 1, 'reply' => 1, 'ignored' => 1, 'duplicate' => 0, 'failed' => 1, 'retry_pending' => 2, 'move_pending' => 1]
|
||||||
|
|| $result['mailboxes']['other']['status'] !== 'failed' || isset($result['mailboxes']['disabled'])
|
||||||
|
|| $transport->moves !== [2 => 'processed_folder', 3 => 'ignored_folder', 4 => 'failed_folder']
|
||||||
|
|| !$transport->disconnected || !$other->disconnected || $locked) {
|
||||||
|
throw new RuntimeException('Polling result or folder routing incorrect: ' . json_encode($result));
|
||||||
|
}
|
||||||
|
$clock += 299;
|
||||||
|
if ($poller->poll()['status'] !== 'not_due' || $transport->connections !== 1 || $locked) { throw new RuntimeException('Five-minute interval failed'); }
|
||||||
|
$clock++;
|
||||||
|
$transport->failMove = false;
|
||||||
|
$result = $poller->poll();
|
||||||
|
if ($result['mailboxes']['default']['duplicate'] !== 1 || $transport->moves[1] !== 'processed_folder'
|
||||||
|
|| (int) $pollDb->fetchOne('SELECT COUNT(*) FROM imported') !== 2) { throw new RuntimeException('Move retry duplicated import'); }
|
||||||
|
if ($poller->poll(true)['status'] !== 'completed') { throw new RuntimeException('Forced run failed'); }
|
||||||
|
$locked = true;
|
||||||
|
if ($poller->poll(true)['status'] !== 'busy') { throw new RuntimeException('Concurrent poll allowed'); }
|
||||||
|
$locked = false;
|
||||||
|
$pollStore->settings['enabled'] = false;
|
||||||
|
if ($poller->poll()['status'] !== 'disabled' || $locked) { throw new RuntimeException('Disabled mailbox handling failed'); }
|
||||||
|
if (str_contains(implode("\n", $pollDi['logger']->logs), 'SECRET')) { throw new RuntimeException('Polling leaked secrets'); }
|
||||||
|
|
||||||
|
// Use the installed MIME parser offline: real encoded headers, multipart and HTML.
|
||||||
|
$config = \Webklex\PHPIMAP\Config::make(['options' => ['debug' => false]]);
|
||||||
|
$raw = "From: =?UTF-8?B?U2Vi?= <seb@molenaar.co.uk>\r\nTo: support@example.com\r\nMessage-ID: <mime-1@example.com>\r\nSubject: =?UTF-8?B?VGVzdCDDqQ==?=\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=boundary\r\n\r\n--boundary\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nPlain =C3=A9\r\n--boundary\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n<p>HTML</p>\r\n--boundary--\r\n";
|
||||||
|
$message = \Webklex\PHPIMAP\Message::fromString($raw, $config);
|
||||||
|
$headers = \Box\Mod\Mailgateway\ImapMailbox::rawHeaders($message->getHeader()->raw);
|
||||||
|
$parsed = \Box\Mod\Mailgateway\ImapMailbox::data($message, 'support@example.com', $headers, false);
|
||||||
|
if ($parsed['sender_email'] !== 'seb@molenaar.co.uk' || $parsed['sender_name'] !== 'Seb'
|
||||||
|
|| $parsed['subject'] !== 'Test é' || $parsed['content'] !== 'Plain é' || $parsed['message_id'] !== '<mime-1@example.com>') { throw new RuntimeException('Real MIME decode failed: ' . json_encode($parsed)); }
|
||||||
|
$rawHtml = "From: Seb <seb@molenaar.co.uk>\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n<style>SECRET STYLE</style><script>SECRET SCRIPT</script><p>Hello & welcome</p><p>Second line</p>";
|
||||||
|
$message = \Webklex\PHPIMAP\Message::fromString($rawHtml, $config);
|
||||||
|
$headers = \Box\Mod\Mailgateway\ImapMailbox::rawHeaders($message->getHeader()->raw);
|
||||||
|
$parsed = \Box\Mod\Mailgateway\ImapMailbox::data($message, 'support@example.com', $headers, false);
|
||||||
|
$again = \Box\Mod\Mailgateway\ImapMailbox::data($message, 'support@example.com', $headers, false);
|
||||||
|
if ($parsed['content'] !== "Hello & welcome\nSecond line" || $parsed['subject'] !== '(No subject)' || $parsed['message_id'] !== $again['message_id']) { throw new RuntimeException('HTML fallback or missing-ID fingerprint failed'); }
|
||||||
|
$headers = \Box\Mod\Mailgateway\ImapMailbox::rawHeaders("Return-Path: <>\r\nAuto-Submitted: no\r\nAuto-Submitted: auto-replied;\r\n reason=vacation\r\n");
|
||||||
|
$policy = new \Box\Mod\Mailgateway\InboundPolicy();
|
||||||
|
if (count($headers['auto-submitted']) !== 2 || $headers['return-path'] !== ['<>'] || $policy->ignoreReason($policy->headers(['headers' => $headers])) === null) { throw new RuntimeException('Raw automatic headers lost'); }
|
||||||
|
|
||||||
|
$event = new \Box_Event(null, 'onBeforeAdminCronRun');
|
||||||
|
$hookDi = new \Pimple\Container();
|
||||||
|
$hookDi['logger'] = $pollDi['logger'];
|
||||||
|
$hookDi['mod_service'] = $hookDi->protect(fn ($module) => new class { public function poll() { throw new RuntimeException('SECRET'); } });
|
||||||
|
$event->setDi($hookDi);
|
||||||
|
\Box\Mod\Mailgateway\Service::onBeforeAdminCronRun($event);
|
||||||
|
echo "Passed cron isolation, five-minute cadence, overlap lock, disabled mailboxes, folder routing, failure retry, move-failure deduplication, real MIME/HTML parsing, stable missing-ID tracking, raw autoresponder headers, and secret-safe logs.\n";
|
||||||
|
|
||||||
|
// Exercise the real transport orchestration without opening a network connection.
|
||||||
|
$imapBody = "Hello from the inbox.";
|
||||||
|
$imapHeader = "From: Seb <seb@molenaar.co.uk>\r\nMessage-ID: <transport@example.com>\r\nSubject: Inbox test\r\nContent-Type: text/plain; charset=UTF-8\r\n";
|
||||||
|
$transportMessage = new class($imapHeader, $config) {
|
||||||
|
public $message;
|
||||||
|
public string $body = '';
|
||||||
|
public function __construct(public string $raw, public $config) { $this->parseRawBody(''); }
|
||||||
|
public function parseRawBody($body) { $this->body = $body; $this->message = \Webklex\PHPIMAP\Message::fromString($this->raw . "\r\n" . $body, $this->config); }
|
||||||
|
public function getHeader() { return $this->message->getHeader(); }
|
||||||
|
public function getFrom() { return $this->message->getFrom(); }
|
||||||
|
public function getSubject() { return $this->message->getSubject(); }
|
||||||
|
public function getTextBody() { return $this->message->getTextBody(); }
|
||||||
|
public function getHTMLBody() { return $this->message->getHTMLBody(); }
|
||||||
|
public function getRawBody() { return $this->body; }
|
||||||
|
public function getSize() { return 1000; }
|
||||||
|
};
|
||||||
|
$query = new class($transportMessage) {
|
||||||
|
public bool $peek = false;
|
||||||
|
public bool $undeleted = false;
|
||||||
|
public function __construct(public $message) {}
|
||||||
|
public function leaveUnread() { $this->peek = true; return $this; }
|
||||||
|
public function setSequence($value) { if ($value !== \Webklex\PHPIMAP\IMAP::ST_UID) { throw new RuntimeException('Expected UID search'); } return $this; }
|
||||||
|
public function whereUndeleted() { $this->undeleted = true; return $this; }
|
||||||
|
public function search() { return new \Illuminate\Support\Collection([39, 37, 38]); }
|
||||||
|
public function fetchBody($value) { if ($value !== false) { throw new RuntimeException('Body fetched before size/policy checks'); } return $this; }
|
||||||
|
public function setFetchFlags($value) { return $this; }
|
||||||
|
public function getMessageByUid($uid) { if ($uid !== 37 || !$this->peek) { throw new RuntimeException('Unsafe message fetch'); } return $this->message; }
|
||||||
|
};
|
||||||
|
$protocol = new class($imapBody) {
|
||||||
|
public array $commands = [];
|
||||||
|
public bool $supportsMove = true;
|
||||||
|
public function __construct(public string $body) {}
|
||||||
|
public function getCapabilities() { return new class($this->supportsMove) { public function __construct(public bool $move) {} public function validatedData() { return $this->move ? ['IMAP4rev1', 'MOVE'] : ['IMAP4rev1']; } }; }
|
||||||
|
public function fetch($items, $uids, $to, $sequence) {
|
||||||
|
if ($items !== ['BODY.PEEK[TEXT]', 'UID'] || $uids !== [37] || $sequence !== \Webklex\PHPIMAP\IMAP::ST_UID) { throw new RuntimeException('Unsafe body fetch'); }
|
||||||
|
$this->commands[] = 'BODY.PEEK[TEXT]';
|
||||||
|
return new class($this->body) { public function __construct(public string $body) {} public function validatedData() { return [37 => ['UID' => 37, 'BODY[TEXT]' => $this->body]]; } };
|
||||||
|
}
|
||||||
|
public function escapeString($path) { return '"' . $path . '"'; }
|
||||||
|
public function requestAndResponse($command, $tokens, $dontParse) {
|
||||||
|
if ($command !== 'UID MOVE' || $tokens !== ['37', '"Processed"'] || !$dontParse) { throw new RuntimeException('Unsafe move or expunge'); }
|
||||||
|
$this->commands[] = $command;
|
||||||
|
return new class { public function boolean() { return true; } };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$client = new class($protocol, $query) {
|
||||||
|
public bool $disconnected = false;
|
||||||
|
public function __construct(public $protocol, public $query) {}
|
||||||
|
public function connect() {}
|
||||||
|
public function disconnect() { $this->disconnected = true; }
|
||||||
|
public function getConnection() { return $this->protocol; }
|
||||||
|
public function getFolderByPath($path) { return new class($path, $this->query) { public bool $no_select = false; public function __construct(public string $path, public $q) {} public function query() { return $this->q; } }; }
|
||||||
|
public function openFolder($path) { if ($path !== 'INBOX') { throw new RuntimeException('Move from wrong inbox'); } }
|
||||||
|
};
|
||||||
|
$imap = new \Box\Mod\Mailgateway\ImapMailbox($enabled, function ($config) use ($client) {
|
||||||
|
if (!$config['validate_cert'] || $config['password'] !== 'TEST-SECRET' || $config['timeout'] !== 15) { throw new RuntimeException('Unsafe client config'); }
|
||||||
|
return $client;
|
||||||
|
});
|
||||||
|
$imap->connect();
|
||||||
|
if ($imap->uids(2) !== [37, 38] || !$query->undeleted) { throw new RuntimeException('UID ordering or undeleted filtering failed'); }
|
||||||
|
$transportData = $imap->read(37);
|
||||||
|
if ($transportData['content'] !== $imapBody || $transportData['message_id'] !== '<transport@example.com>') { throw new RuntimeException('Transport MIME conversion failed'); }
|
||||||
|
$imap->move(37, 'processed_folder');
|
||||||
|
$imap->disconnect();
|
||||||
|
if (!$client->disconnected || $protocol->commands !== ['BODY.PEEK[TEXT]', 'UID MOVE']) { throw new RuntimeException('Unsafe transport side effects'); }
|
||||||
|
$protocol->supportsMove = false;
|
||||||
|
try { $imap->connect(); throw new RuntimeException('Unsafe MOVE fallback enabled'); } catch (\FOSSBilling\InformationException) {} finally { $imap->disconnect(); }
|
||||||
|
echo "Passed transport configuration, UID snapshot order, deleted-mail exclusion, true BODY.PEEK reads, native UID MOVE, and rejection of servers requiring an EXPUNGE fallback.\n";
|
||||||
|
|
||||||
|
// Verify option setters against the installed API, rather than only a transport mock.
|
||||||
|
$offlineClient = (new \Webklex\PHPIMAP\ClientManager(['options' => ['debug' => false]]))->make([
|
||||||
|
'host' => 'example.invalid', 'port' => 993, 'protocol' => 'imap',
|
||||||
|
'encryption' => 'ssl', 'validate_cert' => true,
|
||||||
|
'username' => 'offline@example.invalid', 'password' => 'test-only',
|
||||||
|
]);
|
||||||
|
$realQuery = new \Webklex\PHPIMAP\Query\WhereQuery($offlineClient);
|
||||||
|
$realQuery->leaveUnread()->fetchBody(false)->setFetchFlags(false);
|
||||||
|
if ($realQuery->getFetchBody() || $realQuery->getFetchFlags()
|
||||||
|
|| $realQuery->getFetchOptions() !== \Webklex\PHPIMAP\IMAP::FT_PEEK) {
|
||||||
|
throw new RuntimeException('Installed IMAP query options differ from importer assumptions');
|
||||||
|
}
|
||||||
|
echo "Passed installed WhereQuery option setters without connecting to IMAP.\n";
|
||||||
|
|
||||||
|
// Validate SSH configuration overrides and reject invalid storage limits.
|
||||||
|
$runtime = require dirname(__DIR__) . '/config.php';
|
||||||
|
$fixture = tempnam(sys_get_temp_dir(), 'mailgateway-runtime-');
|
||||||
|
try {
|
||||||
|
$changed = array_replace($runtime, ['poll_interval_seconds' => 600, 'messages_per_mailbox' => 10,
|
||||||
|
'imap_timeout_seconds' => 25, 'max_ticket_body_bytes' => 32000]);
|
||||||
|
file_put_contents($fixture, '<?php return ' . var_export($changed, true) . ';');
|
||||||
|
if (\Box\Mod\Mailgateway\RuntimeConfig::load($fixture) !== $changed) {
|
||||||
|
throw new RuntimeException('SSH operational overrides not loaded');
|
||||||
|
}
|
||||||
|
foreach ([['poll_interval_seconds' => 0], ['max_ticket_body_bytes' => 65536],
|
||||||
|
['messages_per_mailbox' => '50'], ['composer_paths' => ['relative/composer']]] as $invalid) {
|
||||||
|
file_put_contents($fixture, '<?php return ' . var_export(array_replace($runtime, $invalid), true) . ';');
|
||||||
|
$rejected = false;
|
||||||
|
try { \Box\Mod\Mailgateway\RuntimeConfig::load($fixture); } catch (RuntimeException) { $rejected = true; }
|
||||||
|
if (!$rejected) { throw new RuntimeException('Invalid SSH operational config accepted'); }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
unlink($fixture);
|
||||||
|
}
|
||||||
|
echo "Passed blank mailbox defaults, saved mailbox preservation, SSH configuration overrides, and operational value validation.\n";
|
||||||
|
|
||||||
|
// Activation creates tables before attempting dependencies and permits recovery
|
||||||
|
// through the settings page when Composer is unavailable.
|
||||||
|
foreach ([false, true] as $failDependencies) {
|
||||||
|
$setupDb = new class {
|
||||||
|
public array $statements = [];
|
||||||
|
public function executeStatement($sql) { $this->statements[] = $sql; }
|
||||||
|
};
|
||||||
|
$setupLogger = new class {
|
||||||
|
public array $warnings = [];
|
||||||
|
public function warning($message) { $this->warnings[] = $message; }
|
||||||
|
};
|
||||||
|
$setupDi = new \Pimple\Container();
|
||||||
|
$setupDi['dbal'] = $setupDb;
|
||||||
|
$setupDi['logger'] = $setupLogger;
|
||||||
|
$setupDi['mailgateway_template_installer'] = new class { public function install() {} };
|
||||||
|
$setupService = new class($setupDb, $failDependencies) extends \Box\Mod\Mailgateway\Service {
|
||||||
|
public int $installs = 0;
|
||||||
|
public function __construct(public $db, public bool $fail) {}
|
||||||
|
public function installDependencies(bool $force = false): array {
|
||||||
|
++$this->installs;
|
||||||
|
if (count($this->db->statements) !== 3) { throw new LogicException('Dependencies attempted before table setup'); }
|
||||||
|
if ($this->fail) { throw new RuntimeException('SECRET Composer failure'); }
|
||||||
|
return ['installed' => true];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$setupService->setDi($setupDi);
|
||||||
|
if (!$setupService->install() || $setupService->installs !== 1 || count($setupDb->statements) !== 3
|
||||||
|
|| count($setupLogger->warnings) !== ($failDependencies ? 1 : 0)
|
||||||
|
|| str_contains(implode(' ', $setupLogger->warnings), 'SECRET')) {
|
||||||
|
throw new RuntimeException('Activation dependency setup or recoverable failure handling failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "Passed activation table setup, automatic dependency install attempt, and recoverable installation failures without running Composer.\n";
|
||||||
|
|
||||||
|
$repairDb = new class {
|
||||||
|
public array $statements = [];
|
||||||
|
public array $locks = [];
|
||||||
|
public bool $busy = false;
|
||||||
|
public function fetchOne($sql, $params) { $this->locks[] = $sql; return str_contains($sql, 'GET_LOCK') && $this->busy ? 0 : 1; }
|
||||||
|
public function executeStatement($sql) {
|
||||||
|
if (!str_starts_with(trim($sql), 'CREATE TABLE IF NOT EXISTS')) { throw new RuntimeException('Unexpected table repair SQL'); }
|
||||||
|
$this->statements[] = $sql;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$repairDi = new \Pimple\Container();
|
||||||
|
$repairDi['dbal'] = $repairDb;
|
||||||
|
$repairDi['mailgateway_template_installer'] = new class { public function install() {} };
|
||||||
|
$repairService = new class extends \Box\Mod\Mailgateway\Service {
|
||||||
|
public function installDependencies(bool $force = false): array { throw new RuntimeException('Table repair must not invoke Composer'); }
|
||||||
|
};
|
||||||
|
$repairService->setDi($repairDi);
|
||||||
|
$repairService->setup();
|
||||||
|
$repairService->setup();
|
||||||
|
if (count($repairDb->statements) !== 6 || count($repairDb->locks) !== 4
|
||||||
|
|| !str_contains($repairDb->locks[3], 'RELEASE_LOCK')) { throw new RuntimeException('Repeatable table repair or overlap lock failed'); }
|
||||||
|
$repairDb->busy = true;
|
||||||
|
try { $repairService->setup(); throw new RuntimeException('Repair allowed while polling'); } catch (\FOSSBilling\InformationException) {}
|
||||||
|
echo "Passed repeatable table setup, shared polling lock, and separate setup/Composer actions.\n";
|
||||||
|
|
||||||
|
require dirname(__DIR__) . '/TemplateInstaller.php';
|
||||||
|
$templateDi = new \Pimple\Container();
|
||||||
|
$templateEmail = new class {
|
||||||
|
public array $templates = [];
|
||||||
|
public int $creates = 0;
|
||||||
|
public function getTemplateRepository() { return $this; }
|
||||||
|
public function findOneByActionCode($code) { return $this->templates[$code] ?? null; }
|
||||||
|
public function templateCreate($code, $subject, $content, $enabled, $category) {
|
||||||
|
++$this->creates;
|
||||||
|
return $this->templates[$code] = (new \Box\Mod\Email\Entity\EmailTemplate($code))
|
||||||
|
->setSubject($subject)->setContent($content)->setEnabled((bool) $enabled)->setCategory($category)->setIsCustom(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$templateDi['mod_service'] = $templateDi->protect(fn ($module) => $templateEmail);
|
||||||
|
$templateInstaller = new \Box\Mod\Mailgateway\TemplateInstaller($templateDi);
|
||||||
|
$templateInstaller->install();
|
||||||
|
$templateInstaller->install();
|
||||||
|
$template = $templateEmail->templates['mailgateway_sender_mismatch'];
|
||||||
|
if ($templateEmail->creates !== 1 || !$template->isEnabled() || $template->getCategory() !== 'mailgateway') {
|
||||||
|
throw new RuntimeException('Fresh mismatch template was not installed exactly once');
|
||||||
|
}
|
||||||
|
$templateTwig = new \Twig\Environment(new \Twig\Loader\ArrayLoader(), ['autoescape' => 'html', 'strict_variables' => true]);
|
||||||
|
$templateGlobals = ['guest' => ['system_company' => ['name' => 'Example Company'],
|
||||||
|
'system_email' => ['signature' => 'Example signature']]];
|
||||||
|
$subject = $templateTwig->createTemplate($template->getSubject())->render($templateGlobals);
|
||||||
|
$body = $templateTwig->createTemplate($template->getContent())->render($templateGlobals);
|
||||||
|
if ($subject !== '[Example Company] Unable to process your support reply'
|
||||||
|
|| !str_contains($body, 'Thank you for contacting Example Company.')
|
||||||
|
|| !str_contains($body, 'Example signature')
|
||||||
|
|| !str_contains($body, 'Your message has not been added to the ticket.')) {
|
||||||
|
throw new RuntimeException('Packaged mismatch template did not render the supplied wording with normal globals');
|
||||||
|
}
|
||||||
|
$template->setSubject('My custom subject')->setContent('My custom content')->setEnabled(false);
|
||||||
|
$templateInstaller->install();
|
||||||
|
if ($templateEmail->creates !== 1 || $template->getSubject() !== 'My custom subject'
|
||||||
|
|| $template->getContent() !== 'My custom content' || $template->isEnabled()) {
|
||||||
|
throw new RuntimeException('Existing mismatch template content or disabled setting was overwritten');
|
||||||
|
}
|
||||||
|
echo "Passed fresh mismatch-template installation, repeatability, Twig rendering with minimal variables, and preservation of custom content/disabled state.\n";
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/validation.php';
|
||||||
|
|
||||||
|
use Box\Mod\Support\Entity\Helpdesk;
|
||||||
|
use Box\Mod\Support\Entity\SupportTicket;
|
||||||
|
use Box\Mod\Support\Entity\SupportTicketMessage;
|
||||||
|
|
||||||
|
$ticket = new SupportTicket();
|
||||||
|
(new ReflectionProperty($ticket, 'id'))->setValue($ticket, 4);
|
||||||
|
$ticket->setClientId(7);
|
||||||
|
$ticket->setStatus('on_hold');
|
||||||
|
$helpdesk = new Helpdesk();
|
||||||
|
$helpdesk->setCanReopen(true);
|
||||||
|
$ticket->setSupportHelpdesk($helpdesk);
|
||||||
|
$support = new class($ticket) extends \Box\Mod\Support\Service {
|
||||||
|
public function __construct(private SupportTicket $ticket) {}
|
||||||
|
public function setDi(\Pimple\Container $di): void { $this->di = $di; }
|
||||||
|
public function getTicketById(int $id): SupportTicket
|
||||||
|
{
|
||||||
|
if ($id !== 4) {
|
||||||
|
throw new \FOSSBilling\InformationException('Ticket not found');
|
||||||
|
}
|
||||||
|
return $this->ticket;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$em = new class {
|
||||||
|
public array $messages = [];
|
||||||
|
public function persist($message): void
|
||||||
|
{
|
||||||
|
if (!$message instanceof SupportTicketMessage) {
|
||||||
|
throw new RuntimeException('Unexpected entity');
|
||||||
|
}
|
||||||
|
(new ReflectionProperty($message, 'id'))->setValue($message, count($this->messages) + 10);
|
||||||
|
$this->messages[] = $message;
|
||||||
|
}
|
||||||
|
public function flush(): void {}
|
||||||
|
};
|
||||||
|
$di = new \Pimple\Container();
|
||||||
|
$lookup = new class {
|
||||||
|
public array $ids = [7];
|
||||||
|
public function fetchFirstColumn($sql, $params) { return $this->ids; }
|
||||||
|
};
|
||||||
|
$di['dbal'] = $lookup;
|
||||||
|
$di['mailgateway_notification_cooldown'] = new class {
|
||||||
|
public function run($sender, $queue): bool { return $queue() !== false; }
|
||||||
|
};
|
||||||
|
$di['db'] = new class($client) {
|
||||||
|
public function __construct(public $client) {}
|
||||||
|
public function getExistingModelById($type, $id) { return $this->client; }
|
||||||
|
};
|
||||||
|
$di['em'] = $em;
|
||||||
|
$di['request'] = new \Symfony\Component\HttpFoundation\Request();
|
||||||
|
$di['events_manager'] = new class {
|
||||||
|
public array $events = [];
|
||||||
|
public function fire($data): void { $this->events[] = $data; }
|
||||||
|
};
|
||||||
|
$di['logger'] = new class {
|
||||||
|
public function info(...$args): void {}
|
||||||
|
public function warning(...$args): void {}
|
||||||
|
};
|
||||||
|
$email = new class {
|
||||||
|
public array $notifications = [];
|
||||||
|
public bool $fail = false;
|
||||||
|
public function sendTemplate($data): void
|
||||||
|
{
|
||||||
|
if ($this->fail) {
|
||||||
|
throw new RuntimeException('Simulated mail failure');
|
||||||
|
}
|
||||||
|
$this->notifications[] = $data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$di['mod_service'] = $di->protect(fn ($module) => $module === 'email' ? $email : $support);
|
||||||
|
$support->setDi($di);
|
||||||
|
$service->setDi($di);
|
||||||
|
$reply = ['ticket_id' => '4', 'sender_email' => 'customer@example.com', 'content' => ' Customer reply '];
|
||||||
|
$result = $service->reply($reply);
|
||||||
|
if ($result !== ['ticket_id' => 4, 'message_id' => 10]
|
||||||
|
|| $ticket->getStatus() !== 'open' || $em->messages[0]->getClientId() !== 7
|
||||||
|
|| $em->messages[0]->getAdminId() !== null || $em->messages[0]->getContent() !== 'Customer reply') {
|
||||||
|
throw new RuntimeException('Customer reply attribution or status incorrect');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([[], [8], [7, 8]] as $ids) {
|
||||||
|
$lookup->ids = $ids;
|
||||||
|
try {
|
||||||
|
$service->reply($reply);
|
||||||
|
throw new RuntimeException('Wrong or ambiguous sender accepted');
|
||||||
|
} catch (\FOSSBilling\InformationException) {}
|
||||||
|
}
|
||||||
|
$lookup->ids = [7];
|
||||||
|
foreach ([0, -1, '4x', [], true, null, 4.5, 999] as $id) {
|
||||||
|
try {
|
||||||
|
$service->reply(array_replace($reply, ['ticket_id' => $id]));
|
||||||
|
throw new RuntimeException('Invalid or missing ticket accepted');
|
||||||
|
} catch (\FOSSBilling\InformationException) {}
|
||||||
|
}
|
||||||
|
$ticket->setStatus('closed');
|
||||||
|
$helpdesk->setCanReopen(false);
|
||||||
|
try {
|
||||||
|
$service->reply($reply);
|
||||||
|
throw new RuntimeException('Reopen restriction bypassed');
|
||||||
|
} catch (\FOSSBilling\InformationException) {}
|
||||||
|
$helpdesk->setCanReopen(true);
|
||||||
|
$service->reply($reply);
|
||||||
|
|
||||||
|
// A guest ticket retains its guest owner even if that address later registers.
|
||||||
|
$ticket->setClientId(null);
|
||||||
|
$ticket->setAccessHash('private-hash');
|
||||||
|
$ticket->setAuthorEmail('guest@example.com');
|
||||||
|
$ticket->setStatus('on_hold');
|
||||||
|
$reply['sender_email'] = 'GUEST@example.com';
|
||||||
|
$service->reply($reply);
|
||||||
|
$message = $em->messages[2];
|
||||||
|
if ($message->getClientId() !== null || $message->getAdminId() !== null || $ticket->getStatus() !== 'open') {
|
||||||
|
throw new RuntimeException('Guest reply attribution or status incorrect');
|
||||||
|
}
|
||||||
|
$reply['sender_email'] = 'other@example.com';
|
||||||
|
try {
|
||||||
|
$service->reply($reply);
|
||||||
|
throw new RuntimeException('Wrong guest accepted');
|
||||||
|
} catch (\FOSSBilling\InformationException) {}
|
||||||
|
if (count($em->messages) !== 3) {
|
||||||
|
throw new RuntimeException('Rejected reply created a message');
|
||||||
|
}
|
||||||
|
if ($email->notifications !== []) {
|
||||||
|
throw new RuntimeException('Direct reply processing attempted a notification');
|
||||||
|
}
|
||||||
|
$service->notifySenderMismatch('other@example.com', 4);
|
||||||
|
foreach ($email->notifications as $notification) {
|
||||||
|
if ($notification !== [
|
||||||
|
'to' => $notification['sender_email'],
|
||||||
|
'code' => 'mailgateway_sender_mismatch',
|
||||||
|
'sender_email' => $notification['sender_email'],
|
||||||
|
'ticket_id' => 4,
|
||||||
|
]) {
|
||||||
|
throw new RuntimeException('Mismatch notification contains incorrect or private ticket data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$email->fail = true;
|
||||||
|
$service->notifySenderMismatch('other@example.com', 4);
|
||||||
|
try {
|
||||||
|
$service->reply($reply);
|
||||||
|
throw new RuntimeException('Mail failure allowed a mismatched reply');
|
||||||
|
} catch (\FOSSBilling\InformationException) {}
|
||||||
|
if (count($em->messages) !== 3) {
|
||||||
|
throw new RuntimeException('Mail failure created a message');
|
||||||
|
}
|
||||||
|
echo "Passed customer and guest reply attribution, sender ownership, ambiguity, ticket validation, and reopen policy using Support ticketReply.\n";
|
||||||
|
echo "Passed mismatch notification recipient, private data exclusion, and rejection despite mail failure.\n";
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/cooldown.php';
|
||||||
|
$connection->executeStatement('CREATE TABLE mailgateway_settings (id INTEGER PRIMARY KEY, config_encrypted TEXT NOT NULL)');
|
||||||
|
$di = new \Pimple\Container();
|
||||||
|
$di['dbal'] = $connection;
|
||||||
|
$di['crypt'] = new class extends \Box_Crypt {
|
||||||
|
public function encrypt(string $text, ?string $pass = null): string { return parent::encrypt($text, 'isolated-settings-test-key'); }
|
||||||
|
public function decrypt(?string $text, ?string $pass = null) { return parent::decrypt($text, 'isolated-settings-test-key'); }
|
||||||
|
};
|
||||||
|
$desk = new \Box\Mod\Support\Entity\Helpdesk();
|
||||||
|
(new ReflectionProperty($desk, 'id'))->setValue($desk, 2);
|
||||||
|
$ticket = new \Box\Mod\Support\Entity\SupportTicket();
|
||||||
|
(new ReflectionProperty($ticket, 'id'))->setValue($ticket, 61);
|
||||||
|
$support = new class($desk, $ticket) {
|
||||||
|
public array $created = [];
|
||||||
|
public function __construct(public $desk, public $ticket) {}
|
||||||
|
public function getHelpdeskRepository() { return $this; }
|
||||||
|
public function find($id) { return $id === 2 ? $this->desk : null; }
|
||||||
|
public function findBy(...$args) { throw new RuntimeException('Configured helpdesk was ignored'); }
|
||||||
|
public function ticketCreateForGuest($data) { $this->created[] = $data; return 'guest-hash'; }
|
||||||
|
public function findOneByHash($hash) { return $this->ticket; }
|
||||||
|
};
|
||||||
|
$di['mod_service'] = $di->protect(fn ($module, $sub = '') => $support);
|
||||||
|
$store = new \Box\Mod\Mailgateway\Settings($di);
|
||||||
|
if ($store->get() !== \Box\Mod\Mailgateway\Settings::DEFAULTS || $store->getPublic()['password_configured']
|
||||||
|
|| $store->get()['host'] !== '' || $store->get()['username'] !== '' || $store->get()['mailbox'] !== '') {
|
||||||
|
throw new RuntimeException('Settings defaults incorrect');
|
||||||
|
}
|
||||||
|
$input = array_replace(\Box\Mod\Mailgateway\Settings::DEFAULTS, [
|
||||||
|
'host' => 'mail.example.com', 'username' => 'support@example.com', 'mailbox' => 'support@example.com',
|
||||||
|
'enabled' => '0', 'support_helpdesk_id' => '2', 'password' => 'test-only-password',
|
||||||
|
]);
|
||||||
|
$store->save($input);
|
||||||
|
$stored = $connection->fetchOne('SELECT config_encrypted FROM mailgateway_settings WHERE id = 1');
|
||||||
|
if (!str_starts_with($stored, 'v2:') || str_contains($stored, 'test-only-password')
|
||||||
|
|| str_contains($stored, 'support@example.com') || array_key_exists('password', $store->getPublic())) {
|
||||||
|
throw new RuntimeException('Settings stored or exposed in plaintext');
|
||||||
|
}
|
||||||
|
$input['password'] = '';
|
||||||
|
$store->save($input);
|
||||||
|
if ($store->get()['password'] !== 'test-only-password' || !$store->getPublic()['password_configured']) {
|
||||||
|
throw new RuntimeException('Blank password did not preserve saved credential');
|
||||||
|
}
|
||||||
|
foreach ([
|
||||||
|
['host' => 'https://example.com'], ['port' => '0'], ['port' => '65536'],
|
||||||
|
['encryption' => 'none'], ['mailbox' => 'bad-email'], ['enabled' => 'maybe'],
|
||||||
|
['inbox_folder' => 'Processed'], ['username' => "bad\r\nuser"],
|
||||||
|
['support_helpdesk_id' => '999'], ['password' => ['invalid']],
|
||||||
|
['clear_password' => '1', 'password' => 'conflicting-password'],
|
||||||
|
] as $changes) {
|
||||||
|
try {
|
||||||
|
$store->save(array_replace($input, $changes));
|
||||||
|
throw new RuntimeException('Invalid settings accepted');
|
||||||
|
} catch (\FOSSBilling\InformationException) {}
|
||||||
|
}
|
||||||
|
$service = new \Box\Mod\Mailgateway\Service();
|
||||||
|
$service->setDi($di);
|
||||||
|
$result = $service->import(['sender_email' => 'new-guest@example.com', 'subject' => 'Configured helpdesk', 'content' => 'Test body']);
|
||||||
|
if ($result['support_helpdesk_id'] !== 2 || $support->created[0]['support_helpdesk_id'] !== 2) {
|
||||||
|
throw new RuntimeException('Selected helpdesk not used for import');
|
||||||
|
}
|
||||||
|
$input['enabled'] = '0';
|
||||||
|
$input['clear_password'] = '1';
|
||||||
|
$store->save($input);
|
||||||
|
if ($store->getPublic()['password_configured']) { throw new RuntimeException('Password not cleared'); }
|
||||||
|
try {
|
||||||
|
$store->save(array_replace($input, ['enabled' => '1', 'clear_password' => '0']));
|
||||||
|
throw new RuntimeException('Enabled imports without a password');
|
||||||
|
} catch (\FOSSBilling\InformationException) {}
|
||||||
|
|
||||||
|
$input['clear_password'] = '0';
|
||||||
|
$input['password'] = 'test-only-password';
|
||||||
|
$store->save($input);
|
||||||
|
$source = file_get_contents(dirname(__DIR__) . '/templates/admin/mod_mailgateway_settings.html.twig');
|
||||||
|
$twig = new \Twig\Environment(new \Twig\Loader\ArrayLoader([
|
||||||
|
'settings' => $source,
|
||||||
|
'layout_default.html.twig' => '{% block content %}{% endblock %}',
|
||||||
|
'layout_blank.html.twig' => '{% block content %}{% endblock %}',
|
||||||
|
]), ['autoescape' => 'html', 'strict_variables' => true]);
|
||||||
|
$twig->addFilter(new \Twig\TwigFilter('trans', fn ($text) => $text));
|
||||||
|
$twig->addFilter(new \Twig\TwigFilter('api_url', fn ($path, $role = 'admin') => '/api/admin/' . $path));
|
||||||
|
$twig->addFunction(new \Twig\TwigFunction('fb_api_form', fn ($options) => 'class="api-form"', ['is_safe' => ['html']]));
|
||||||
|
$admin = new class($store) {
|
||||||
|
public function __construct(public $store) {}
|
||||||
|
public function mailgateway_settings_get($data) { return ['settings' => $this->store->getPublic(), 'helpdesks' => [2 => 'Helpdesk <script>']]; }
|
||||||
|
};
|
||||||
|
$html = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $admin]);
|
||||||
|
if (str_contains($html, 'test-only-password') || str_contains($html, 'Helpdesk <script>')
|
||||||
|
|| !str_contains($html, 'Helpdesk <script>') || !str_contains($html, '/api/admin/mailgateway/settings_save')) {
|
||||||
|
throw new RuntimeException('Settings page leaked credentials or failed to escape values');
|
||||||
|
}
|
||||||
|
$api = new \Box\Mod\Mailgateway\Api\Admin();
|
||||||
|
foreach (['settings_get', 'settings_save', 'install_dependencies', 'setup', 'mailbox_delete', 'notifications_setup'] as $method) {
|
||||||
|
try {
|
||||||
|
$api->$method([]);
|
||||||
|
throw new RuntimeException('Unauthenticated settings access accepted');
|
||||||
|
} catch (\FOSSBilling\InformationException) {}
|
||||||
|
}
|
||||||
|
echo "Passed settings validation, actual encryption, secret redaction, password retention/clearing, selected helpdesk routing, Twig rendering/escaping, and unauthenticated rejection.\n";
|
||||||
|
|
||||||
|
// Existing single-mailbox ciphertext migrates without losing its credentials.
|
||||||
|
$legacy = array_replace(\Box\Mod\Mailgateway\Settings::DEFAULTS, ['host' => 'legacy.example.com', 'username' => 'legacy@example.com', 'mailbox' => 'legacy@example.com', 'password' => 'legacy-password', 'support_helpdesk_id' => 2]);
|
||||||
|
$connection->update('mailgateway_settings', ['config_encrypted' => $di['crypt']->encrypt(json_encode($legacy))], ['id' => 1]);
|
||||||
|
if ($store->get() !== $legacy) { throw new RuntimeException('Legacy mailbox values or password changed'); }
|
||||||
|
$secondDesk = new \Box\Mod\Support\Entity\Helpdesk();
|
||||||
|
(new ReflectionProperty($secondDesk, 'id'))->setValue($secondDesk, 3);
|
||||||
|
$multiSupport = new class($support, $secondDesk) {
|
||||||
|
public function __construct(public $original, public $secondDesk) {}
|
||||||
|
public function getHelpdeskRepository() { return $this; }
|
||||||
|
public function find($id) { return $id === 3 ? $this->secondDesk : $this->original->find($id); }
|
||||||
|
public function ticketCreateForGuest($data) { return $this->original->ticketCreateForGuest($data); }
|
||||||
|
public function findOneByHash($hash) { return $this->original->findOneByHash($hash); }
|
||||||
|
};
|
||||||
|
unset($di['mod_service']);
|
||||||
|
$di['mod_service'] = $di->protect(fn ($module, $sub = '') => $multiSupport);
|
||||||
|
$new = array_replace($input, ['mailbox_id' => 'new', 'mailbox' => 'billing@example.com', 'username' => 'billing@example.com', 'support_helpdesk_id' => 3, 'password' => 'second-password']);
|
||||||
|
$store->save($new);
|
||||||
|
$mailboxes = $store->getAllPublic();
|
||||||
|
$newId = array_key_last($mailboxes);
|
||||||
|
if (count($mailboxes) !== 2 || $store->get()['password'] !== 'legacy-password' || $store->get($newId)['password'] !== 'second-password') {
|
||||||
|
throw new RuntimeException('Adding mailbox overwrote credentials');
|
||||||
|
}
|
||||||
|
$new['mailbox_id'] = $newId;
|
||||||
|
$new['password'] = '';
|
||||||
|
$store->save($new);
|
||||||
|
if ($store->get($newId)['password'] !== 'second-password') { throw new RuntimeException('Wrong mailbox password retained'); }
|
||||||
|
try {
|
||||||
|
$store->save(array_replace($new, ['mailbox_id' => 'new']));
|
||||||
|
throw new RuntimeException('Duplicate mailbox accepted');
|
||||||
|
} catch (\FOSSBilling\InformationException) {}
|
||||||
|
$result = $service->import(['mailbox' => 'BILLING@example.com', 'sender_email' => 'new-guest@example.com', 'subject' => 'Second helpdesk', 'content' => 'Body']);
|
||||||
|
if ($result['support_helpdesk_id'] !== 3) { throw new RuntimeException('Second mailbox routing failed'); }
|
||||||
|
try {
|
||||||
|
$service->import(['mailbox' => 'unknown@example.com', 'sender_email' => 'new-guest@example.com', 'subject' => 'Unknown mailbox', 'content' => 'Body']);
|
||||||
|
throw new RuntimeException('Unknown mailbox routed to default');
|
||||||
|
} catch (\FOSSBilling\InformationException) {}
|
||||||
|
$admin = new class($store) {
|
||||||
|
public function __construct(public $store) {}
|
||||||
|
public function mailgateway_settings_get($data) { return ['settings' => $this->store->getPublic(), 'mailboxes' => $this->store->getAllPublic(), 'helpdesks' => [2 => 'General', 3 => 'Billing']]; }
|
||||||
|
};
|
||||||
|
$html = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $admin]);
|
||||||
|
if (!str_contains($html, 'billing@example.com') || substr_count($html, '>Test Connection and Create Folders</button>') !== 2 || str_contains($html, 'second-password') || str_contains($html, 'legacy-password')) {
|
||||||
|
throw new RuntimeException('Multiple mailbox rendering failed');
|
||||||
|
}
|
||||||
|
require dirname(__DIR__) . '/ImapConnectionTest.php';
|
||||||
|
trait ImapTestCapabilities
|
||||||
|
{
|
||||||
|
public bool $supportsMove = true;
|
||||||
|
public bool $failCapabilities = false;
|
||||||
|
public function getConnection() {
|
||||||
|
return new class($this) {
|
||||||
|
public function __construct(public $client) {}
|
||||||
|
public function getCapabilities() {
|
||||||
|
if ($this->client->failCapabilities) { throw new RuntimeException('SECRET capabilities'); }
|
||||||
|
return new class($this->client->supportsMove) {
|
||||||
|
public function __construct(public bool $move) {}
|
||||||
|
public function validatedData() { return $this->move ? ['IMAP4rev1', 'MOVE'] : ['IMAP4rev1']; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$client = new class {
|
||||||
|
use ImapTestCapabilities;
|
||||||
|
public bool $disconnected = false;
|
||||||
|
public bool $fail = false;
|
||||||
|
public function connect() { if ($this->fail) { throw new RuntimeException('secret-second-password'); } }
|
||||||
|
public function getFolders($hierarchical) {
|
||||||
|
if ($hierarchical !== false) { throw new RuntimeException('Expected flat folder listing'); }
|
||||||
|
return [(object) ['path' => 'inbox'], (object) ['path' => 'Processed']];
|
||||||
|
}
|
||||||
|
public function disconnect() { $this->disconnected = true; }
|
||||||
|
};
|
||||||
|
$test = new \Box\Mod\Mailgateway\ImapConnectionTest(function ($config) use ($client) {
|
||||||
|
if (!$config['validate_cert'] || $config['password'] !== 'second-password' || $config['username'] !== 'billing@example.com') { throw new RuntimeException('Wrong IMAP config'); }
|
||||||
|
return $client;
|
||||||
|
});
|
||||||
|
$check = $test->test($store->get($newId));
|
||||||
|
if (!$check['connected'] || !$check['folders'][0]['exists'] || $check['folders'][2]['exists'] || !$client->disconnected) { throw new RuntimeException('IMAP folder test failed'); }
|
||||||
|
$client->fail = true;
|
||||||
|
$client->disconnected = false;
|
||||||
|
$failedCheck = $test->test($store->get($newId));
|
||||||
|
if ($failedCheck['success'] || $failedCheck['connected'] || !$client->disconnected
|
||||||
|
|| str_contains(json_encode($failedCheck), 'secret')) {
|
||||||
|
throw new RuntimeException('IMAP failure leaked secrets or skipped cleanup');
|
||||||
|
}
|
||||||
|
try { $api->test_connection([]); throw new RuntimeException('Unauthenticated IMAP test accepted'); } catch (\FOSSBilling\InformationException) {}
|
||||||
|
echo "Passed legacy migration, multiple mailbox credential isolation, duplicate rejection, per-mailbox helpdesk routing, multi-mailbox Twig rendering, read-only IMAP checks, disconnect cleanup, and secret-safe failures.\n";
|
||||||
|
|
||||||
|
// Render all dependency states using the actual settings-page template.
|
||||||
|
foreach ([
|
||||||
|
[['installed' => true, 'available' => true], 'Mail Gateway dependencies are installed.', true],
|
||||||
|
[['installed' => false, 'available' => true], 'Install dependencies', true],
|
||||||
|
[['installed' => false, 'available' => false], 'Automatic installation is unavailable', false],
|
||||||
|
] as [$dependencies, $expected, $button]) {
|
||||||
|
$admin = new class($store, $dependencies) {
|
||||||
|
public function __construct(public $store, public $dependencies) {}
|
||||||
|
public function mailgateway_settings_get($data) {
|
||||||
|
return ['settings' => $this->store->getPublic(), 'mailboxes' => $this->store->getAllPublic(),
|
||||||
|
'helpdesks' => [2 => 'General', 3 => 'Billing'], 'dependencies' => $this->dependencies];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$html = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $admin]);
|
||||||
|
if (!str_contains($html, $expected) || str_contains($html, '/api/admin/mailgateway/install_dependencies') !== $button) {
|
||||||
|
throw new RuntimeException('Dependency installation controls rendered incorrectly');
|
||||||
|
}
|
||||||
|
preg_match('/<input[^>]*type="checkbox"[^>]*name="enabled"[^>]*>/', $html, $switch);
|
||||||
|
if ($switch === [] || !str_contains($switch[0], 'disabled')) {
|
||||||
|
throw new RuntimeException('Import switch is not gated by installed dependencies');
|
||||||
|
}
|
||||||
|
if ($dependencies['installed'] && strpos($html, 'Mail Gateway dependencies are installed.') < strpos($html, '>Advanced</summary>')) {
|
||||||
|
throw new RuntimeException('Installed dependency status is outside Advanced');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require_once dirname(__DIR__) . '/DependencyInstaller.php';
|
||||||
|
$runner = new class extends \Box\Mod\Mailgateway\DependencyInstaller {
|
||||||
|
public function executeForTest(array $command): array { return $this->run($command); }
|
||||||
|
};
|
||||||
|
$result = $runner->executeForTest([PHP_BINARY, '-r', 'fwrite(STDOUT, str_repeat("secret", 30000)); fwrite(STDERR, str_repeat("secret", 30000)); exit(7);']);
|
||||||
|
if ($result !== ['exit' => 7, 'timeout' => false]) {
|
||||||
|
throw new RuntimeException('Dependency runner failed to drain pipes, preserve exit code or suppress sensitive output');
|
||||||
|
}
|
||||||
|
echo "Passed dependency controls, installer authentication, subprocess pipe draining, exit-code handling, and output redaction without running Composer.\n";
|
||||||
|
|
||||||
|
$reinstaller = new class extends \Box\Mod\Mailgateway\DependencyInstaller {
|
||||||
|
public array $commands = [];
|
||||||
|
protected function run(array $command): array {
|
||||||
|
$this->commands[] = $command;
|
||||||
|
return ['exit' => 0, 'timeout' => false];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$reinstaller->install();
|
||||||
|
if ($reinstaller->commands !== []) { throw new RuntimeException('Normal install did not skip existing dependencies'); }
|
||||||
|
$reinstaller->install(true);
|
||||||
|
if (count($reinstaller->commands) !== 1 || !in_array('install', $reinstaller->commands[0], true)
|
||||||
|
|| !in_array('--no-scripts', $reinstaller->commands[0], true) || !in_array('--no-plugins', $reinstaller->commands[0], true)) {
|
||||||
|
throw new RuntimeException('Forced Composer rerun did not reach the installer');
|
||||||
|
}
|
||||||
|
if (!str_contains($html, '>Advanced</summary>') || !str_contains($html, '/api/admin/mailgateway/setup')) {
|
||||||
|
throw new RuntimeException('Advanced table setup control missing');
|
||||||
|
}
|
||||||
|
$missingDb = \Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
|
||||||
|
$missingDi = new \Pimple\Container();
|
||||||
|
$missingDi['dbal'] = $missingDb;
|
||||||
|
if ((new \Box\Mod\Mailgateway\Settings($missingDi))->get()['host'] !== '') {
|
||||||
|
throw new RuntimeException('Missing settings table prevented access to setup page');
|
||||||
|
}
|
||||||
|
echo "Passed Advanced rendering, forced Composer rerun, and settings recovery when module tables are missing.\n";
|
||||||
|
|
||||||
|
$di['mailgateway_dependency_installer'] = new class {
|
||||||
|
public function status() { return ['installed' => false]; }
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
$store->save(array_replace($new, ['enabled' => '1']));
|
||||||
|
throw new RuntimeException('Importing enabled without installed dependencies');
|
||||||
|
} catch (\FOSSBilling\InformationException) {}
|
||||||
|
$store->save(array_replace($new, ['enabled' => '0']));
|
||||||
|
unset($di['mailgateway_dependency_installer']);
|
||||||
|
|
||||||
|
$folderClient = new class {
|
||||||
|
use ImapTestCapabilities;
|
||||||
|
public array $paths = ['Processed'];
|
||||||
|
public array $created = [];
|
||||||
|
public bool $disconnected = false;
|
||||||
|
public bool $failCreate = false;
|
||||||
|
public function connect() {}
|
||||||
|
public function getFolders($hierarchical) { return array_map(fn ($path) => (object) ['path' => $path], $this->paths); }
|
||||||
|
public function createFolder($path, $expunge) {
|
||||||
|
if ($expunge !== false || $path === 'INBOX') { throw new RuntimeException('Unsafe folder creation'); }
|
||||||
|
if ($this->failCreate) { throw new RuntimeException('SECRET folder failure'); }
|
||||||
|
$this->created[] = $path;
|
||||||
|
$this->paths[] = $path;
|
||||||
|
}
|
||||||
|
public function disconnect() { $this->disconnected = true; }
|
||||||
|
};
|
||||||
|
$folderTest = new \Box\Mod\Mailgateway\ImapConnectionTest(fn ($config) => $folderClient);
|
||||||
|
$check = $folderTest->test($store->get($newId), true);
|
||||||
|
if ($folderClient->created !== ['Ignored', 'Failed'] || $check['folders'][0]['exists']
|
||||||
|
|| !$check['folders'][2]['created'] || !$check['folders'][3]['exists'] || !$folderClient->disconnected) {
|
||||||
|
throw new RuntimeException('Destination folder creation or missing-inbox handling failed');
|
||||||
|
}
|
||||||
|
$folderTest->test($store->get($newId), true);
|
||||||
|
if ($folderClient->created !== ['Ignored', 'Failed']) { throw new RuntimeException('Existing folders were recreated'); }
|
||||||
|
$folderClient->paths = [];
|
||||||
|
$folderClient->failCreate = true;
|
||||||
|
$folderClient->disconnected = false;
|
||||||
|
$folderFailure = $folderTest->test($store->get($newId), true);
|
||||||
|
if ($folderFailure['success'] || !$folderFailure['connected'] || !$folderClient->disconnected
|
||||||
|
|| str_contains(json_encode($folderFailure), 'SECRET')
|
||||||
|
|| !str_contains($folderFailure['folders'][1]['error'], 'Folder creation failed')) {
|
||||||
|
throw new RuntimeException('Folder failure did not distinguish login from folder creation');
|
||||||
|
}
|
||||||
|
echo "Passed dependency status placement, disabled import switches, server-side enablement guard, safe destination folder creation, repeated setup, and failure cleanup.\n";
|
||||||
|
|
||||||
|
// Readiness is per mailbox, persisted, and tied to the tested configuration.
|
||||||
|
$readyClient = new class {
|
||||||
|
use ImapTestCapabilities;
|
||||||
|
public function connect() {}
|
||||||
|
public function getFolders($hierarchical) { return array_map(fn ($path) => (object) ['path' => $path], ['INBOX', 'Processed', 'Ignored', 'Failed']); }
|
||||||
|
public function disconnect() {}
|
||||||
|
};
|
||||||
|
$readyTest = new \Box\Mod\Mailgateway\ImapConnectionTest(fn ($config) => $readyClient);
|
||||||
|
$readySettings = $store->get($newId);
|
||||||
|
$readyResult = $readyTest->test($readySettings, true);
|
||||||
|
if (!$readyResult['success']) { throw new RuntimeException('Valid mailbox did not pass readiness'); }
|
||||||
|
$store->recordTest($newId, $readySettings, $readyResult);
|
||||||
|
if (!$store->getAllPublic()[$newId]['test_passed'] || $store->getPublic()['test_passed']) {
|
||||||
|
throw new RuntimeException('Readiness did not remain isolated to tested mailbox');
|
||||||
|
}
|
||||||
|
$store->save(array_replace($new, ['enabled' => '1']));
|
||||||
|
if (!$store->get($newId)['enabled']) { throw new RuntimeException('Tested mailbox could not be enabled'); }
|
||||||
|
$store->save(array_replace($new, ['enabled' => '1', 'host' => 'new-host.example.com']));
|
||||||
|
if ($store->get($newId)['enabled'] || $store->getAllPublic()[$newId]['test_passed']) {
|
||||||
|
throw new RuntimeException('Connection changes did not revoke readiness and disable imports');
|
||||||
|
}
|
||||||
|
try { $store->recordTest($newId, $readySettings, $readyResult); throw new RuntimeException('Stale test accepted'); }
|
||||||
|
catch (\FOSSBilling\InformationException) {}
|
||||||
|
$readySettings = $store->get($newId);
|
||||||
|
$store->recordTest($newId, $readySettings, $readyResult);
|
||||||
|
$store->save(array_replace($new, ['enabled' => '1', 'host' => 'new-host.example.com']));
|
||||||
|
$store->recordTest($newId, $store->get($newId), $folderFailure);
|
||||||
|
if ($store->get($newId)['enabled'] || $store->getAllPublic()[$newId]['test_passed']
|
||||||
|
|| $store->getAllPublic()[$newId]['test_result'] !== $folderFailure) {
|
||||||
|
throw new RuntimeException('Failed retest did not disable importing and persist diagnostics');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$store->save(array_replace($new, ['enabled' => '1', 'host' => 'new-host.example.com', 'tested_config_hash' => 'fake', 'test_passed' => true]));
|
||||||
|
throw new RuntimeException('Unverified enablement or spoofed readiness accepted');
|
||||||
|
} catch (\FOSSBilling\InformationException) {}
|
||||||
|
$sourceSettings = $store->get($newId);
|
||||||
|
$di['mailgateway_connection_test'] = $readyTest;
|
||||||
|
$service->testConnection(['mailbox_id' => $newId]);
|
||||||
|
if (!(new \Box\Mod\Mailgateway\Settings($di))->getAllPublic()[$newId]['test_passed']) {
|
||||||
|
throw new RuntimeException('Settings-page connection test did not persist readiness');
|
||||||
|
}
|
||||||
|
unset($di['mailgateway_connection_test']);
|
||||||
|
$di['mailgateway_connection_test'] = new class($folderFailure) {
|
||||||
|
public function __construct(public $result) {}
|
||||||
|
public function test($settings, $create) { return $this->result; }
|
||||||
|
};
|
||||||
|
$service->testConnection(['mailbox_id' => $newId]);
|
||||||
|
$admin = new class($store) {
|
||||||
|
public function __construct(public $store) {}
|
||||||
|
public function mailgateway_settings_get($data) {
|
||||||
|
return ['settings' => $this->store->getPublic(), 'mailboxes' => $this->store->getAllPublic(),
|
||||||
|
'dependencies' => ['installed' => true, 'available' => true], 'helpdesks' => [2 => 'General', 3 => 'Billing']];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$html = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $admin]);
|
||||||
|
if (!str_contains($html, 'Folder creation failed') || !str_contains($html, 'Importing remains disabled')
|
||||||
|
|| !str_contains($html, 'title="Save this mailbox, then run Test Connection')) {
|
||||||
|
throw new RuntimeException('Persisted failure or hover explanation missing from page');
|
||||||
|
}
|
||||||
|
echo "Passed per-mailbox readiness, server-side enablement enforcement, configuration invalidation, stale-test rejection, failed-retest disabling, persisted test results, and explanatory UI.\n";
|
||||||
|
|
||||||
|
$keptMailbox = $store->get('default');
|
||||||
|
$store->delete($newId);
|
||||||
|
if (count($store->getAll()) !== 1 || $store->get('default') !== $keptMailbox) {
|
||||||
|
throw new RuntimeException('Deleting a mailbox modified another mailbox');
|
||||||
|
}
|
||||||
|
try { $store->delete('unknown'); throw new RuntimeException('Unknown mailbox deletion accepted'); }
|
||||||
|
catch (\FOSSBilling\InformationException) {}
|
||||||
|
$store->delete('default');
|
||||||
|
if ($store->getAll() !== [] || $store->getPublic()['mailbox'] !== '') {
|
||||||
|
throw new RuntimeException('Deleting last mailbox failed');
|
||||||
|
}
|
||||||
|
$html = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $admin]);
|
||||||
|
if (str_contains($html, '>Delete mailbox</button>') || !str_contains($html, 'Add mailbox')) {
|
||||||
|
throw new RuntimeException('Empty mailbox list rendered a phantom mailbox');
|
||||||
|
}
|
||||||
|
$new['mailbox_id'] = 'new';
|
||||||
|
$new['enabled'] = '0';
|
||||||
|
$new['password'] = 'replacement-password';
|
||||||
|
$store->save($new);
|
||||||
|
if (count($store->getAll()) !== 1 || $store->get()['password'] !== 'replacement-password') {
|
||||||
|
throw new RuntimeException('Adding mailbox after deleting last mailbox failed');
|
||||||
|
}
|
||||||
|
echo "Passed mailbox deletion, remaining credential preservation, unknown-ID rejection, empty-page rendering, and adding a replacement mailbox.\n";
|
||||||
|
|
||||||
|
$readyClient->supportsMove = false;
|
||||||
|
$noMove = $readyTest->test($sourceSettings, true);
|
||||||
|
if (!$noMove['connected'] || $noMove['success'] || $noMove['move_supported'] !== false
|
||||||
|
|| !str_contains(implode(' ', $noMove['errors']), 'does not support native MOVE')) {
|
||||||
|
throw new RuntimeException('Successful login without MOVE was not rejected clearly');
|
||||||
|
}
|
||||||
|
$readyClient->supportsMove = true;
|
||||||
|
$readyClient->failCapabilities = true;
|
||||||
|
$capabilityFailure = $readyTest->test($sourceSettings, true);
|
||||||
|
if (!$capabilityFailure['connected'] || $capabilityFailure['success']
|
||||||
|
|| !str_contains(implode(' ', $capabilityFailure['errors']), 'checking server MOVE support failed')
|
||||||
|
|| str_contains(json_encode($capabilityFailure), 'SECRET')) {
|
||||||
|
throw new RuntimeException('MOVE check failure did not preserve login result or hide secrets');
|
||||||
|
}
|
||||||
|
$readyClient->failCapabilities = false;
|
||||||
|
echo "Passed native MOVE requirement, distinct capability failures, and secret-safe shared connection diagnostics.\n";
|
||||||
|
|
||||||
|
$notificationDi = new \Pimple\Container();
|
||||||
|
$notificationEmail = new class {
|
||||||
|
public array $templates = [];
|
||||||
|
public function getTemplateRepository() { return $this; }
|
||||||
|
public function findOneByActionCode($code) { return $this->templates[$code] ?? null; }
|
||||||
|
public function templateToApiArray($template) {
|
||||||
|
return ['enabled' => $template->isEnabled(), 'subject' => $template->getSubject()];
|
||||||
|
}
|
||||||
|
public int $updates = 0;
|
||||||
|
public function updateTemplate($template, $enabled = null, $category = null, $subject = null): bool {
|
||||||
|
if ($enabled !== null) $template->setEnabled($enabled);
|
||||||
|
if ($subject !== null) $template->setSubject($subject);
|
||||||
|
++$this->updates;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
public function templateCreate($code, $subject, $content, $enabled, $category) {
|
||||||
|
return $this->templates[$code] = (new \Box\Mod\Email\Entity\EmailTemplate($code))
|
||||||
|
->setSubject($subject)->setContent($content)->setEnabled((bool) $enabled);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
foreach (['mod_support_ticket_open', 'mod_support_ticket_staff_reply',
|
||||||
|
'mod_support_ticket_staff_open', 'mod_support_ticket_staff_close'] as $code) {
|
||||||
|
$notificationEmail->templates[$code] = (new \Box\Mod\Email\Entity\EmailTemplate($code))
|
||||||
|
->setSubject('[Company] [Ticket #{{ ticket.id }}] {{ ticket.subject }}');
|
||||||
|
}
|
||||||
|
$notificationDi['mod_service'] = $notificationDi->protect(fn ($module) => $notificationEmail);
|
||||||
|
$notificationStore = new class {
|
||||||
|
public int $saves = 0;
|
||||||
|
public function save($data): bool { ++$this->saves; return true; }
|
||||||
|
};
|
||||||
|
$notificationDi['mailgateway_settings'] = $notificationStore;
|
||||||
|
$notificationService = new \Box\Mod\Mailgateway\Service();
|
||||||
|
$notificationService->setDi($notificationDi);
|
||||||
|
if (!$notificationService->getNotificationStatus()['ready']) {
|
||||||
|
throw new RuntimeException('Valid ticket notification templates were rejected');
|
||||||
|
}
|
||||||
|
$notificationService->saveSettings(['enabled' => '1']);
|
||||||
|
$notificationEmail->templates['mod_support_ticket_open']->setEnabled(false);
|
||||||
|
$notificationService->saveSettings(['enabled' => '1']);
|
||||||
|
if (!$notificationEmail->templates['mod_support_ticket_open']->isEnabled() || $notificationStore->saves !== 2) {
|
||||||
|
throw new RuntimeException('Acknowledgement was not enabled automatically');
|
||||||
|
}
|
||||||
|
$notificationService->saveSettings(['enabled' => '0']);
|
||||||
|
$notificationEmail->templates['mod_support_ticket_open']->setEnabled(true);
|
||||||
|
$notificationEmail->templates['mod_support_ticket_staff_reply']->setSubject('Reply without marker');
|
||||||
|
if ($notificationService->getNotificationStatus()['ready']) {
|
||||||
|
throw new RuntimeException('Missing staff reply ticket marker accepted');
|
||||||
|
}
|
||||||
|
$notificationEmail->templates['mod_support_ticket_staff_reply']->setEnabled(false);
|
||||||
|
if (!$notificationService->getNotificationStatus()['ready']) {
|
||||||
|
throw new RuntimeException('Optional disabled staff notification rejected');
|
||||||
|
}
|
||||||
|
unset($notificationEmail->templates['mod_support_ticket_open']);
|
||||||
|
if ($notificationService->getNotificationStatus()['ready']) {
|
||||||
|
throw new RuntimeException('Missing acknowledgement template accepted');
|
||||||
|
}
|
||||||
|
$notificationEmail->templates['mod_support_ticket_staff_reply']->setEnabled(true)->setContent('Keep this body');
|
||||||
|
$notificationService->saveSettings(['enabled' => '1']);
|
||||||
|
if (!$notificationService->getNotificationStatus()['ready']
|
||||||
|
|| $notificationEmail->templates['mod_support_ticket_staff_reply']->getSubject() !== '[Ticket #{{ ticket.id }}] Reply without marker'
|
||||||
|
|| $notificationEmail->templates['mod_support_ticket_staff_reply']->getContent() !== 'Keep this body') {
|
||||||
|
throw new RuntimeException('Automatic notification repair failed to preserve wording');
|
||||||
|
}
|
||||||
|
$updates = $notificationEmail->updates;
|
||||||
|
$notificationService->configureNotifications();
|
||||||
|
if ($notificationEmail->updates !== $updates) throw new RuntimeException('Repeated notification repair changed valid templates');
|
||||||
|
echo "Passed notification readiness, automatic repair, preserved wording, and repeatability.\n";
|
||||||
|
|
||||||
|
$notificationAdmin = new class($store) {
|
||||||
|
public bool $ready = true;
|
||||||
|
public function __construct(public $store) {}
|
||||||
|
public function mailgateway_settings_get($data) {
|
||||||
|
return ['settings' => $this->store->getPublic(), 'mailboxes' => $this->store->getAllPublic(),
|
||||||
|
'helpdesks' => [2 => 'General'], 'dependencies' => ['installed' => true, 'available' => false],
|
||||||
|
'notifications' => ['ready' => $this->ready, 'errors' => $this->ready ? [] : ['Template <needs repair>']]];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$notificationHtml = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $notificationAdmin]);
|
||||||
|
if (!preg_match('/id="mg-notification-banner"[^>]*hidden/', $notificationHtml)
|
||||||
|
|| !str_contains($notificationHtml, 'mailgateway/notifications_setup')) {
|
||||||
|
throw new RuntimeException('Ready notification banner was not hidden or Advanced setup was missing');
|
||||||
|
}
|
||||||
|
$notificationAdmin->ready = false;
|
||||||
|
$notificationHtml = $twig->render('settings', ['request' => ['ajax' => false], 'admin' => $notificationAdmin]);
|
||||||
|
if (preg_match('/id="mg-notification-banner"[^>]*hidden/', $notificationHtml)
|
||||||
|
|| !str_contains($notificationHtml, 'Template <needs repair>')) {
|
||||||
|
throw new RuntimeException('Notification warnings were hidden or not escaped');
|
||||||
|
}
|
||||||
|
echo "Passed notification banner visibility, Advanced setup rendering, and warning escaping.\n";
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/replies.php';
|
||||||
|
require dirname(__DIR__) . '/MessageTracker.php';
|
||||||
|
|
||||||
|
$connection = \Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
|
||||||
|
$connection->executeStatement('CREATE TABLE mailgateway_message (message_key TEXT PRIMARY KEY, mailbox TEXT, message_id TEXT, payload_hash TEXT, ticket_id INTEGER, result_json TEXT)');
|
||||||
|
$connection->executeStatement('CREATE TABLE test_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER)');
|
||||||
|
$trackingService = new class($connection) extends \Box\Mod\Mailgateway\Service {
|
||||||
|
public bool $fail = false;
|
||||||
|
public bool $mismatch = false;
|
||||||
|
public int $notifications = 0;
|
||||||
|
public function __construct(private \Doctrine\DBAL\Connection $connection) {}
|
||||||
|
public function import(array $data): array
|
||||||
|
{
|
||||||
|
$this->connection->insert('test_messages', ['ticket_id' => 41]);
|
||||||
|
if ($this->fail) {
|
||||||
|
throw new RuntimeException('Failure after ticket write');
|
||||||
|
}
|
||||||
|
return ['ticket_id' => 41, 'client_id' => 7];
|
||||||
|
}
|
||||||
|
public function reply(array $data): array
|
||||||
|
{
|
||||||
|
if ($data['ticket_id'] === 999) {
|
||||||
|
throw new \FOSSBilling\InformationException('Ticket not found');
|
||||||
|
}
|
||||||
|
if ($this->mismatch) {
|
||||||
|
throw new \FOSSBilling\InformationException('Sender does not own this ticket');
|
||||||
|
}
|
||||||
|
$this->connection->insert('test_messages', ['ticket_id' => $data['ticket_id']]);
|
||||||
|
return ['ticket_id' => $data['ticket_id'], 'message_id' => 12];
|
||||||
|
}
|
||||||
|
public function notifySenderMismatch(string $sender, int $ticketId): void { ++$this->notifications; }
|
||||||
|
};
|
||||||
|
$trackingDi = new \Pimple\Container();
|
||||||
|
$trackingDi['em'] = new class {
|
||||||
|
public int $clears = 0;
|
||||||
|
public function clear(): void { ++$this->clears; }
|
||||||
|
};
|
||||||
|
$trackingDi['mod_service'] = $trackingDi->protect(fn ($module) => new class {
|
||||||
|
public function getTicketById($id): void {}
|
||||||
|
});
|
||||||
|
$trackingService->setDi($trackingDi);
|
||||||
|
$tracker = new \Box\Mod\Mailgateway\MessageTracker($connection);
|
||||||
|
$data = ['mailbox' => 'support@dev.sebs.space', 'message_id' => '<initial@example.com>',
|
||||||
|
'sender_email' => 'customer@example.com', 'subject' => 'Test', 'content' => 'Test content'];
|
||||||
|
$initial = $tracker->process($data, $trackingService);
|
||||||
|
$duplicate = $tracker->process($data, $trackingService);
|
||||||
|
if ($initial['action'] !== 'ticket' || $initial['duplicate'] || !$duplicate['duplicate']
|
||||||
|
|| (int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages') !== 1) {
|
||||||
|
throw new RuntimeException('Duplicate created another ticket');
|
||||||
|
}
|
||||||
|
$expectRejected = function (array $input) use ($tracker, $trackingService): void {
|
||||||
|
try {
|
||||||
|
$tracker->process($input, $trackingService);
|
||||||
|
throw new RuntimeException('Invalid tracked message accepted');
|
||||||
|
} catch (\FOSSBilling\InformationException) {}
|
||||||
|
};
|
||||||
|
$expectRejected(array_replace($data, ['content' => 'Changed contents']));
|
||||||
|
foreach (['bad-id', "<id@example.com>\r\nInjected", '<id example.com>'] as $id) {
|
||||||
|
$expectRejected(array_replace($data, ['message_id' => $id]));
|
||||||
|
}
|
||||||
|
$replyData = array_replace($data, ['message_id' => '<reply@example.com>', 'subject' => 'Re: [Ticket #41] Test']);
|
||||||
|
$result = $tracker->process($replyData, $trackingService);
|
||||||
|
if ($result['action'] !== 'reply' || $result['ticket_id'] !== 41) {
|
||||||
|
throw new RuntimeException('Subject failed to match ticket');
|
||||||
|
}
|
||||||
|
foreach ([
|
||||||
|
'Re: [Ticket #41] Updated subject',
|
||||||
|
'Fwd: [ticket #41] Test',
|
||||||
|
'[Ticket #41] Re: [Ticket #41] Test',
|
||||||
|
] as $index => $subject) {
|
||||||
|
$result = $tracker->process(array_replace($data, ['message_id' => '<subject-' . $index . '@example.com>', 'subject' => $subject]), $trackingService);
|
||||||
|
if ($result['action'] !== 'reply' || $result['ticket_id'] !== 41) {
|
||||||
|
throw new RuntimeException('Valid subject format failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (['[Ticket #0]', '[Ticket #999999999999999999999999]', '[Ticket #41] [Ticket #42]', '[Ticket #bad]', '[Ticket #41', '[Ticket #999]', "Bad\r\nSubject"] as $index => $subject) {
|
||||||
|
$expectRejected(array_replace($data, ['message_id' => '<invalid-subject-' . $index . '@example.com>', 'subject' => $subject]));
|
||||||
|
}
|
||||||
|
$unthreaded = $tracker->process(array_replace($data, [
|
||||||
|
'message_id' => '<reference-removed@example.com>', 'subject' => 'A new request',
|
||||||
|
'in_reply_to' => '<initial@example.com>', 'references' => '<unknown@example.com>',
|
||||||
|
]), $trackingService);
|
||||||
|
if ($unthreaded['action'] !== 'ticket') {
|
||||||
|
throw new RuntimeException('Removed subject reference did not create a new request');
|
||||||
|
}
|
||||||
|
$duplicateReply = $tracker->process($replyData, $trackingService);
|
||||||
|
if (!$duplicateReply['duplicate'] || $duplicateReply['action'] !== 'reply') {
|
||||||
|
throw new RuntimeException('Repeated reply was not deduplicated');
|
||||||
|
}
|
||||||
|
$isolation = $tracker->process(array_replace($data, ['mailbox' => 'different@example.com']), $trackingService);
|
||||||
|
if ($isolation['duplicate']) {
|
||||||
|
throw new RuntimeException('Separate mailboxes incorrectly share deduplication state');
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = (int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages');
|
||||||
|
$trackingService->fail = true;
|
||||||
|
$failed = array_replace($data, ['message_id' => '<retry@example.com>']);
|
||||||
|
try {
|
||||||
|
$tracker->process($failed, $trackingService);
|
||||||
|
throw new LogicException('Simulated failure was swallowed');
|
||||||
|
} catch (RuntimeException $error) {
|
||||||
|
if ($error->getMessage() !== 'Failure after ticket write') {
|
||||||
|
throw $error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages') !== $count) {
|
||||||
|
throw new RuntimeException('Partial write survived rollback');
|
||||||
|
}
|
||||||
|
$trackingService->fail = false;
|
||||||
|
$tracker->process($failed, $trackingService);
|
||||||
|
$tracker->process($failed, $trackingService);
|
||||||
|
if ((int) $connection->fetchOne('SELECT COUNT(*) FROM test_messages') !== $count + 1) {
|
||||||
|
throw new RuntimeException('Retry created duplicate writes');
|
||||||
|
}
|
||||||
|
$trackingService->mismatch = true;
|
||||||
|
$expectRejected(array_replace($replyData, ['message_id' => '<mismatch@example.com>']));
|
||||||
|
if ($trackingService->notifications !== 1 || $connection->isTransactionActive()) {
|
||||||
|
throw new RuntimeException('Mismatch notification or transaction cleanup failed');
|
||||||
|
}
|
||||||
|
echo "Passed persistent deduplication, conflicting payload rejection, subject matching, mailbox isolation, malformed/conflicting/missing ticket rejection, removed references, rollback/retry, and mismatch notifications.\n";
|
||||||
|
|
||||||
|
// Exercise the real rejection path with a queue spy that cannot be rolled back.
|
||||||
|
$rejectionConnection = \Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
|
||||||
|
$rejectionConnection->executeStatement('CREATE TABLE mailgateway_message (message_key TEXT PRIMARY KEY, mailbox TEXT, message_id TEXT, payload_hash TEXT, ticket_id INTEGER, result_json TEXT)');
|
||||||
|
$rejectionConnection->executeStatement('CREATE TABLE mailgateway_notification (sender_key TEXT PRIMARY KEY, last_notified_at INTEGER NOT NULL)');
|
||||||
|
$rejectionConnection->executeStatement('CREATE TABLE client (id INTEGER PRIMARY KEY, email TEXT)');
|
||||||
|
$rejectionDi = new \Pimple\Container();
|
||||||
|
$rejectionDi['dbal'] = $rejectionConnection;
|
||||||
|
$rejectionEm = new class {
|
||||||
|
public int $clears = 0;
|
||||||
|
public function clear(): void { ++$this->clears; }
|
||||||
|
};
|
||||||
|
$rejectionDi['em'] = $rejectionEm;
|
||||||
|
$rejectionDi['logger'] = new class {
|
||||||
|
public function warning(...$args): void {}
|
||||||
|
};
|
||||||
|
$rejectionEmail = new class {
|
||||||
|
public array $attempts = [];
|
||||||
|
public bool $fail = false;
|
||||||
|
public function sendTemplate($data): bool {
|
||||||
|
$this->attempts[] = $data;
|
||||||
|
if ($this->fail) throw new RuntimeException('Queue unavailable');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$rejectionDi['mod_service'] = $rejectionDi->protect(fn ($module) => $module === 'email' ? $rejectionEmail : $support);
|
||||||
|
$rejectionLimiter = new class($rejectionConnection, $rejectionEm) {
|
||||||
|
public array $entries = [];
|
||||||
|
public function __construct(private $connection, private $em) {}
|
||||||
|
public function run($sender, $queue): bool {
|
||||||
|
$this->entries[] = ['active' => $this->connection->isTransactionActive(),
|
||||||
|
'tracked' => (int) $this->connection->fetchOne('SELECT COUNT(*) FROM mailgateway_message'),
|
||||||
|
'clears' => $this->em->clears];
|
||||||
|
return (new \Box\Mod\Mailgateway\NotificationCooldown($this->connection))->run($sender, $queue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$rejectionDi['mailgateway_notification_cooldown'] = $rejectionLimiter;
|
||||||
|
$rejectionService = new \Box\Mod\Mailgateway\Service();
|
||||||
|
$rejectionService->setDi($rejectionDi);
|
||||||
|
$rejectionTracker = new \Box\Mod\Mailgateway\MessageTracker($rejectionConnection);
|
||||||
|
foreach (['guest', 'customer', 'ambiguous', 'queue-failure'] as $index => $case) {
|
||||||
|
$ticket->setClientId($case === 'guest' || $case === 'queue-failure' ? null : 7);
|
||||||
|
$ticket->setAuthorEmail('owner@example.com');
|
||||||
|
$rejectionConnection->executeStatement('DELETE FROM client');
|
||||||
|
$rejectionConnection->executeStatement('DELETE FROM mailgateway_notification');
|
||||||
|
$rejectionConnection->insert('client', ['id' => 8, 'email' => 'other@example.com']);
|
||||||
|
if ($case === 'ambiguous') {
|
||||||
|
$rejectionConnection->insert('client', ['id' => 7, 'email' => 'other@example.com']);
|
||||||
|
}
|
||||||
|
$rejectionEmail->fail = $case === 'queue-failure';
|
||||||
|
$input = ['mailbox' => 'support@example.com', 'message_id' => "<rejection-$index@example.com>",
|
||||||
|
'sender_email' => 'other@example.com', 'subject' => '[Ticket #4] Reply', 'content' => 'Rejected reply'];
|
||||||
|
try {
|
||||||
|
$rejectionTracker->process($input, $rejectionService);
|
||||||
|
throw new RuntimeException('Real mismatched reply accepted');
|
||||||
|
} catch (\FOSSBilling\InformationException $error) {
|
||||||
|
$expected = $ticket->isGuestTicket() ? 'Sender does not own this ticket' : 'Sender does not uniquely match the ticket owner';
|
||||||
|
if ($error->getMessage() !== $expected) throw $error;
|
||||||
|
}
|
||||||
|
if (count($rejectionEmail->attempts) !== $index + 1
|
||||||
|
|| count($rejectionLimiter->entries) !== $index + 1
|
||||||
|
|| $rejectionLimiter->entries[$index] !== ['active' => false, 'tracked' => 0, 'clears' => $index + 1]
|
||||||
|
|| $rejectionConnection->isTransactionActive()
|
||||||
|
|| (int) $rejectionConnection->fetchOne('SELECT COUNT(*) FROM mailgateway_notification') !== ($rejectionEmail->fail ? 0 : 1)
|
||||||
|
|| $rejectionEmail->attempts[$index] !== ['to' => 'other@example.com',
|
||||||
|
'code' => 'mailgateway_sender_mismatch', 'sender_email' => 'other@example.com', 'ticket_id' => 4]) {
|
||||||
|
throw new RuntimeException('Rejected reply did not attempt exactly one safe notification after rollback');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "Passed real guest/customer/ambiguous rejection paths, single post-rollback notification attempts, and queue-failure cleanup.\n";
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require dirname(__DIR__, 3) . '/vendor/autoload.php';
|
||||||
|
define('PATH_CONFIG', __DIR__ . '/config.php');
|
||||||
|
define('DEBUG', false);
|
||||||
|
require dirname(__DIR__) . '/RuntimeConfig.php';
|
||||||
|
require dirname(__DIR__) . '/DependencyInstaller.php';
|
||||||
|
require dirname(__DIR__) . '/Service.php';
|
||||||
|
require dirname(__DIR__) . '/InboundPolicy.php';
|
||||||
|
require dirname(__DIR__) . '/NotificationCooldown.php';
|
||||||
|
require dirname(__DIR__) . '/NotificationEmailProxy.php';
|
||||||
|
require dirname(__DIR__) . '/Settings.php';
|
||||||
|
require dirname(__DIR__) . '/Api/Admin.php';
|
||||||
|
|
||||||
|
$service = new \Box\Mod\Mailgateway\Service();
|
||||||
|
$valid = ['sender_email' => 'customer@example.com', 'subject' => 'Test', 'content' => 'Test body'];
|
||||||
|
$cases = [
|
||||||
|
[],
|
||||||
|
array_replace($valid, ['sender_email' => ['customer@example.com']]),
|
||||||
|
array_replace($valid, ['sender_email' => 'Name <customer@example.com>']),
|
||||||
|
array_replace($valid, ['subject' => "Header\r\nInjected"]),
|
||||||
|
array_replace($valid, ['subject' => str_repeat('x', 256)]),
|
||||||
|
array_replace($valid, ['subject' => "\xFF"]),
|
||||||
|
array_replace($valid, ['content' => str_repeat('x', 65536)]),
|
||||||
|
array_replace($valid, ['content' => "\xFF"]),
|
||||||
|
array_replace($valid, ['content' => "\0"]),
|
||||||
|
array_replace($valid, ['sender_name' => ['Invalid']]),
|
||||||
|
array_replace($valid, ['sender_name' => "Name\nInjected"]),
|
||||||
|
array_replace($valid, ['sender_name' => str_repeat('x', 256)]),
|
||||||
|
];
|
||||||
|
foreach ($cases as $index => $data) {
|
||||||
|
try {
|
||||||
|
$service->import($data);
|
||||||
|
throw new \RuntimeException('Invalid input accepted: ' . $index);
|
||||||
|
} catch (\FOSSBilling\InformationException) {
|
||||||
|
// Must reject before accessing any database services.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The production API exposes only the settings page operations.
|
||||||
|
$methods = array_filter((new ReflectionClass(\Box\Mod\Mailgateway\Api\Admin::class))->getMethods(ReflectionMethod::IS_PUBLIC),
|
||||||
|
fn ($method) => $method->getDeclaringClass()->getName() === \Box\Mod\Mailgateway\Api\Admin::class);
|
||||||
|
$names = array_map(fn ($method) => $method->getName(), $methods);
|
||||||
|
sort($names);
|
||||||
|
if ($names !== ['install_dependencies', 'mailbox_delete', 'notifications_setup', 'settings_get', 'settings_save', 'setup', 'test_connection']) {
|
||||||
|
throw new RuntimeException('Unexpected production API endpoints');
|
||||||
|
}
|
||||||
|
echo 'Passed ', count($cases), " invalid-input checks and production API surface check.\n";
|
||||||
|
|
||||||
|
// Exercise the import orchestration with real ticket entities and a simulated
|
||||||
|
// Support autoresponse, without requiring a database or sending email.
|
||||||
|
$client = new class extends \Model_Client {
|
||||||
|
public function __get($property)
|
||||||
|
{
|
||||||
|
return $property === 'id' ? 7 : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
foreach (['on_hold' => 1, 'open' => 0, 'closed' => 0] as $status => $expectedFlushes) {
|
||||||
|
$ticket = new \Box\Mod\Support\Entity\SupportTicket();
|
||||||
|
$ticket->setStatus($status);
|
||||||
|
$helpdesk = new \Box\Mod\Support\Entity\Helpdesk();
|
||||||
|
$support = new class($ticket, $helpdesk, $client) {
|
||||||
|
public function __construct(public $ticket, public $helpdesk, public $client) {}
|
||||||
|
public function getHelpdeskRepository() { return $this; }
|
||||||
|
public function findBy($criteria, $order, $limit) { return [$this->helpdesk]; }
|
||||||
|
public function ticketCreateForClient($client, $helpdesk, $data): int
|
||||||
|
{
|
||||||
|
if ($client !== $this->client || $helpdesk !== $this->helpdesk || $data['content'] !== 'Test body') {
|
||||||
|
throw new \RuntimeException('Incorrect customer or message passed to Support');
|
||||||
|
}
|
||||||
|
return 42;
|
||||||
|
}
|
||||||
|
public function getTicketById(int $id) { return $this->ticket; }
|
||||||
|
public function toApiArray($ticket, $deep, $identity)
|
||||||
|
{
|
||||||
|
if ($identity !== $this->client) {
|
||||||
|
throw new \RuntimeException('Notification uses incorrect customer');
|
||||||
|
}
|
||||||
|
return ['id' => 42];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$em = new class {
|
||||||
|
public int $flushes = 0;
|
||||||
|
public function flush(): void { ++$this->flushes; }
|
||||||
|
};
|
||||||
|
$di = new \Pimple\Container();
|
||||||
|
$di['dbal'] = new class {
|
||||||
|
public function fetchFirstColumn($sql, $params) { return [7]; }
|
||||||
|
};
|
||||||
|
$di['db'] = new class($client) {
|
||||||
|
public function __construct(public $client) {}
|
||||||
|
public function getExistingModelById($type, $id) { return $this->client; }
|
||||||
|
};
|
||||||
|
$di['em'] = $em;
|
||||||
|
$di['mailgateway_settings'] = new class {
|
||||||
|
public function get(): array { return ['support_helpdesk_id' => null]; }
|
||||||
|
};
|
||||||
|
$di['mailgateway_notification_cooldown'] = new class {
|
||||||
|
public function run($sender, $queue): bool { return $queue() !== false; }
|
||||||
|
};
|
||||||
|
$di['auth'] = new class {
|
||||||
|
public function isClientLoggedIn(): bool { return false; }
|
||||||
|
};
|
||||||
|
$email = new class {
|
||||||
|
public array $messages = [];
|
||||||
|
public function sendTemplate($data) { $this->messages[] = $data; }
|
||||||
|
};
|
||||||
|
$di['mod_service'] = $di->protect(fn ($module) => $module === 'email' ? $email : $support);
|
||||||
|
$service->setDi($di);
|
||||||
|
$result = $service->import($valid);
|
||||||
|
$expectedStatus = $status === 'on_hold' ? 'open' : $status;
|
||||||
|
if ($ticket->getStatus() !== $expectedStatus || $em->flushes !== $expectedFlushes
|
||||||
|
|| $result['ticket_id'] !== 42 || $result['client_id'] !== 7) {
|
||||||
|
throw new \RuntimeException('Import status regression: ' . $status);
|
||||||
|
}
|
||||||
|
if ($email->messages !== [['to_client' => 7, 'code' => 'mod_support_ticket_open', 'ticket' => ['id' => 42]]]) {
|
||||||
|
throw new \RuntimeException('Customer acknowledgement missing or incorrect');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
echo "Passed autoresponse status check, existing Open/Closed status checks, and customer identity forwarding.\n";
|
||||||
|
echo "Passed customer acknowledgement recipient, template, and ticket-number checks.\n";
|
||||||
|
|
||||||
|
$guestSupport = new class {
|
||||||
|
public array $received = [];
|
||||||
|
public bool $enabled = true;
|
||||||
|
public function getHelpdeskRepository() { return $this; }
|
||||||
|
public function findBy($criteria, $order, $limit)
|
||||||
|
{
|
||||||
|
$helpdesk = new \Box\Mod\Support\Entity\Helpdesk();
|
||||||
|
(new \ReflectionProperty($helpdesk, 'id'))->setValue($helpdesk, 3);
|
||||||
|
return [$helpdesk];
|
||||||
|
}
|
||||||
|
public function ticketCreateForGuest($data): string
|
||||||
|
{
|
||||||
|
if (!$this->enabled) {
|
||||||
|
throw new \FOSSBilling\InformationException('Guest tickets disabled');
|
||||||
|
}
|
||||||
|
$this->received[] = $data;
|
||||||
|
return 'private-access-hash';
|
||||||
|
}
|
||||||
|
public function findOneByHash($hash)
|
||||||
|
{
|
||||||
|
if ($hash !== 'private-access-hash') {
|
||||||
|
throw new \RuntimeException('Guest hash lookup incorrect');
|
||||||
|
}
|
||||||
|
$ticket = new \Box\Mod\Support\Entity\SupportTicket();
|
||||||
|
(new \ReflectionProperty($ticket, 'id'))->setValue($ticket, 43);
|
||||||
|
return $ticket;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$guestDi = new \Pimple\Container();
|
||||||
|
$lookup = new class {
|
||||||
|
public array $ids = [];
|
||||||
|
public function fetchFirstColumn($sql, $params) { return $this->ids; }
|
||||||
|
};
|
||||||
|
$guestDi['dbal'] = $lookup;
|
||||||
|
$guestDi['mailgateway_settings'] = new class {
|
||||||
|
public function get(): array { return ['support_helpdesk_id' => null]; }
|
||||||
|
};
|
||||||
|
$guestDi['mod_service'] = $guestDi->protect(fn ($module) => $guestSupport);
|
||||||
|
$service->setDi($guestDi);
|
||||||
|
foreach ([null, 'Guest Sender'] as $name) {
|
||||||
|
$data = $valid;
|
||||||
|
if ($name !== null) {
|
||||||
|
$data['sender_name'] = $name;
|
||||||
|
}
|
||||||
|
$result = $service->import($data);
|
||||||
|
$received = $guestSupport->received[array_key_last($guestSupport->received)];
|
||||||
|
if ($result !== ['ticket_id' => 43, 'client_id' => null, 'support_helpdesk_id' => 3]
|
||||||
|
|| $received !== ['name' => $name ?? $valid['sender_email'], 'email' => $valid['sender_email'],
|
||||||
|
'subject' => 'Test', 'content' => 'Test body', 'support_helpdesk_id' => 3]) {
|
||||||
|
throw new \RuntimeException('Guest import or attribution incorrect');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$guestSupport->enabled = false;
|
||||||
|
try {
|
||||||
|
$service->import($valid);
|
||||||
|
throw new \RuntimeException('Disabled guest policy bypassed');
|
||||||
|
} catch (\FOSSBilling\InformationException) {
|
||||||
|
}
|
||||||
|
$lookup->ids = [7, 8];
|
||||||
|
try {
|
||||||
|
$service->import($valid);
|
||||||
|
throw new \RuntimeException('Ambiguous customer accepted');
|
||||||
|
} catch (\FOSSBilling\InformationException) {
|
||||||
|
}
|
||||||
|
if (count($guestSupport->received) !== 2) {
|
||||||
|
throw new \RuntimeException('Unexpected guest creation');
|
||||||
|
}
|
||||||
|
echo "Passed guest routing, sender name fallback, helpdesk mapping, private hash handling, disabled guest policy, and ambiguous customer rejection.\n";
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user