feat: implement global autoresponder plugin

This commit is contained in:
Marek Miklewicz
2026-06-02 19:19:00 +02:00
commit 6f989af278
62 changed files with 2018 additions and 0 deletions
+52
View File
@@ -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));
});
+15
View File
@@ -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'));
});
+20
View File
@@ -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']);
});
+20
View File
@@ -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);
});
+20
View File
@@ -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());
});
+17
View File
@@ -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);
});
+17
View File
@@ -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'));
});
+13
View File
@@ -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'));
});
+9
View File
@@ -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);
});
+14
View File
@@ -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));
});
+28
View File
@@ -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');
});
+39
View File
@@ -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');
});
+91
View File
@@ -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);
}