From 6f989af278bd8dc9054c1e4fe13a15b9b852b6a7 Mon Sep 17 00:00:00 2001 From: Marek Miklewicz Date: Tue, 2 Jun 2026 19:19:00 +0200 Subject: [PATCH] feat: implement global autoresponder plugin --- exec/bootstrap.php | 60 +++++++ exec/handlers/_form_helpers.php | 71 ++++++++ exec/handlers/create.php | 18 ++ exec/handlers/delete.php | 27 +++ exec/handlers/index.php | 40 +++++ exec/handlers/preview.php | 16 ++ exec/handlers/toggle.php | 14 ++ exec/handlers/update.php | 24 +++ exec/lib/AppContext.php | 126 ++++++++++++++ exec/lib/AuditLog.php | 28 +++ exec/lib/CsrfGuard.php | 43 +++++ exec/lib/DirectAdminSyncRepository.php | 67 ++++++++ exec/lib/DirectAdminUser.php | 78 +++++++++ exec/lib/DirectAdminVacationApi.php | 41 +++++ exec/lib/Http.php | 30 ++++ exec/lib/Lang.php | 61 +++++++ exec/lib/LocalizedException.php | 22 +++ exec/lib/MailSender.php | 43 +++++ exec/lib/MailboxDirectory.php | 54 ++++++ exec/lib/MessageClassifier.php | 31 ++++ exec/lib/RepeatState.php | 48 ++++++ exec/lib/RuleRepository.php | 104 +++++++++++ exec/lib/RuleValidator.php | 84 +++++++++ exec/lib/Settings.php | 161 ++++++++++++++++++ hooks/user_img.html | 1 + hooks/user_txt.html | 1 + lang/en.php | 39 +++++ lang/pl.php | 39 +++++ php.ini | 7 + plugin-settings.conf | 31 ++++ plugin.conf | 7 + scripts/backend.sh | 36 ++++ scripts/exim/global_autoresponder_router.conf | 2 + .../exim/global_autoresponder_transport.conf | 2 + scripts/global_autoresponder_worker.php | 10 ++ scripts/install.sh | 23 +++ scripts/package.sh | 25 +++ scripts/sync_directadmin_vacations.php | 5 + scripts/uninstall.sh | 10 ++ scripts/update.sh | 6 + tests/bootstrap_test.php | 52 ++++++ tests/csrf_test.php | 15 ++ tests/directadmin_sync_repository_test.php | 17 ++ tests/directadmin_vacation_api_test.php | 20 +++ tests/handler_test.php | 20 +++ tests/lang_test.php | 20 +++ tests/mail_sender_test.php | 17 ++ tests/mailbox_directory_test.php | 17 ++ tests/message_classifier_test.php | 13 ++ tests/package_contents_test.php | 9 + tests/repeat_state_test.php | 14 ++ tests/rule_repository_test.php | 28 +++ tests/rule_validator_test.php | 39 +++++ tests/run.php | 91 ++++++++++ user/create.html | 6 + user/delete.html | 6 + user/enhanced.css | 74 ++++++++ user/evolution.css | 1 + user/index.html | 6 + user/preview.html | 6 + user/toggle.html | 6 + user/update.html | 6 + 62 files changed, 2018 insertions(+) create mode 100644 exec/bootstrap.php create mode 100644 exec/handlers/_form_helpers.php create mode 100644 exec/handlers/create.php create mode 100644 exec/handlers/delete.php create mode 100644 exec/handlers/index.php create mode 100644 exec/handlers/preview.php create mode 100644 exec/handlers/toggle.php create mode 100644 exec/handlers/update.php create mode 100644 exec/lib/AppContext.php create mode 100644 exec/lib/AuditLog.php create mode 100644 exec/lib/CsrfGuard.php create mode 100644 exec/lib/DirectAdminSyncRepository.php create mode 100644 exec/lib/DirectAdminUser.php create mode 100644 exec/lib/DirectAdminVacationApi.php create mode 100644 exec/lib/Http.php create mode 100644 exec/lib/Lang.php create mode 100644 exec/lib/LocalizedException.php create mode 100644 exec/lib/MailSender.php create mode 100644 exec/lib/MailboxDirectory.php create mode 100644 exec/lib/MessageClassifier.php create mode 100644 exec/lib/RepeatState.php create mode 100644 exec/lib/RuleRepository.php create mode 100644 exec/lib/RuleValidator.php create mode 100644 exec/lib/Settings.php create mode 100644 hooks/user_img.html create mode 100644 hooks/user_txt.html create mode 100644 lang/en.php create mode 100644 lang/pl.php create mode 100644 php.ini create mode 100644 plugin-settings.conf create mode 100644 plugin.conf create mode 100755 scripts/backend.sh create mode 100644 scripts/exim/global_autoresponder_router.conf create mode 100644 scripts/exim/global_autoresponder_transport.conf create mode 100755 scripts/global_autoresponder_worker.php create mode 100755 scripts/install.sh create mode 100755 scripts/package.sh create mode 100755 scripts/sync_directadmin_vacations.php create mode 100755 scripts/uninstall.sh create mode 100755 scripts/update.sh create mode 100644 tests/bootstrap_test.php create mode 100644 tests/csrf_test.php create mode 100644 tests/directadmin_sync_repository_test.php create mode 100644 tests/directadmin_vacation_api_test.php create mode 100644 tests/handler_test.php create mode 100644 tests/lang_test.php create mode 100644 tests/mail_sender_test.php create mode 100644 tests/mailbox_directory_test.php create mode 100644 tests/message_classifier_test.php create mode 100644 tests/package_contents_test.php create mode 100644 tests/repeat_state_test.php create mode 100644 tests/rule_repository_test.php create mode 100644 tests/rule_validator_test.php create mode 100644 tests/run.php create mode 100755 user/create.html create mode 100755 user/delete.html create mode 100644 user/enhanced.css create mode 100644 user/evolution.css create mode 100755 user/index.html create mode 100755 user/preview.html create mode 100755 user/toggle.html create mode 100755 user/update.html diff --git a/exec/bootstrap.php b/exec/bootstrap.php new file mode 100644 index 0000000..c88fb1a --- /dev/null +++ b/exec/bootstrap.php @@ -0,0 +1,60 @@ +language(), $settings); + if (!$user->hasPluginAccess($settings)) { + http_response_code(403); + echo 'Plugin is not enabled for this account.'; + exit; + } + $secret = $settings->loadOrCreateSecret(); + $csrf = new CsrfGuard($secret, $user->username(), (string)($_SERVER['SESSION_ID'] ?? getenv('SESSION_ID') ?: '')); + $ctx = new AppContext($user, $settings, new RuleRepository(), $csrf, $lang, new AuditLog()); + + $action = basename((string)PLUGIN_ACTION); + $handlerPath = PLUGIN_EXEC_DIR . '/handlers/' . $action . '.php'; + if (!is_file($handlerPath)) { + throw new RuntimeException('Missing handler: ' . $action); + } + $handler = require $handlerPath; + if (!is_callable($handler)) { + throw new RuntimeException('Invalid handler: ' . $action); + } + $handler($ctx); +} catch (Throwable $e) { + http_response_code(500); + header('Content-Type: text/html; charset=UTF-8'); + echo 'Błąd'; + echo '

Błąd pluginu

' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '

'; + echo ''; +} diff --git a/exec/handlers/_form_helpers.php b/exec/handlers/_form_helpers.php new file mode 100644 index 0000000..5a69f38 --- /dev/null +++ b/exec/handlers/_form_helpers.php @@ -0,0 +1,71 @@ +url($isUpdate ? 'update.html' : 'create.html'); + $intent = $isUpdate ? 'update:' . $id : 'create'; + $csrf = $ctx->csrfField($intent); + $hiddenId = $isUpdate ? '' : ''; + + return '
' . $csrf . $hiddenId . + '

Treść odpowiedzi

' . + '
' . + '
' . + '

Podsumowanie

Zakres' . AppContext::e($ctx->t('All valid POP/IMAP mailboxes in this account')) . '
' . + '
Start' . AppContext::e($startValue) . '
Koniec' . AppContext::e($endValue) . '
' . + '
' . AppContext::e($ctx->t('Cancel')) . '
' . + render_calendar_picker('start_value', 'Data rozpoczęcia', $startValue) . + render_calendar_picker('end_value', 'Data zakończenia', $endValue) . + '
'; +} + +/** @param array $post @return array */ +function normalize_rule_post(array $post): array +{ + foreach (['start_value', 'end_value'] as $key) { + $date = trim((string)($post[$key . '_date'] ?? '')); + $hour = trim((string)($post[$key . '_hour'] ?? '')); + if ($date !== '' && preg_match('/^[0-9]{2}$/', $hour)) { + $post[$key] = $date . 'T' . $hour . ':00'; + } + } + return $post; +} + +function render_calendar_picker(string $name, string $title, string $value): string +{ + $selectedDate = substr($value, 0, 10); + $selectedHour = substr($value, 11, 2) ?: '09'; + $days = ''; + for ($week = 0, $day = 1; $week < 4; $week++) { + $days .= ''; + for ($i = 0; $i < 7; $i++, $day++) { + $date = sprintf('2026-06-%02d', $day); + $class = $date === $selectedDate ? ' is-selected' : ''; + $days .= ''; + } + $days .= ''; + } + $hours = ''; + foreach (['08', '09', '10', '11', '12', '13', '14', '15', '16', '17', '18'] as $hour) { + $hours .= ''; + } + return '

' . AppContext::e($title) . '

' . AppContext::e($title) . '

Czerwiec 2026
' . + '' . $days . '
PnWtŚrCzPtSbNd
' . + '
Wybrano: ' . AppContext::e($selectedDate) . '
' . + '

Godzina

Wybierz godzinę
' . $hours . '
' . + '
'; +} + +function render_preview_box(array $rule): string +{ + return '
Temat: ' . AppContext::e((string)$rule['subject']) . '

' . nl2br(AppContext::e((string)$rule['body'])) . '
'; +} diff --git a/exec/handlers/create.php b/exec/handlers/create.php new file mode 100644 index 0000000..f120008 --- /dev/null +++ b/exec/handlers/create.php @@ -0,0 +1,18 @@ +requireCsrf('create'); + $input = normalize_rule_post($_POST); + $rule = RuleValidator::validate($input, $ctx->settings); + $ctx->rules->create($ctx->daUser->username(), $rule); + Http::redirect($ctx->url('index.html', ['status' => 'created'])); + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + $ctx->render('Add autoresponder', render_rule_form($ctx, 'create', null), [], $errors); +}; diff --git a/exec/handlers/delete.php b/exec/handlers/delete.php new file mode 100644 index 0000000..d052cb5 --- /dev/null +++ b/exec/handlers/delete.php @@ -0,0 +1,27 @@ +post('id') : $ctx->query('id'); + $rule = $ctx->rules->find($ctx->daUser->username(), $id); + if ($rule === null) { + $ctx->render('Confirm deletion', '
Rule not found
'); + return; + } + if (Http::isPost()) { + try { + $ctx->requireCsrf('delete:' . $id); + $ctx->rules->delete($ctx->daUser->username(), $id); + $ctx->audit->record('delete', $ctx->daUser->username(), ['rule_id' => $id, 'subject' => (string)$rule['subject']]); + Http::redirect($ctx->url('index.html', ['status' => 'deleted'])); + } catch (Throwable $e) { + $ctx->render('Confirm deletion', '
' . AppContext::e($e->getMessage()) . '
'); + return; + } + } + $body = '

' . AppContext::e($ctx->t('Preview autoresponder')) . '

' . render_preview_box($rule) . '
' . + '

' . AppContext::e($ctx->t('Confirm deletion')) . '

Usunięcie reguły wyłączy odpowiedź automatyczną utworzoną przez tę regułę dla objętych skrzynek.
' . + '
' . $ctx->csrfField('delete:' . $id) . '' . + '
' . AppContext::e($ctx->t('Do not delete')) . '
'; + $ctx->render('Confirm deletion', $body); +}; diff --git a/exec/handlers/index.php b/exec/handlers/index.php new file mode 100644 index 0000000..ad1a9b0 --- /dev/null +++ b/exec/handlers/index.php @@ -0,0 +1,40 @@ +rules->all($ctx->daUser->username()); + $ok = []; + $status = $ctx->query('status'); + if ($status === 'created') { $ok[] = $ctx->t('Rule created.'); } + if ($status === 'updated') { $ok[] = $ctx->t('Rule updated.'); } + if ($status === 'deleted') { $ok[] = $ctx->t('Rule deleted.'); } + if ($status === 'toggled') { $ok[] = $ctx->t('Rule status changed.'); } + + if ($rules === []) { + $body = '

' . AppContext::e($ctx->t('No global autoresponders')) . '

' . + '

' . AppContext::e($ctx->t('All valid POP/IMAP mailboxes in this account')) . '

' . + '' . AppContext::e($ctx->t('Add autoresponder')) . '
'; + $ctx->render('Global autoresponders', $body, $ok); + return; + } + + $rows = ''; + foreach ($rules as $rule) { + $id = (string)$rule['id']; + $enabled = !empty($rule['enabled']); + $rows .= '' . AppContext::e($ctx->t($enabled ? 'Enabled' : 'Disabled')) . ''; + $rows .= '' . AppContext::e((string)$rule['subject']) . '
' . date('Y-m-d H:i', (int)($rule['updated_at'] ?? time())) . ''; + $rows .= '' . AppContext::e(date('Y-m-d H:i', (int)$rule['start_ts'])) . '
do ' . AppContext::e(date('Y-m-d H:i', (int)$rule['end_ts'])) . ''; + $rows .= '' . AppContext::e($rule['dynamic_scope'] ? 'Dynamiczny' : 'Snapshot') . ''; + $rows .= '' . AppContext::e($ctx->t('Edit')) . ''; + $rows .= '' . AppContext::e($ctx->t('Preview')) . ''; + $rows .= '
' . $ctx->csrfField('toggle:' . $id) . '
'; + $rows .= '' . AppContext::e($ctx->t('Delete')) . ''; + $rows .= '
'; + } + + $body = '

' . AppContext::e($ctx->t('Global autoresponders')) . '

' . AppContext::e($ctx->t('All valid POP/IMAP mailboxes in this account')) . '

' . + '' . AppContext::e($ctx->t('Add autoresponder')) . '
' . + '' . $rows . '
Status' . AppContext::e($ctx->t('Subject')) . 'Okres wysyłkiZakresAkcje
'; + $ctx->render('Global autoresponders', $body, $ok); +}; diff --git a/exec/handlers/preview.php b/exec/handlers/preview.php new file mode 100644 index 0000000..2f43ddb --- /dev/null +++ b/exec/handlers/preview.php @@ -0,0 +1,16 @@ +query('id'); + $rule = $ctx->rules->find($ctx->daUser->username(), $id); + if ($rule === null) { + $ctx->render('Preview autoresponder', '
Rule not found
'); + return; + } + $body = '

' . AppContext::e($ctx->t('Preview autoresponder')) . '

' . render_preview_box($rule) . '
' . + '

Szczegóły reguły

Status' . AppContext::e($ctx->t(!empty($rule['enabled']) ? 'Enabled' : 'Disabled')) . '
' . + '
Okres' . AppContext::e(date('Y-m-d H:i', (int)$rule['start_ts']) . ' - ' . date('Y-m-d H:i', (int)$rule['end_ts'])) . '
' . + '
'; + $ctx->render('Preview autoresponder', $body); +}; diff --git a/exec/handlers/toggle.php b/exec/handlers/toggle.php new file mode 100644 index 0000000..df25faf --- /dev/null +++ b/exec/handlers/toggle.php @@ -0,0 +1,14 @@ +requirePost(); + $id = $ctx->post('id'); + $ctx->requireCsrf('toggle:' . $id); + $rule = $ctx->rules->find($ctx->daUser->username(), $id); + if ($rule === null) { + throw new RuntimeException('Rule not found'); + } + $ctx->rules->update($ctx->daUser->username(), $id, ['enabled' => empty($rule['enabled'])]); + Http::redirect($ctx->url('index.html', ['status' => 'toggled'])); +}; diff --git a/exec/handlers/update.php b/exec/handlers/update.php new file mode 100644 index 0000000..8f580f0 --- /dev/null +++ b/exec/handlers/update.php @@ -0,0 +1,24 @@ +post('id') : $ctx->query('id'); + $rule = $ctx->rules->find($ctx->daUser->username(), $id); + if ($rule === null) { + $ctx->render('Edit autoresponder', '
Rule not found
'); + return; + } + $errors = []; + if (Http::isPost()) { + try { + $ctx->requireCsrf('update:' . $id); + $input = normalize_rule_post($_POST); + $patch = RuleValidator::validate($input, $ctx->settings); + $ctx->rules->update($ctx->daUser->username(), $id, $patch); + Http::redirect($ctx->url('index.html', ['status' => 'updated'])); + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + $ctx->render('Edit autoresponder', render_rule_form($ctx, 'update', $rule), [], $errors); +}; diff --git a/exec/lib/AppContext.php b/exec/lib/AppContext.php new file mode 100644 index 0000000..e1ee114 --- /dev/null +++ b/exec/lib/AppContext.php @@ -0,0 +1,126 @@ + $query */ + public function url(string $page, array $query = []): string + { + $url = $this->baseUrl() . '/' . ltrim($page, '/'); + if ($query !== []) { + $url .= '?' . http_build_query($query); + } + return $url; + } + + public function t(string $key): string + { + return $this->lang->t($key); + } + + public function post(string $key, string $default = ''): string + { + return Http::getString($_POST, $key, $default); + } + + public function query(string $key, string $default = ''): string + { + return Http::getString($_GET, $key, $default); + } + + public function requirePost(): void + { + if (!Http::isPost()) { + throw new RuntimeException('Method not allowed'); + } + } + + public function csrfField(string $intent): string + { + $issued = $this->csrf->issue($intent); + return '' . + '' . + ''; + } + + public function requireCsrf(string $intent): void + { + if (!$this->csrf->validate($intent, $this->post('csrf_ts'), $this->post('csrf_token'), 3600, $this->post('csrf_sid'))) { + throw new RuntimeException('Nieprawidłowy lub wygasły token CSRF.'); + } + } + + public function render(string $title, string $body, array $ok = [], array $errors = []): void + { + header('Content-Type: text/html; charset=UTF-8'); + $alerts = ''; + foreach ($ok as $msg) { + $alerts .= '
' . self::e((string)$msg) . '
'; + } + foreach ($errors as $msg) { + $alerts .= '
' . self::e((string)$msg) . '
'; + } + echo ''; + echo ''; + echo '' . self::e($this->t($title)) . ''; + echo '
'; + echo '

' . self::e($this->t($title)) . '

' . self::e($this->t('Manage automatic replies for account mailboxes')) . '

'; + echo ''; + echo '
' . $alerts . $this->lang->translateHtml($body) . '
'; + echo ''; + } + + public static function e(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + private function styles(): string + { + $skin = $this->daUser->skin(); + $path = PLUGIN_ROOT . '/user/' . $skin . '.css'; + if (!is_file($path)) { + $path = PLUGIN_ROOT . '/user/enhanced.css'; + } + return is_file($path) ? (string)file_get_contents($path) : ''; + } + + private function scripts(): string + { + return <<<'JS' +(function () { + document.querySelectorAll('[data-date-value]').forEach(function (btn) { + btn.addEventListener('click', function () { + var target = document.getElementById(btn.getAttribute('data-date-target')); + var label = document.getElementById(btn.getAttribute('data-date-label')); + if (target) { target.value = btn.getAttribute('data-date-value') || ''; } + if (label) { label.textContent = 'Wybrano: ' + (btn.getAttribute('data-date-value') || ''); } + btn.closest('.br-restore-calendar').querySelectorAll('.br-cal-day').forEach(function (d) { d.classList.remove('is-selected'); }); + btn.classList.add('is-selected'); + }); + }); +})(); +JS; + } +} diff --git a/exec/lib/AuditLog.php b/exec/lib/AuditLog.php new file mode 100644 index 0000000..0597bb8 --- /dev/null +++ b/exec/lib/AuditLog.php @@ -0,0 +1,28 @@ + $fields */ + public function record(string $action, string $user, array $fields = []): void + { + $dir = dirname($this->path); + if (!is_dir($dir)) { + @mkdir($dir, 0700, true); + } + unset($fields['body']); + $line = json_encode([ + 'ts' => date('c'), + 'action' => $action, + 'user' => $user, + 'fields' => $fields, + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($line !== false) { + @file_put_contents($this->path, $line . "\n", FILE_APPEND | LOCK_EX); + } + } +} diff --git a/exec/lib/CsrfGuard.php b/exec/lib/CsrfGuard.php new file mode 100644 index 0000000..bc568ad --- /dev/null +++ b/exec/lib/CsrfGuard.php @@ -0,0 +1,43 @@ +sessionId = $this->sessionId !== '' ? $this->sessionId : 'no_session'; + } + + /** + * @return array{ts:int,sid:string,token:string} + */ + public function issue(string $intent): array + { + $ts = time(); + return ['ts' => $ts, 'sid' => $this->sessionId, 'token' => $this->buildToken($intent, $ts, $this->sessionId)]; + } + + public function validate(string $intent, string $timestamp, string $token, int $ttl = 3600, string $sessionHint = ''): bool + { + if (!preg_match('/^[0-9]{1,20}$/', $timestamp)) { + return false; + } + $ts = (int)$timestamp; + $now = time(); + if ($ts <= 0 || $ts > $now + 30 || $now - $ts > $ttl) { + return false; + } + $sids = array_values(array_unique(array_filter([$sessionHint, $this->sessionId, 'no_session'], static fn ($v): bool => $v !== ''))); + foreach ($sids as $sid) { + if (hash_equals($this->buildToken($intent, $ts, $sid), trim($token))) { + return true; + } + } + return false; + } + + private function buildToken(string $intent, int $timestamp, string $sid): string + { + return hash_hmac('sha256', implode('|', [$this->username, $sid, $intent, (string)$timestamp]), $this->secret); + } +} diff --git a/exec/lib/DirectAdminSyncRepository.php b/exec/lib/DirectAdminSyncRepository.php new file mode 100644 index 0000000..fe05949 --- /dev/null +++ b/exec/lib/DirectAdminSyncRepository.php @@ -0,0 +1,67 @@ +> $entries */ + public function replaceRuleEntries(string $username, string $ruleId, array $entries): void + { + $data = $this->read($username); + $data['rules'][$ruleId] = array_values($entries); + $this->write($username, $data); + } + + /** @return array> */ + public function entriesForRule(string $username, string $ruleId): array + { + $data = $this->read($username); + return array_values($data['rules'][$ruleId] ?? []); + } + + /** @return array> */ + public function deleteRuleEntries(string $username, string $ruleId): array + { + $data = $this->read($username); + $entries = array_values($data['rules'][$ruleId] ?? []); + unset($data['rules'][$ruleId]); + $this->write($username, $data); + return $entries; + } + + /** @return array{rules:array>>} */ + private function read(string $username): array + { + $path = $this->path($username); + if (!is_file($path)) { + return ['rules' => []]; + } + $data = json_decode((string)file_get_contents($path), true); + return is_array($data) && isset($data['rules']) && is_array($data['rules']) ? $data : ['rules' => []]; + } + + /** @param array{rules:array>>} $data */ + private function write(string $username, array $data): void + { + $path = $this->path($username); + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, 0700, true); + } + $tmp = $path . '.tmp.' . getmypid(); + file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX); + @chmod($tmp, 0600); + rename($tmp, $path); + } + + private function path(string $username): string + { + if (!preg_match('/^[A-Za-z0-9._-]+$/', $username)) { + throw new InvalidArgumentException('Invalid username'); + } + return rtrim($this->baseDir, '/') . '/users/' . $username . '/da-sync.json'; + } +} diff --git a/exec/lib/DirectAdminUser.php b/exec/lib/DirectAdminUser.php new file mode 100644 index 0000000..4eaa97e --- /dev/null +++ b/exec/lib/DirectAdminUser.php @@ -0,0 +1,78 @@ +username = $username; + $this->skin = $skin !== '' ? $skin : 'enhanced'; + $this->language = $language !== '' ? strtolower($language) : 'en'; + $this->configDir = $configDir; + } + + public static function fromEnvironment(Settings $settings): self + { + $username = getenv('USERNAME') ?: getenv('USER') ?: getenv('DA_USER') ?: ''; + if ($username === '' && isset($_SERVER['USERNAME'])) { + $username = (string)$_SERVER['USERNAME']; + } + if ($username === '') { + throw new RuntimeException('Cannot determine DirectAdmin user'); + } + $skin = getenv('SKIN') ?: (string)($_SERVER['SKIN'] ?? 'enhanced'); + $lang = self::readLanguage($username) ?: $settings->defaultLanguage(); + return new self($username, $skin, $lang); + } + + public function username(): string + { + return $this->username; + } + + public function skin(): string + { + return $this->skin; + } + + public function language(): string + { + return $this->language; + } + + public function hasPluginAccess(Settings $settings): bool + { + $override = $this->configDir . '/users/' . $this->username . '.conf'; + if (is_file($override)) { + $data = Settings::load($override); + return $data->getBool('enabled', $settings->defaultEnable()); + } + return $settings->defaultEnable(); + } + + private static function readLanguage(string $username): string + { + $path = '/usr/local/directadmin/data/users/' . $username . '/user.conf'; + if (!is_file($path)) { + return ''; + } + $lines = file($path, FILE_IGNORE_NEW_LINES); + if ($lines === false) { + return ''; + } + foreach ($lines as $line) { + if (str_starts_with($line, 'language=')) { + return strtolower(trim(substr($line, 9))); + } + } + return ''; + } +} diff --git a/exec/lib/DirectAdminVacationApi.php b/exec/lib/DirectAdminVacationApi.php new file mode 100644 index 0000000..b0d1236 --- /dev/null +++ b/exec/lib/DirectAdminVacationApi.php @@ -0,0 +1,41 @@ + $rule @return array */ + public function buildSavePayload(string $email, array $rule): array + { + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new InvalidArgumentException('Invalid mailbox email'); + } + [$local, $domain] = explode('@', strtolower($email), 2); + [$startDate, $startTime] = $this->splitPeriod((string)($rule['start_value'] ?? '')); + [$endDate, $endTime] = $this->splitPeriod((string)($rule['end_value'] ?? '')); + return [ + 'action' => 'modify', + 'domain' => $domain, + 'user' => $local, + 'email' => $email, + 'text' => (string)($rule['body'] ?? ''), + 'subject' => (string)($rule['subject'] ?? ''), + 'startdate' => $startDate, + 'starttime' => $startTime, + 'enddate' => $endDate, + 'endtime' => $endTime, + ]; + } + + /** @return array{0:string,1:string} */ + private function splitPeriod(string $value): array + { + if (!preg_match('/^([0-9]{4}-[0-9]{2}-[0-9]{2})\|(morning|afternoon|evening)$/', $value, $m)) { + throw new InvalidArgumentException('DirectAdmin API backend requires directadmin_period values'); + } + return [$m[1], $m[2]]; + } +} diff --git a/exec/lib/Http.php b/exec/lib/Http.php new file mode 100644 index 0000000..27b375a --- /dev/null +++ b/exec/lib/Http.php @@ -0,0 +1,30 @@ + $source + */ + public static function getString(array $source, string $key, string $default = ''): string + { + $value = $source[$key] ?? $default; + return is_scalar($value) ? trim((string)$value) : $default; + } + + public static function redirect(string $url): void + { + header('Location: ' . $url, true, 302); + exit; + } +} diff --git a/exec/lib/Lang.php b/exec/lib/Lang.php new file mode 100644 index 0000000..b7ad02f --- /dev/null +++ b/exec/lib/Lang.php @@ -0,0 +1,61 @@ + */ + private array $map; + private string $code; + + /** + * @param array $map + */ + private function __construct(string $code, array $map) + { + $this->code = $code; + $this->map = $map; + } + + public static function load(string $preferred, Settings $settings): self + { + $candidates = [strtolower(trim($preferred)), $settings->defaultLanguage(), 'en']; + foreach ($candidates as $code) { + if (!preg_match('/^[a-z]{2}$/', $code)) { + continue; + } + $path = PLUGIN_ROOT . '/lang/' . $code . '.php'; + if (is_file($path)) { + $data = require $path; + return new self($code, is_array($data) ? $data : []); + } + } + return new self('en', []); + } + + public function code(): string + { + return $this->code; + } + + /** + * @param array $vars + */ + public function t(string $key, array $vars = []): string + { + $text = $this->map[$key] ?? $key; + foreach ($vars as $var => $value) { + $text = str_replace('{' . $var . '}', (string)$value, $text); + } + return $text; + } + + public function translateHtml(string $html): string + { + if ($html === '' || empty($this->map)) { + return $html; + } + $keys = array_keys($this->map); + usort($keys, static fn (string $a, string $b): int => strlen($b) <=> strlen($a)); + return str_replace($keys, array_map(fn (string $k): string => $this->map[$k], $keys), $html); + } +} diff --git a/exec/lib/LocalizedException.php b/exec/lib/LocalizedException.php new file mode 100644 index 0000000..330b547 --- /dev/null +++ b/exec/lib/LocalizedException.php @@ -0,0 +1,22 @@ + $vars */ + public function __construct(private string $key, private array $vars = []) + { + parent::__construct($key); + } + + public function key(): string + { + return $this->key; + } + + /** @return array */ + public function vars(): array + { + return $this->vars; + } +} diff --git a/exec/lib/MailSender.php b/exec/lib/MailSender.php new file mode 100644 index 0000000..8ea5030 --- /dev/null +++ b/exec/lib/MailSender.php @@ -0,0 +1,43 @@ + */ + public function mailboxesForUser(string $username): array + { + $domains = $this->domainsForUser($username); + $out = []; + foreach ($domains as $domain) { + $passwd = $this->virtualRoot . '/' . $domain . '/passwd'; + if (!is_file($passwd)) { + continue; + } + $lines = file($passwd, FILE_IGNORE_NEW_LINES) ?: []; + foreach ($lines as $line) { + $local = trim(strtok($line, ':') ?: ''); + if ($local === '' || !preg_match('/^[A-Za-z0-9._+-]+$/', $local)) { + continue; + } + $out[] = ['domain' => $domain, 'mailbox' => $local, 'email' => $local . '@' . $domain]; + } + } + usort($out, static fn (array $a, array $b): int => strcmp($a['email'], $b['email'])); + return $out; + } + + /** @return string[] */ + private function domainsForUser(string $username): array + { + $path = $this->virtualRoot . '/domainowners'; + if (!is_file($path)) { + return []; + } + $domains = []; + foreach (file($path, FILE_IGNORE_NEW_LINES) ?: [] as $line) { + if (!str_contains($line, ':')) { + continue; + } + [$domain, $owner] = array_map('trim', explode(':', $line, 2)); + if ($owner !== $username || !preg_match('/^[A-Za-z0-9.-]+$/', $domain)) { + continue; + } + $domains[] = strtolower($domain); + } + sort($domains); + return $domains; + } +} diff --git a/exec/lib/MessageClassifier.php b/exec/lib/MessageClassifier.php new file mode 100644 index 0000000..5ce6cd7 --- /dev/null +++ b/exec/lib/MessageClassifier.php @@ -0,0 +1,31 @@ + $headers */ + public static function shouldSuppress(array $headers, string $sender): bool + { + $sender = strtolower(trim($sender)); + if ($sender === '' || str_starts_with($sender, 'mailer-daemon') || str_starts_with($sender, 'postmaster')) { + return true; + } + $normalized = []; + foreach ($headers as $key => $value) { + $normalized[strtolower(trim($key))] = strtolower(trim($value)); + } + if (($normalized['auto-submitted'] ?? 'no') !== 'no') { + return true; + } + if (in_array($normalized['precedence'] ?? '', ['bulk', 'list', 'junk'], true)) { + return true; + } + if (($normalized['list-id'] ?? '') !== '') { + return true; + } + if (str_contains($normalized['content-type'] ?? '', 'delivery-status')) { + return true; + } + return false; + } +} diff --git a/exec/lib/RepeatState.php b/exec/lib/RepeatState.php new file mode 100644 index 0000000..cc9fcfd --- /dev/null +++ b/exec/lib/RepeatState.php @@ -0,0 +1,48 @@ +path($owner, $ruleId, $recipient, $sender); + if (!is_file($path)) { + return true; + } + $data = json_decode((string)file_get_contents($path), true); + $last = is_array($data) ? (int)($data['last_sent_at'] ?? 0) : 0; + return ($this->time() - $last) >= ($repeatMinutes * 60); + } + + public function record(string $owner, string $ruleId, string $recipient, string $sender): void + { + $path = $this->path($owner, $ruleId, $recipient, $sender); + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, 0700, true); + } + file_put_contents($path, json_encode(['last_sent_at' => $this->time()], JSON_PRETTY_PRINT), LOCK_EX); + @chmod($path, 0600); + } + + private function path(string $owner, string $ruleId, string $recipient, string $sender): string + { + if (!preg_match('/^[A-Za-z0-9._-]+$/', $owner)) { + throw new InvalidArgumentException('Invalid owner'); + } + $hash = hash('sha256', implode('|', [$owner, $ruleId, strtolower($recipient), strtolower($sender)])); + return rtrim($this->baseDir, '/') . '/replies/' . $owner . '/' . $hash . '.json'; + } + + private function time(): int + { + return $this->now ?? time(); + } +} diff --git a/exec/lib/RuleRepository.php b/exec/lib/RuleRepository.php new file mode 100644 index 0000000..9db3ec2 --- /dev/null +++ b/exec/lib/RuleRepository.php @@ -0,0 +1,104 @@ +> */ + public function all(string $username): array + { + $data = $this->read($username); + return array_values($data['rules'] ?? []); + } + + /** @return array|null */ + public function find(string $username, string $id): ?array + { + foreach ($this->all($username) as $rule) { + if (($rule['id'] ?? '') === $id) { + return $rule; + } + } + return null; + } + + /** @param array $rule @return array */ + public function create(string $username, array $rule): array + { + $data = $this->read($username); + $now = time(); + $rule['id'] = bin2hex(random_bytes(12)); + $rule['created_at'] = $now; + $rule['updated_at'] = $now; + $data['rules'][] = $rule; + $this->write($username, $data); + return $rule; + } + + /** @param array $patch @return array */ + public function update(string $username, string $id, array $patch): array + { + $data = $this->read($username); + foreach ($data['rules'] as $i => $rule) { + if (($rule['id'] ?? '') === $id) { + $updated = array_merge($rule, $patch, ['id' => $id, 'updated_at' => time()]); + $data['rules'][$i] = $updated; + $this->write($username, $data); + return $updated; + } + } + throw new RuntimeException('Rule not found'); + } + + public function delete(string $username, string $id): void + { + $data = $this->read($username); + $before = count($data['rules']); + $data['rules'] = array_values(array_filter($data['rules'], static fn (array $rule): bool => ($rule['id'] ?? '') !== $id)); + if (count($data['rules']) === $before) { + throw new RuntimeException('Rule not found'); + } + $this->write($username, $data); + } + + /** @return array{rules:array>} */ + private function read(string $username): array + { + $path = $this->path($username); + if (!is_file($path)) { + return ['rules' => []]; + } + $data = json_decode((string)file_get_contents($path), true); + return is_array($data) && isset($data['rules']) && is_array($data['rules']) ? $data : ['rules' => []]; + } + + /** @param array{rules:array>} $data */ + private function write(string $username, array $data): void + { + $path = $this->path($username); + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, 0700, true); + } + $tmp = $path . '.tmp.' . getmypid(); + file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX); + @chmod($tmp, 0600); + rename($tmp, $path); + } + + private function path(string $username): string + { + $this->assertUsername($username); + return rtrim($this->baseDir, '/') . '/users/' . $username . '/rules.json'; + } + + private function assertUsername(string $username): void + { + if (!preg_match('/^[A-Za-z0-9._-]+$/', $username)) { + throw new InvalidArgumentException('Invalid username'); + } + } +} diff --git a/exec/lib/RuleValidator.php b/exec/lib/RuleValidator.php new file mode 100644 index 0000000..7bd4e60 --- /dev/null +++ b/exec/lib/RuleValidator.php @@ -0,0 +1,84 @@ + $input @return array */ + public static function validate(array $input, Settings $settings): array + { + $subject = trim((string)($input['subject'] ?? '')); + if ($subject === '') { + throw new InvalidArgumentException('Temat jest wymagany.'); + } + if (str_contains($subject, "\r") || str_contains($subject, "\n")) { + throw new InvalidArgumentException('Temat nie może zawierać nowych linii.'); + } + if (strlen($subject) > $settings->maxSubjectBytes()) { + throw new InvalidArgumentException('Temat jest za długi.'); + } + + $body = str_replace(["\r\n", "\r", "\0"], ["\n", "\n", ''], (string)($input['body'] ?? '')); + $body = trim($body); + if ($body === '') { + throw new InvalidArgumentException('Treść wiadomości jest wymagana.'); + } + if (strlen($body) > $settings->maxBodyBytes()) { + throw new InvalidArgumentException('Treść wiadomości jest za długa.'); + } + + $mode = $settings->scheduleMode(); + $tz = new DateTimeZone($settings->timezone()); + [$startTs, $startValue] = self::parseScheduleValue((string)($input['start_value'] ?? ''), $mode, $tz, true); + [$endTs, $endValue] = self::parseScheduleValue((string)($input['end_value'] ?? ''), $mode, $tz, false); + if ($endTs <= $startTs) { + throw new InvalidArgumentException('Data zakończenia musi być późniejsza niż data rozpoczęcia.'); + } + + return [ + 'subject' => $subject, + 'body' => $body, + 'schedule_mode' => $mode, + 'timezone' => $settings->timezone(), + 'start_value' => $startValue, + 'end_value' => $endValue, + 'start_ts' => $startTs, + 'end_ts' => $endTs, + 'enabled' => self::truthy($input['enabled'] ?? false), + 'dynamic_scope' => $settings->dynamicMailboxScope(), + ]; + } + + /** @return array{0:int,1:string} */ + private static function parseScheduleValue(string $value, string $mode, DateTimeZone $tz, bool $start): array + { + $value = trim($value); + if ($mode === 'exact_hour') { + if (!preg_match('/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:00$/', $value)) { + throw new InvalidArgumentException('Nieprawidłowa data lub godzina.'); + } + $dt = DateTimeImmutable::createFromFormat('!Y-m-d\TH:i', $value, $tz); + if (!$dt) { + throw new InvalidArgumentException('Nieprawidłowa data lub godzina.'); + } + return [$dt->getTimestamp(), $value]; + } + + if (!preg_match('/^([0-9]{4}-[0-9]{2}-[0-9]{2})\|(morning|afternoon|evening)$/', $value, $m)) { + throw new InvalidArgumentException('Nieprawidłowa data lub pora dnia.'); + } + $hour = ['morning' => 8, 'afternoon' => 13, 'evening' => 18][$m[2]]; + if (!$start && $m[2] === 'evening') { + $hour = 23; + } + $dt = DateTimeImmutable::createFromFormat('!Y-m-d H:i', $m[1] . ' ' . sprintf('%02d:00', $hour), $tz); + if (!$dt) { + throw new InvalidArgumentException('Nieprawidłowa data lub pora dnia.'); + } + return [$dt->getTimestamp(), $value]; + } + + private static function truthy(mixed $value): bool + { + return in_array(strtolower((string)$value), ['1', 'true', 'yes', 'on'], true); + } +} diff --git a/exec/lib/Settings.php b/exec/lib/Settings.php new file mode 100644 index 0000000..119958f --- /dev/null +++ b/exec/lib/Settings.php @@ -0,0 +1,161 @@ + */ + private array $values; + + /** + * @param array $values + */ + private function __construct(array $values) + { + $this->values = $values; + $this->validate(); + } + + public static function load(string $path): self + { + $values = self::defaults(); + if (is_file($path)) { + $lines = file($path, FILE_IGNORE_NEW_LINES); + if ($lines !== false) { + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) { + continue; + } + [$key, $value] = explode('=', $line, 2); + $values[trim($key)] = trim($value); + } + } + } + return new self($values); + } + + /** + * @return array + */ + public static function defaults(): array + { + return [ + 'DEFAULT_PLUGIN_LANGUAGE' => 'en', + 'DEFAULT_ENABLE' => 'true', + 'GLOBAL_AUTORESPONDER_TIMEZONE' => 'Europe/Warsaw', + 'GLOBAL_AUTORESPONDER_SCHEDULE_MODE' => 'exact_hour', + 'GLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE' => 'false', + 'GLOBAL_AUTORESPONDER_REPEAT_MINUTES' => '1440', + 'GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE' => 'true', + 'GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES' => '255', + 'GLOBAL_AUTORESPONDER_MAX_BODY_BYTES' => '20000', + ]; + } + + public function getString(string $key, string $default = ''): string + { + return $this->values[$key] ?? $default; + } + + public function getBool(string $key, bool $default = false): bool + { + $raw = strtolower($this->getString($key, $default ? 'true' : 'false')); + return in_array($raw, ['1', 'true', 'yes', 'on'], true); + } + + public function getInt(string $key, int $default): int + { + $raw = $this->getString($key, (string)$default); + return preg_match('/^-?[0-9]+$/', $raw) ? (int)$raw : $default; + } + + public function defaultLanguage(): string + { + $lang = strtolower($this->getString('DEFAULT_PLUGIN_LANGUAGE', 'en')); + return preg_match('/^[a-z]{2}$/', $lang) ? $lang : 'en'; + } + + public function defaultEnable(): bool + { + return $this->getBool('DEFAULT_ENABLE', true); + } + + public function timezone(): string + { + return $this->getString('GLOBAL_AUTORESPONDER_TIMEZONE', 'Europe/Warsaw'); + } + + public function scheduleMode(): string + { + return $this->getString('GLOBAL_AUTORESPONDER_SCHEDULE_MODE', 'exact_hour'); + } + + public function replyEveryMessage(): bool + { + return $this->getBool('GLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE', false); + } + + public function repeatMinutes(): int + { + return $this->getInt('GLOBAL_AUTORESPONDER_REPEAT_MINUTES', 1440); + } + + public function dynamicMailboxScope(): bool + { + return $this->getBool('GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE', true); + } + + public function maxSubjectBytes(): int + { + return $this->getInt('GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES', 255); + } + + public function maxBodyBytes(): int + { + return $this->getInt('GLOBAL_AUTORESPONDER_MAX_BODY_BYTES', 20000); + } + + public function loadOrCreateSecret(string $path = self::CONFIG_DIR . '/secret.key'): string + { + if (is_file($path)) { + $secret = trim((string)file_get_contents($path)); + if ($secret !== '') { + return $secret; + } + } + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir, 0700, true); + } + $secret = bin2hex(random_bytes(32)); + file_put_contents($path, $secret . "\n", LOCK_EX); + @chmod($path, 0600); + return $secret; + } + + private function validate(): void + { + try { + new DateTimeZone($this->timezone()); + } catch (Throwable) { + throw new InvalidArgumentException('Invalid timezone: ' . $this->timezone()); + } + if (!in_array($this->scheduleMode(), ['exact_hour', 'directadmin_period'], true)) { + throw new InvalidArgumentException('Invalid schedule mode: ' . $this->scheduleMode()); + } + if ($this->repeatMinutes() < 0) { + throw new InvalidArgumentException('Invalid repeat minutes'); + } + if ($this->maxSubjectBytes() < 1 || $this->maxBodyBytes() < 1) { + throw new InvalidArgumentException('Invalid size limits'); + } + } +} diff --git a/hooks/user_img.html b/hooks/user_img.html new file mode 100644 index 0000000..1d22309 --- /dev/null +++ b/hooks/user_img.html @@ -0,0 +1 @@ +images/user_icon.svg diff --git a/hooks/user_txt.html b/hooks/user_txt.html new file mode 100644 index 0000000..35daf1a --- /dev/null +++ b/hooks/user_txt.html @@ -0,0 +1 @@ +Globalne autorespondery diff --git a/lang/en.php b/lang/en.php new file mode 100644 index 0000000..e12cfe9 --- /dev/null +++ b/lang/en.php @@ -0,0 +1,39 @@ + 'Global autoresponders', + 'Manage automatic replies for account mailboxes' => 'Manage automatic replies for account mailboxes', + 'Dashboard' => 'Dashboard', + 'AUTORESPONDERS' => 'AUTORESPONDERS', + 'ADD AUTORESPONDER' => 'ADD AUTORESPONDER', + 'Add autoresponder' => 'Add autoresponder', + 'Edit autoresponder' => 'Edit autoresponder', + 'Preview autoresponder' => 'Preview autoresponder', + 'Subject' => 'Subject', + 'Message body' => 'Message body', + 'Status' => 'Status', + 'Enabled' => 'Enabled', + 'Disabled' => 'Disabled', + 'Start date' => 'Start date', + 'End date' => 'End date', + 'Start hour' => 'Start hour', + 'End hour' => 'End hour', + 'Save autoresponder' => 'Save autoresponder', + 'Save changes' => 'Save changes', + 'Cancel' => 'Cancel', + 'Edit' => 'Edit', + 'Preview' => 'Preview', + 'Delete' => 'Delete', + 'Enable' => 'Enable', + 'Disable' => 'Disable', + 'Confirm deletion' => 'Confirm deletion', + 'Delete autoresponder' => 'Delete autoresponder', + 'Do not delete' => 'Do not delete', + 'No global autoresponders' => 'No global autoresponders', + 'Rule created.' => 'Rule created.', + 'Rule updated.' => 'Rule updated.', + 'Rule deleted.' => 'Rule deleted.', + 'Rule status changed.' => 'Rule status changed.', + 'All valid POP/IMAP mailboxes in this account' => 'All valid POP/IMAP mailboxes in this account', +]; diff --git a/lang/pl.php b/lang/pl.php new file mode 100644 index 0000000..1ded965 --- /dev/null +++ b/lang/pl.php @@ -0,0 +1,39 @@ + 'Globalne autorespondery', + 'Manage automatic replies for account mailboxes' => 'Zarządzanie odpowiedziami dla skrzynek pocztowych konta', + 'Dashboard' => 'Pulpit', + 'AUTORESPONDERS' => 'AUTORESPONDERY', + 'ADD AUTORESPONDER' => 'DODAJ AUTORESPONDER', + 'Add autoresponder' => 'Dodaj autoresponder', + 'Edit autoresponder' => 'Edytuj autoresponder', + 'Preview autoresponder' => 'Podgląd autorespondera', + 'Subject' => 'Temat', + 'Message body' => 'Treść wiadomości', + 'Status' => 'Status', + 'Enabled' => 'Włączony', + 'Disabled' => 'Wyłączony', + 'Start date' => 'Data rozpoczęcia', + 'End date' => 'Data zakończenia', + 'Start hour' => 'Godzina rozpoczęcia', + 'End hour' => 'Godzina zakończenia', + 'Save autoresponder' => 'Zapisz autoresponder', + 'Save changes' => 'Zapisz zmiany', + 'Cancel' => 'Anuluj', + 'Edit' => 'Edytuj', + 'Preview' => 'Podgląd', + 'Delete' => 'Usuń', + 'Enable' => 'Włącz', + 'Disable' => 'Wyłącz', + 'Confirm deletion' => 'Potwierdź usunięcie', + 'Delete autoresponder' => 'Usuń autoresponder', + 'Do not delete' => 'Nie usuwaj', + 'No global autoresponders' => 'Brak globalnych autoresponderów', + 'Rule created.' => 'Reguła została utworzona.', + 'Rule updated.' => 'Reguła została zaktualizowana.', + 'Rule deleted.' => 'Reguła została usunięta.', + 'Rule status changed.' => 'Status reguły został zmieniony.', + 'All valid POP/IMAP mailboxes in this account' => 'Wszystkie prawidłowe skrzynki POP/IMAP konta', +]; diff --git a/php.ini b/php.ini new file mode 100644 index 0000000..8561bda --- /dev/null +++ b/php.ini @@ -0,0 +1,7 @@ +display_errors=Off +log_errors=On +html_errors=Off +default_charset=UTF-8 +memory_limit=128M +max_execution_time=60 +open_basedir= diff --git a/plugin-settings.conf b/plugin-settings.conf new file mode 100644 index 0000000..5f4c000 --- /dev/null +++ b/plugin-settings.conf @@ -0,0 +1,31 @@ +# Domyślny język pluginu, gdy język użytkownika DirectAdmin nie jest obsługiwany. +DEFAULT_PLUGIN_LANGUAGE=en + +# Czy plugin jest domyślnie dostępny dla wszystkich użytkowników. +# true = dostępny dla każdego użytkownika, false = wymaga indywidualnego włączenia. +DEFAULT_ENABLE=true + +# Strefa czasu używana do interpretacji i wyświetlania dat rozpoczęcia oraz zakończenia. +GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw + +# Tryb harmonogramu. +# exact_hour = wybór dnia i godziny. +# directadmin_period = wybór dnia oraz morning/afternoon/evening zgodnie z API DirectAdmin. +GLOBAL_AUTORESPONDER_SCHEDULE_MODE=exact_hour + +# Czy odpowiadać automatycznie na każdą kwalifikującą się wiadomość. +GLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE=false + +# Minimalny odstęp w minutach między autoresponderami do tego samego nadawcy, +# używany tylko gdy GLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE=false. +GLOBAL_AUTORESPONDER_REPEAT_MINUTES=1440 + +# Czy reguła obejmuje także skrzynki POP/IMAP dodane po utworzeniu reguły. +# true = zakres dynamiczny, false = snapshot skrzynek przy tworzeniu/edycji. +GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE=true + +# Maksymalna długość tematu po przycięciu białych znaków. +GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES=255 + +# Maksymalna długość treści wiadomości po normalizacji. +GLOBAL_AUTORESPONDER_MAX_BODY_BYTES=20000 diff --git a/plugin.conf b/plugin.conf new file mode 100644 index 0000000..7eb4648 --- /dev/null +++ b/plugin.conf @@ -0,0 +1,7 @@ +name=global-autoresponder +id=global-autoresponder +type=user +author=HITME.PL +version=1.0.1 +active=no +installed=no diff --git a/scripts/backend.sh b/scripts/backend.sh new file mode 100755 index 0000000..075775c --- /dev/null +++ b/scripts/backend.sh @@ -0,0 +1,36 @@ +#!/bin/sh +set -eu + +BASE_DIR="/usr/local/hitme_plugins/global-autoresponders" +STATE_FILE="$BASE_DIR/config/backend-state.json" + +usage() { + echo "Usage: scripts/backend.sh da|exim" >&2 +} + +if [ "${1:-}" != "da" ] && [ "${1:-}" != "exim" ]; then + usage + exit 2 +fi + +mkdir -p "$BASE_DIR/config" "$BASE_DIR/data" "$BASE_DIR/state" "$BASE_DIR/logs" +chmod 700 "$BASE_DIR/config" "$BASE_DIR/logs" + +if [ "$1" = "da" ]; then + cat > "$STATE_FILE" <<'EOF' +{"active_backend":"da","migration_status":"complete"} +EOF + echo "Backend set to da" + exit 0 +fi + +CUSTOM_DIR="/usr/local/directadmin/custombuild/custom/exim" +if [ ! -d "$CUSTOM_DIR" ]; then + echo "Cannot enable exim backend: safe CustomBuild exim customization directory not found." >&2 + exit 1 +fi + +cat > "$STATE_FILE" <<'EOF' +{"active_backend":"exim","migration_status":"pending_exim_validation"} +EOF +echo "Backend prepared for exim; validate Exim customization before production use." diff --git a/scripts/exim/global_autoresponder_router.conf b/scripts/exim/global_autoresponder_router.conf new file mode 100644 index 0000000..5d60379 --- /dev/null +++ b/scripts/exim/global_autoresponder_router.conf @@ -0,0 +1,2 @@ +# global-autoresponder router placeholder +# This file must only be installed through DirectAdmin/CustomBuild-safe Exim customization paths. diff --git a/scripts/exim/global_autoresponder_transport.conf b/scripts/exim/global_autoresponder_transport.conf new file mode 100644 index 0000000..4adc9ff --- /dev/null +++ b/scripts/exim/global_autoresponder_transport.conf @@ -0,0 +1,2 @@ +# global-autoresponder transport placeholder +# This file must only be installed through DirectAdmin/CustomBuild-safe Exim customization paths. diff --git a/scripts/global_autoresponder_worker.php b/scripts/global_autoresponder_worker.php new file mode 100755 index 0000000..5e2b7a7 --- /dev/null +++ b/scripts/global_autoresponder_worker.php @@ -0,0 +1,10 @@ +#!/usr/local/bin/php + "$BASE_DIR/config/backend-state.json" + chmod 600 "$BASE_DIR/config/backend-state.json" +fi + +touch "$BASE_DIR/logs/audit.log" +chmod 600 "$BASE_DIR/logs/audit.log" +echo "global-autoresponder installed" diff --git a/scripts/package.sh b/scripts/package.sh new file mode 100755 index 0000000..037c85c --- /dev/null +++ b/scripts/package.sh @@ -0,0 +1,25 @@ +#!/bin/sh +set -eu + +VERSION="$(awk -F= '$1=="version"{print $2}' plugin.conf)" +if [ -z "$VERSION" ]; then + echo "Cannot determine version from plugin.conf" >&2 + exit 1 +fi +if [ ! -f images/user_icon.svg ]; then + echo "Missing user-supplied icon: src/images/user_icon.svg" >&2 + exit 1 +fi + +ROOT_DIR="$(cd .. && pwd)" +mkdir -p "$ROOT_DIR/archives/$VERSION" +tar -czf "$ROOT_DIR/archives/$VERSION/global-autoresponder.tar.gz" \ + --exclude='./.git' \ + --exclude='./tests' \ + --exclude='./data' \ + --exclude='./state' \ + --exclude='./logs' \ + --exclude='./*.log' \ + --exclude='./.DS_Store' \ + . +echo "$ROOT_DIR/archives/$VERSION/global-autoresponder.tar.gz" diff --git a/scripts/sync_directadmin_vacations.php b/scripts/sync_directadmin_vacations.php new file mode 100755 index 0000000..f205e99 --- /dev/null +++ b/scripts/sync_directadmin_vacations.php @@ -0,0 +1,5 @@ +#!/usr/local/bin/php +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)); +}); diff --git a/tests/csrf_test.php b/tests/csrf_test.php new file mode 100644 index 0000000..f3e870c --- /dev/null +++ b/tests/csrf_test.php @@ -0,0 +1,15 @@ +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')); +}); diff --git a/tests/directadmin_sync_repository_test.php b/tests/directadmin_sync_repository_test.php new file mode 100644 index 0000000..6bae87f --- /dev/null +++ b/tests/directadmin_sync_repository_test.php @@ -0,0 +1,17 @@ +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')); +}); diff --git a/tests/directadmin_vacation_api_test.php b/tests/directadmin_vacation_api_test.php new file mode 100644 index 0000000..6985930 --- /dev/null +++ b/tests/directadmin_vacation_api_test.php @@ -0,0 +1,20 @@ +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']); +}); diff --git a/tests/handler_test.php b/tests/handler_test.php new file mode 100644 index 0000000..7527496 --- /dev/null +++ b/tests/handler_test.php @@ -0,0 +1,20 @@ +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()); +}); diff --git a/tests/mail_sender_test.php b/tests/mail_sender_test.php new file mode 100644 index 0000000..3bc81be --- /dev/null +++ b/tests/mail_sender_test.php @@ -0,0 +1,17 @@ +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); +}); diff --git a/tests/mailbox_directory_test.php b/tests/mailbox_directory_test.php new file mode 100644 index 0000000..a7e9983 --- /dev/null +++ b/tests/mailbox_directory_test.php @@ -0,0 +1,17 @@ +mailboxesForUser('alice'); + + assert_same(['info@example.com', 'sales@example.com'], array_column($mailboxes, 'email')); +}); diff --git a/tests/message_classifier_test.php b/tests/message_classifier_test.php new file mode 100644 index 0000000..f916512 --- /dev/null +++ b/tests/message_classifier_test.php @@ -0,0 +1,13 @@ + 'auto-replied'], 'sender@example.com')); + assert_true(MessageClassifier::shouldSuppress(['precedence' => 'bulk'], 'sender@example.com')); + assert_true(MessageClassifier::shouldSuppress(['list-id' => ''], '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')); +}); diff --git a/tests/package_contents_test.php b/tests/package_contents_test.php new file mode 100644 index 0000000..6266fcf --- /dev/null +++ b/tests/package_contents_test.php @@ -0,0 +1,9 @@ +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)); +}); diff --git a/tests/rule_repository_test.php b/tests/rule_repository_test.php new file mode 100644 index 0000000..b991e31 --- /dev/null +++ b/tests/rule_repository_test.php @@ -0,0 +1,28 @@ + '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'); +}); diff --git a/tests/rule_validator_test.php b/tests/rule_validator_test.php new file mode 100644 index 0000000..3f9621d --- /dev/null +++ b/tests/rule_validator_test.php @@ -0,0 +1,39 @@ + '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'); +}); diff --git a/tests/run.php b/tests/run.php new file mode 100644 index 0000000..fa8925f --- /dev/null +++ b/tests/run.php @@ -0,0 +1,91 @@ +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); +} diff --git a/user/create.html b/user/create.html new file mode 100755 index 0000000..2369ef8 --- /dev/null +++ b/user/create.html @@ -0,0 +1,6 @@ +#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/global-autoresponder/php.ini +