feat: implement global autoresponder plugin
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PLUGIN_ROOT . '/exec/lib/Settings.php';
|
||||
require_once PLUGIN_ROOT . '/exec/lib/DirectAdminUser.php';
|
||||
|
||||
test('settings validate defaults and external paths', function (): void {
|
||||
$path = TEST_TMP . '/settings.conf';
|
||||
test_write($path, implode("\n", [
|
||||
'DEFAULT_PLUGIN_LANGUAGE=pl',
|
||||
'DEFAULT_ENABLE=false',
|
||||
'GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw',
|
||||
'GLOBAL_AUTORESPONDER_SCHEDULE_MODE=exact_hour',
|
||||
'GLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE=false',
|
||||
'GLOBAL_AUTORESPONDER_REPEAT_MINUTES=60',
|
||||
'GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE=true',
|
||||
'GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES=255',
|
||||
'GLOBAL_AUTORESPONDER_MAX_BODY_BYTES=20000',
|
||||
]) . "\n");
|
||||
|
||||
$settings = Settings::load($path);
|
||||
assert_same('pl', $settings->defaultLanguage());
|
||||
assert_false($settings->defaultEnable());
|
||||
assert_same('Europe/Warsaw', $settings->timezone());
|
||||
assert_same('exact_hour', $settings->scheduleMode());
|
||||
assert_same(60, $settings->repeatMinutes());
|
||||
assert_same('/usr/local/hitme_plugins/global-autoresponders/config', Settings::CONFIG_DIR);
|
||||
assert_same('/usr/local/hitme_plugins/global-autoresponders/data', Settings::DATA_DIR);
|
||||
});
|
||||
|
||||
test('settings reject invalid timezone and schedule mode', function (): void {
|
||||
$path = TEST_TMP . '/bad-settings.conf';
|
||||
test_write($path, "GLOBAL_AUTORESPONDER_TIMEZONE=Not/AZone\nGLOBAL_AUTORESPONDER_SCHEDULE_MODE=bad\n");
|
||||
try {
|
||||
Settings::load($path);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
assert_contains('timezone', $e->getMessage());
|
||||
return;
|
||||
}
|
||||
throw new RuntimeException('Expected invalid settings to fail');
|
||||
});
|
||||
|
||||
test('directadmin user access follows DEFAULT_ENABLE and per-user overrides', function (): void {
|
||||
$settingsPath = TEST_TMP . '/access.conf';
|
||||
test_write($settingsPath, "DEFAULT_ENABLE=false\n");
|
||||
$settings = Settings::load($settingsPath);
|
||||
$user = new DirectAdminUser('marek', 'enhanced', 'en', TEST_TMP . '/config');
|
||||
|
||||
assert_false($user->hasPluginAccess($settings));
|
||||
test_write(TEST_TMP . '/config/users/marek.conf', "enabled=true\n");
|
||||
assert_true($user->hasPluginAccess($settings));
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PLUGIN_ROOT . '/exec/lib/CsrfGuard.php';
|
||||
|
||||
test('csrf token is bound to user session action and time', function (): void {
|
||||
$guard = new CsrfGuard('secret', 'alice', 'session-1');
|
||||
$issued = $guard->issue('create');
|
||||
|
||||
assert_true($guard->validate('create', (string)$issued['ts'], $issued['token'], 3600, 'session-1'));
|
||||
assert_false((new CsrfGuard('secret', 'bob', 'session-1'))->validate('create', (string)$issued['ts'], $issued['token'], 3600, 'session-1'));
|
||||
assert_false($guard->validate('delete', (string)$issued['ts'], $issued['token'], 3600, 'session-1'));
|
||||
assert_false($guard->validate('create', (string)($issued['ts'] - 7200), $issued['token'], 3600, 'session-1'));
|
||||
assert_false($guard->validate('create', 'bad', $issued['token'], 3600, 'session-1'));
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PLUGIN_ROOT . '/exec/lib/DirectAdminSyncRepository.php';
|
||||
|
||||
test('directadmin sync repository only targets plugin tracked entries', function (): void {
|
||||
$repo = new DirectAdminSyncRepository(TEST_TMP . '/data');
|
||||
$repo->replaceRuleEntries('alice', 'rule-1', [
|
||||
['domain' => 'example.com', 'mailbox' => 'info', 'api_key' => 'info@example.com'],
|
||||
['domain' => 'example.com', 'mailbox' => 'sales', 'api_key' => 'sales@example.com'],
|
||||
]);
|
||||
|
||||
assert_same(2, count($repo->entriesForRule('alice', 'rule-1')));
|
||||
assert_same([], $repo->entriesForRule('bob', 'rule-1'));
|
||||
assert_same(['info@example.com', 'sales@example.com'], array_column($repo->deleteRuleEntries('alice', 'rule-1'), 'api_key'));
|
||||
assert_same([], $repo->entriesForRule('alice', 'rule-1'));
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PLUGIN_ROOT . '/exec/lib/DirectAdminVacationApi.php';
|
||||
|
||||
test('directadmin vacation api builds scoped payload for one mailbox', function (): void {
|
||||
$api = new DirectAdminVacationApi('/usr/local/directadmin/directadmin');
|
||||
$payload = $api->buildSavePayload('info@example.com', [
|
||||
'subject' => 'Urlop',
|
||||
'body' => 'Body',
|
||||
'start_value' => '2026-06-10|morning',
|
||||
'end_value' => '2026-06-24|evening',
|
||||
]);
|
||||
|
||||
assert_same('info', $payload['user']);
|
||||
assert_same('example.com', $payload['domain']);
|
||||
assert_same('Body', $payload['text']);
|
||||
assert_same('morning', $payload['starttime']);
|
||||
assert_same('evening', $payload['endtime']);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
test('mockup and planned UI do not expose backend terms', function (): void {
|
||||
foreach (glob(dirname(__DIR__, 2) . '/docs/mockups/*.html') ?: [] as $file) {
|
||||
$html = file_get_contents($file) ?: '';
|
||||
assert_false(stripos($html, 'Exim') !== false, basename($file) . ' exposes Exim');
|
||||
assert_false(stripos($html, 'DirectAdmin API') !== false, basename($file) . ' exposes API');
|
||||
assert_false(stripos($html, 'backend.sh') !== false, basename($file) . ' exposes backend script');
|
||||
}
|
||||
});
|
||||
|
||||
test('mockup forms include calendar classes and delete confirmation view', function (): void {
|
||||
$create = file_get_contents(dirname(__DIR__, 2) . '/docs/mockups/02-dodaj.html') ?: '';
|
||||
$delete = file_get_contents(dirname(__DIR__, 2) . '/docs/mockups/04-podglad-usun.html') ?: '';
|
||||
assert_contains('br-restore-calendar', $create);
|
||||
assert_contains('br-calendar-grid', $create);
|
||||
assert_contains('Potwierdź usunięcie', $delete);
|
||||
assert_false(stripos($delete, 'checkbox') !== false);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PLUGIN_ROOT . '/exec/lib/Lang.php';
|
||||
require_once PLUGIN_ROOT . '/exec/lib/Settings.php';
|
||||
|
||||
test('language falls back to default language then English', function (): void {
|
||||
$settingsPath = TEST_TMP . '/lang-settings.conf';
|
||||
test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\n");
|
||||
$settings = Settings::load($settingsPath);
|
||||
|
||||
$lang = Lang::load('de', $settings);
|
||||
assert_same('pl', $lang->code());
|
||||
assert_same('Globalne autorespondery', $lang->t('Global autoresponders'));
|
||||
|
||||
test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=de\n");
|
||||
$settings = Settings::load($settingsPath);
|
||||
$lang = Lang::load('fr', $settings);
|
||||
assert_same('en', $lang->code());
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PLUGIN_ROOT . '/exec/lib/MailSender.php';
|
||||
|
||||
test('mail sender rejects subject header injection and builds utf8 message', function (): void {
|
||||
try {
|
||||
MailSender::buildMessage('from@example.com', 'to@example.com', "Bad\nSubject", 'Body');
|
||||
} catch (InvalidArgumentException $e) {
|
||||
assert_contains('subject', strtolower($e->getMessage()));
|
||||
}
|
||||
|
||||
$message = MailSender::buildMessage('from@example.com', 'to@example.com', 'Temat', "Treść\nDruga linia");
|
||||
assert_contains('Auto-Submitted: auto-replied', $message);
|
||||
assert_contains('Content-Type: text/plain; charset=UTF-8', $message);
|
||||
assert_contains("Subject: =?UTF-8?B?", $message);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PLUGIN_ROOT . '/exec/lib/MailboxDirectory.php';
|
||||
|
||||
test('mailbox directory returns only pop imap mailboxes owned by user', function (): void {
|
||||
$root = TEST_TMP . '/etc_virtual';
|
||||
test_write($root . '/domainowners', "example.com: alice\nother.com: bob\n");
|
||||
test_write($root . '/example.com/passwd', "info:x:1000:12::/home/alice/imap/example.com/info:/bin/false\nsales:x:1000:12::/home/alice/imap/example.com/sales:/bin/false\n");
|
||||
test_write($root . '/example.com/aliases', "alias: info\n");
|
||||
test_write($root . '/other.com/passwd', "info:x:1001:12::/home/bob/imap/other.com/info:/bin/false\n");
|
||||
|
||||
$dir = new MailboxDirectory($root);
|
||||
$mailboxes = $dir->mailboxesForUser('alice');
|
||||
|
||||
assert_same(['info@example.com', 'sales@example.com'], array_column($mailboxes, 'email'));
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PLUGIN_ROOT . '/exec/lib/MessageClassifier.php';
|
||||
|
||||
test('message classifier suppresses automated and list messages', function (): void {
|
||||
assert_true(MessageClassifier::shouldSuppress(['auto-submitted' => 'auto-replied'], 'sender@example.com'));
|
||||
assert_true(MessageClassifier::shouldSuppress(['precedence' => 'bulk'], 'sender@example.com'));
|
||||
assert_true(MessageClassifier::shouldSuppress(['list-id' => '<list.example.com>'], 'sender@example.com'));
|
||||
assert_true(MessageClassifier::shouldSuppress([], ''));
|
||||
assert_true(MessageClassifier::shouldSuppress([], 'MAILER-DAEMON@example.com'));
|
||||
assert_false(MessageClassifier::shouldSuppress(['auto-submitted' => 'no'], 'person@example.com'));
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
test('packing guidelines exclude docs and require manual icon', function (): void {
|
||||
$packing = file_get_contents(dirname(__DIR__, 2) . '/PACKING.md') ?: '';
|
||||
assert_contains('docs/dokumentacja.html', $packing);
|
||||
assert_contains('must not contain `docs/`', $packing);
|
||||
assert_contains('src/images/user_icon.svg', $packing);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PLUGIN_ROOT . '/exec/lib/RepeatState.php';
|
||||
|
||||
test('repeat state allows first reply and blocks repeated reply until ttl', function (): void {
|
||||
$state = new RepeatState(TEST_TMP . '/state', 1000);
|
||||
assert_true($state->shouldSend('alice', 'rule', 'info@example.com', 'sender@example.com', 60));
|
||||
$state->record('alice', 'rule', 'info@example.com', 'sender@example.com');
|
||||
assert_false($state->shouldSend('alice', 'rule', 'info@example.com', 'sender@example.com', 60));
|
||||
|
||||
$later = new RepeatState(TEST_TMP . '/state', 5000);
|
||||
assert_true($later->shouldSend('alice', 'rule', 'info@example.com', 'sender@example.com', 60));
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PLUGIN_ROOT . '/exec/lib/RuleRepository.php';
|
||||
|
||||
test('rule repository isolates users and stores rules atomically', function (): void {
|
||||
$repo = new RuleRepository(TEST_TMP . '/data');
|
||||
$rule = [
|
||||
'subject' => 'Urlop',
|
||||
'body' => 'Body',
|
||||
'start_ts' => 1,
|
||||
'end_ts' => 2,
|
||||
'enabled' => true,
|
||||
];
|
||||
|
||||
$created = $repo->create('alice', $rule);
|
||||
assert_true(isset($created['id']));
|
||||
assert_same(1, count($repo->all('alice')));
|
||||
assert_same(0, count($repo->all('bob')));
|
||||
|
||||
try {
|
||||
$repo->update('bob', $created['id'], ['subject' => 'Other']);
|
||||
} catch (RuntimeException $e) {
|
||||
assert_contains('not found', $e->getMessage());
|
||||
return;
|
||||
}
|
||||
throw new RuntimeException('Expected cross-user update to fail');
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PLUGIN_ROOT . '/exec/lib/Settings.php';
|
||||
require_once PLUGIN_ROOT . '/exec/lib/RuleValidator.php';
|
||||
|
||||
test('rule validator normalizes exact hour schedule', function (): void {
|
||||
$settingsPath = TEST_TMP . '/validator.conf';
|
||||
test_write($settingsPath, "GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw\nGLOBAL_AUTORESPONDER_SCHEDULE_MODE=exact_hour\n");
|
||||
$settings = Settings::load($settingsPath);
|
||||
$rule = RuleValidator::validate([
|
||||
'subject' => 'Urlop',
|
||||
'body' => "Dzień dobry\r\nWracam jutro",
|
||||
'start_value' => '2026-06-10T09:00',
|
||||
'end_value' => '2026-06-24T17:00',
|
||||
'enabled' => '1',
|
||||
], $settings);
|
||||
|
||||
assert_same('Urlop', $rule['subject']);
|
||||
assert_same("Dzień dobry\nWracam jutro", $rule['body']);
|
||||
assert_same('exact_hour', $rule['schedule_mode']);
|
||||
assert_true($rule['start_ts'] < $rule['end_ts']);
|
||||
});
|
||||
|
||||
test('rule validator rejects header injection and reversed dates', function (): void {
|
||||
$settings = Settings::load(TEST_TMP . '/missing.conf');
|
||||
try {
|
||||
RuleValidator::validate([
|
||||
'subject' => "Bad\nSubject",
|
||||
'body' => 'Body',
|
||||
'start_value' => '2026-06-24T17:00',
|
||||
'end_value' => '2026-06-10T09:00',
|
||||
], $settings);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
assert_contains('Temat', $e->getMessage());
|
||||
return;
|
||||
}
|
||||
throw new RuntimeException('Expected validation failure');
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
define('PLUGIN_ROOT', $root);
|
||||
define('TEST_TMP', sys_get_temp_dir() . '/global-autoresponder-tests-' . getmypid());
|
||||
|
||||
@mkdir(TEST_TMP, 0700, true);
|
||||
|
||||
$tests = [];
|
||||
|
||||
function test(string $name, callable $fn): void
|
||||
{
|
||||
global $tests;
|
||||
$tests[] = [$name, $fn];
|
||||
}
|
||||
|
||||
function assert_true(bool $condition, string $message = 'Expected true'): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
function assert_false(bool $condition, string $message = 'Expected false'): void
|
||||
{
|
||||
if ($condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
function assert_same(mixed $expected, mixed $actual, string $message = ''): void
|
||||
{
|
||||
if ($expected !== $actual) {
|
||||
throw new RuntimeException($message !== '' ? $message : 'Expected ' . var_export($expected, true) . ', got ' . var_export($actual, true));
|
||||
}
|
||||
}
|
||||
|
||||
function assert_contains(string $needle, string $haystack, string $message = ''): void
|
||||
{
|
||||
if (strpos($haystack, $needle) === false) {
|
||||
throw new RuntimeException($message !== '' ? $message : 'Missing text: ' . $needle);
|
||||
}
|
||||
}
|
||||
|
||||
function test_write(string $path, string $content): void
|
||||
{
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0700, true);
|
||||
}
|
||||
file_put_contents($path, $content);
|
||||
}
|
||||
|
||||
function test_rm_rf(string $path): void
|
||||
{
|
||||
if (!is_dir($path) && !is_file($path)) {
|
||||
return;
|
||||
}
|
||||
$it = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($it as $item) {
|
||||
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
||||
}
|
||||
if (is_dir($path)) {
|
||||
rmdir($path);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (glob(__DIR__ . '/*_test.php') ?: [] as $file) {
|
||||
require $file;
|
||||
}
|
||||
|
||||
$failures = 0;
|
||||
foreach ($tests as [$name, $fn]) {
|
||||
try {
|
||||
$fn();
|
||||
echo "PASS {$name}\n";
|
||||
} catch (Throwable $e) {
|
||||
$failures++;
|
||||
echo "FAIL {$name}: " . $e->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
test_rm_rf(TEST_TMP);
|
||||
|
||||
if ($failures > 0) {
|
||||
exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user