Implement IMAP email-to-ticket gateway
This commit is contained in:
1 parent
b7e5884457
commit
6f25869345
33 files changed
+5619
-5
No files matched your search
@@ -0,0 +1,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";
|
||||
Reference in New Issue
Block a user