diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c6da0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.log +logs/ +state/ +data/ +.DS_Store diff --git a/exec/bootstrap.php b/exec/bootstrap.php index 06ca659..62d6194 100644 --- a/exec/bootstrap.php +++ b/exec/bootstrap.php @@ -15,7 +15,6 @@ PluginLogger::init(PLUGIN_ROOT . '/plugin.log'); foreach ([ 'Settings.php', - 'BackendRuntimeConfig.php', 'Http.php', 'DirectAdminUser.php', 'Lang.php', @@ -38,7 +37,6 @@ try { Http::bootstrapGlobals(); $settings = Settings::load(Settings::EXTERNAL_SETTINGS); - BackendRuntimeConfig::sync($settings, PLUGIN_ROOT . '/scripts/backend.sh', Settings::CONFIG_DIR . '/backend-state.json'); $user = DirectAdminUser::fromEnvironment($settings); $lang = Lang::load($user->language(), $settings); if (!$user->hasPluginAccess($settings)) { diff --git a/exec/handlers/_form_helpers.php b/exec/handlers/_form_helpers.php index 8921ce2..17638b2 100644 --- a/exec/handlers/_form_helpers.php +++ b/exec/handlers/_form_helpers.php @@ -5,6 +5,7 @@ function render_rule_form(AppContext $ctx, string $mode, ?array $rule): string { $isUpdate = $mode === 'update'; $id = $isUpdate ? (string)$rule['id'] : ''; + $name = (string)($rule['name'] ?? ''); $subjectPrefix = $rule['subject_prefix'] ?? $rule['subject'] ?? ''; $replyFrequency = selected_reply_frequency($ctx, $rule); $body = $rule['body'] ?? ''; @@ -17,16 +18,25 @@ function render_rule_form(AppContext $ctx, string $mode, ?array $rule): string $hiddenId = $isUpdate ? '' : ''; return '
' . $csrf . $hiddenId . - '

' . AppContext::e($ctx->t('Response content')) . '

' . AppContext::e($ctx->t('Original subject suffix')) . '
' . + '

' . AppContext::e($ctx->t('Response content')) . '

' . AppContext::e($ctx->t('Autoresponder name help')) . '
' . + '
' . AppContext::e($ctx->t('Original subject suffix')) . '
' . '
' . render_reply_frequency_select($ctx, $replyFrequency) . '
' . '
' . '
' . - render_calendar_picker($ctx, 'start_value', $ctx->t('Start date'), $startValue) . - render_calendar_picker($ctx, 'end_value', $ctx->t('End date'), $endValue) . + render_schedule_panel($ctx, $startValue, $endValue, !$isUpdate) . '
' . AppContext::e($ctx->t('Cancel')) . '
' . ''; } +function render_schedule_panel(AppContext $ctx, string $startValue, string $endValue, bool $enforceMinDate = true): string +{ + return '

' . AppContext::e($ctx->t('Sending period')) . '

' . + '
' . + render_calendar_picker($ctx, 'start_value', $ctx->t('Start date'), $startValue, $enforceMinDate) . + render_calendar_picker($ctx, 'end_value', $ctx->t('End date'), $endValue, $enforceMinDate) . + '
'; +} + function default_schedule_value(AppContext $ctx, bool $start): string { $today = new DateTimeImmutable('today', new DateTimeZone($ctx->settings->timezone())); @@ -39,9 +49,6 @@ function selected_reply_frequency(AppContext $ctx, ?array $rule): string if ($rule === null) { return '1440'; } - if (!empty($rule['reply_every_message'])) { - return $ctx->settings->backendMode() === 'da' ? '1' : 'every'; - } $minutes = (string)(int)($rule['repeat_minutes'] ?? 1440); return array_key_exists($minutes, reply_frequency_options($ctx)) ? $minutes : '1440'; } @@ -60,11 +67,7 @@ function render_reply_frequency_select(AppContext $ctx, string $selected): strin /** @return array */ function reply_frequency_options(AppContext $ctx): array { - $options = []; - if ($ctx->settings->backendMode() !== 'da') { - $options['every'] = 'Send automatic reply for every message'; - } - return $options + [ + return [ '1' => '1 minute', '10' => '10 minutes', '30' => '30 minutes', @@ -114,12 +117,9 @@ function attach_rule_scope(AppContext $ctx, array $rule): array return $rule; } -/** @param array $rule */ -function enforce_rule_backend_constraints(AppContext $ctx, ?string $currentId, array $rule): void +/** @return array|null */ +function find_active_rule_conflict(AppContext $ctx, ?string $currentId): ?array { - if ($ctx->settings->backendMode() !== 'da' || empty($rule['enabled'])) { - return; - } foreach ($ctx->rules->all($ctx->daUser->username()) as $existing) { if (empty($existing['enabled'])) { continue; @@ -127,13 +127,234 @@ function enforce_rule_backend_constraints(AppContext $ctx, ?string $currentId, a if ($currentId !== null && (string)($existing['id'] ?? '') === $currentId) { continue; } - throw new RuntimeException($ctx->t('DirectAdmin backend supports only one enabled global rule.')); + return $existing; + } + return null; +} + +/** @param array $rule */ +function enforce_rule_directadmin_constraints(AppContext $ctx, ?string $currentId, array $rule): void +{ + if (empty($rule['enabled'])) { + return; + } + if (find_active_rule_conflict($ctx, $currentId) !== null) { + throw new RuntimeException($ctx->t('Only one global autoresponder can be active per user.')); } } -function render_calendar_picker(AppContext $ctx, string $name, string $title, string $value): string +/** @param array $rule */ +function display_rule_name(AppContext $ctx, array $rule): string +{ + $name = trim((string)($rule['name'] ?? '')); + if ($name !== '') { + return $name; + } + $subjectPrefix = trim((string)($rule['subject_prefix'] ?? $rule['subject'] ?? '')); + return $subjectPrefix !== '' ? $subjectPrefix : $ctx->t('Unnamed autoresponder'); +} + +/** @param array $rule */ +function format_rule_created_at(AppContext $ctx, array $rule): string +{ + $timestamp = (int)($rule['created_at'] ?? $rule['updated_at'] ?? time()); + return format_rule_timestamp($ctx, $timestamp); +} + +function format_rule_timestamp(AppContext $ctx, int $timestamp): string +{ + return (new DateTimeImmutable('@' . $timestamp)) + ->setTimezone(new DateTimeZone($ctx->settings->timezone())) + ->format('Y-m-d H:i'); +} + +/** @param array $rule */ +/** @param array $extra */ +function render_hidden_rule_inputs(AppContext $ctx, array $rule, bool $enabled, array $extra = []): string +{ + $fields = [ + 'name' => display_rule_name_for_input($rule), + 'subject_prefix' => (string)($rule['subject_prefix'] ?? $rule['subject'] ?? ''), + 'reply_frequency' => selected_reply_frequency($ctx, $rule), + 'body' => (string)($rule['body'] ?? ''), + 'start_value' => (string)($rule['start_value'] ?? ''), + 'end_value' => (string)($rule['end_value'] ?? ''), + ]; + $html = ''; + foreach ($fields as $name => $value) { + $html .= ''; + } + if ($enabled) { + $html .= ''; + } + foreach ($extra as $name => $value) { + $html .= ''; + } + return $html; +} + +/** @param array $rule */ +function display_rule_name_for_input(array $rule): string +{ + return trim((string)($rule['name'] ?? '')); +} + +/** @param array $pending @param array $active */ +function render_create_conflict_modal(AppContext $ctx, array $pending, array $active): string +{ + $newName = display_rule_name($ctx, $pending); + $oldName = display_rule_name($ctx, $active); + $created = format_rule_created_at($ctx, $active); + $action = $ctx->url('create.html'); + + $html = ''; + return $html; +} + +/** @param array $pending @param array> $nativeEntries @param array $extra */ +function render_native_vacation_conflict_modal(AppContext $ctx, array $pending, array $nativeEntries, array $extra = []): string +{ + $rows = ''; + foreach ($nativeEntries as $entry) { + $email = (string)($entry['email'] ?? ''); + $domain = (string)($entry['domain'] ?? ''); + $mailbox = (string)($entry['mailbox'] ?? ''); + $rows .= '' . AppContext::e($email) . '' . AppContext::e($domain) . '' . AppContext::e($mailbox) . ''; + } + + $html = ''; + return $html; +} + +function directadmin_vacation_sync_service(AppContext $ctx): DirectAdminVacationSyncService +{ + return new DirectAdminVacationSyncService( + $ctx->rules, + new DirectAdminSyncRepository(), + new MailboxDirectory(), + new DirectAdminVacationApi() + ); +} + +/** @return array */ +function list_untracked_native_da_vacations(AppContext $ctx): array +{ + return directadmin_vacation_sync_service($ctx)->untrackedNativeVacations($ctx->daUser->username()); +} + +function delete_untracked_native_da_vacations(AppContext $ctx): void +{ + $service = directadmin_vacation_sync_service($ctx); + $service->deleteNativeVacations($ctx->daUser->username(), $service->untrackedNativeVacations($ctx->daUser->username())); +} + +/** @param array $target @param array $active */ +function render_toggle_conflict_modal(AppContext $ctx, array $target, array $active): string +{ + $targetName = display_rule_name($ctx, $target); + $oldName = display_rule_name($ctx, $active); + $created = format_rule_created_at($ctx, $active); + $id = (string)($target['id'] ?? ''); + + $html = ''; + return $html; +} + +function render_delete_confirmation_body(AppContext $ctx, array $rule): string +{ + $id = (string)($rule['id'] ?? ''); + $name = display_rule_name($ctx, $rule); + $created = format_rule_created_at($ctx, $rule); + $message = $ctx->t('Delete confirmation question', [ + 'name' => $name, + 'created' => $created, + ]); + + return '

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

' . + '
' . $ctx->csrfField('delete:' . $id) . + '' . + '
' . + '' . AppContext::e($ctx->t('No')) . '
'; +} + +function render_rules_index_body(AppContext $ctx, string $extraHtml = ''): string +{ + $rules = $ctx->rules->all($ctx->daUser->username()); + if ($rules === []) { + return '

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

' . $extraHtml; + } + + $rows = ''; + foreach ($rules as $rule) { + $id = (string)$rule['id']; + $enabled = !empty($rule['enabled']); + $subjectPrefix = (string)($rule['subject_prefix'] ?? $rule['subject'] ?? ''); + $rows .= '' . AppContext::e($ctx->t($enabled ? 'Enabled' : 'Disabled')) . ''; + $rows .= '' . AppContext::e(display_rule_name($ctx, $rule)) . ''; + $rows .= '' . AppContext::e($subjectPrefix) . ''; + $rows .= '' . AppContext::e(format_rule_created_at($ctx, $rule)) . ''; + $rows .= '' . AppContext::e(format_rule_timestamp($ctx, (int)$rule['start_ts'])) . '
' . AppContext::e($ctx->t('To')) . ' ' . AppContext::e(format_rule_timestamp($ctx, (int)$rule['end_ts'])) . ''; + $rows .= ''; + $rows .= '' . AppContext::e($ctx->t('Edit')) . ''; + $rows .= '' . AppContext::e($ctx->t('Preview')) . ''; + $rows .= '
' . $ctx->csrfField('toggle:' . $id) . '
'; + $rows .= '' . AppContext::e($ctx->t('Delete')) . ''; + $rows .= '
'; + } + + return '

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

' . + '
' . $rows . '
' . AppContext::e($ctx->t('Status')) . '' . AppContext::e($ctx->t('Autoresponder name')) . '' . AppContext::e($ctx->t('Subject prefix')) . '' . AppContext::e($ctx->t('Created')) . '' . AppContext::e($ctx->t('Sending period')) . '' . AppContext::e($ctx->t('Actions')) . '
' . $extraHtml; +} + +function render_rules_index(AppContext $ctx, array $ok = [], array $errors = [], string $extraHtml = ''): void +{ + $ctx->render('Global autoresponders', render_rules_index_body($ctx, $extraHtml), $ok, $errors); +} + +function render_calendar_picker(AppContext $ctx, string $name, string $title, string $value, bool $enforceMinDate = true): string { $mode = $ctx->settings->scheduleMode(); + $columnClass = $name === 'start_value' ? 'schedule-start' : 'schedule-end'; + $timeLabel = $ctx->t($name === 'start_value' ? 'Start hour' : 'End hour'); $selectedDate = substr($value, 0, 10); if (!preg_match('/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/', $selectedDate)) { $selectedDate = date('Y-m-d'); @@ -147,12 +368,12 @@ function render_calendar_picker(AppContext $ctx, string $name, string $title, st } $selectedDt = DateTimeImmutable::createFromFormat('!Y-m-d', $selectedDate) ?: new DateTimeImmutable('today'); $monthStart = $selectedDt->modify('first day of this month'); - $minDate = (new DateTimeImmutable('today', new DateTimeZone($ctx->settings->timezone())))->format('Y-m-d'); + $minDate = $enforceMinDate ? (new DateTimeImmutable('today', new DateTimeZone($ctx->settings->timezone())))->format('Y-m-d') : ''; $days = render_calendar_days($name, $selectedDate, $monthStart, $minDate); $visibleMonth = $monthStart->format('Y-m'); $monthLabel = calendar_month_label($ctx, $monthStart); if ($mode === 'exact_hour') { - $timeControl = ''; + $timeControl = ''; } else { $timeControl = ''; } - return '

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

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

' . AppContext::e($monthLabel) . '
' . + return '

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

' . AppContext::e($monthLabel) . '
' . '' . render_weekday_headers($ctx) . '' . $days . '
' . '
' . AppContext::e($ctx->t('Selected')) . ': ' . AppContext::e($selectedDate) . '
' . - '

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

' . $timeControl . '
' . + '
' . $timeControl . '
' . '
'; } @@ -196,7 +417,7 @@ function render_calendar_days(string $name, string $selectedDate, DateTimeImmuta continue; } $date = $monthStart->setDate((int)$monthStart->format('Y'), (int)$monthStart->format('m'), $day)->format('Y-m-d'); - if ($date < $minDate) { + if ($minDate !== '' && $date < $minDate) { $html .= '' . $day . ''; $day++; continue; diff --git a/exec/handlers/create.php b/exec/handlers/create.php index f44f77e..bdd2e70 100644 --- a/exec/handlers/create.php +++ b/exec/handlers/create.php @@ -3,24 +3,84 @@ declare(strict_types=1); return static function (AppContext $ctx): void { $errors = []; + $formRule = null; + $modal = ''; if (Http::isPost()) { try { $ctx->requireCsrf('create'); $input = normalize_rule_post($_POST); $rule = attach_rule_scope($ctx, RuleValidator::validate($input, $ctx->settings)); - enforce_rule_backend_constraints($ctx, null, $rule); - $created = $ctx->rules->create($ctx->daUser->username(), $rule); - try { - $ctx->syncActiveBackend(); - } catch (Throwable $syncError) { - $ctx->rules->delete($ctx->daUser->username(), (string)$created['id']); - throw $syncError; + $formRule = $rule; + $conflict = !empty($rule['enabled']) ? find_active_rule_conflict($ctx, null) : null; + $conflictAction = $ctx->post('conflict_action'); + $conflictRuleId = $ctx->post('conflict_rule_id'); + + if ($conflict !== null && ($conflictAction !== 'replace' || $conflictRuleId !== (string)($conflict['id'] ?? ''))) { + $modal = render_create_conflict_modal($ctx, $rule, $conflict); + } else { + if (!empty($rule['enabled'])) { + $nativeAction = $ctx->post('native_conflict_action'); + if ($nativeAction === 'disable_native') { + delete_untracked_native_da_vacations($ctx); + } else { + $nativeEntries = list_untracked_native_da_vacations($ctx); + if ($nativeEntries !== []) { + $extra = []; + if ($conflict !== null) { + $extra = [ + 'conflict_action' => 'replace', + 'conflict_rule_id' => (string)($conflict['id'] ?? ''), + ]; + } + $modal = render_native_vacation_conflict_modal($ctx, $rule, $nativeEntries, $extra); + $ctx->render('Add autoresponder', render_rule_form($ctx, 'create', $formRule) . $modal, [], $errors); + return; + } + } + } + if ($conflict !== null) { + create_rule_replacing_active($ctx, $rule, $conflict); + } else { + create_rule_with_sync($ctx, $rule); + } + Http::redirect($ctx->url('index.html', ['status' => 'created'])); } - Http::redirect($ctx->url('index.html', ['status' => 'created'])); } catch (Throwable $e) { PluginLogger::exception($e, 'CREATE'); $errors[] = $e->getMessage(); } } - $ctx->render('Add autoresponder', render_rule_form($ctx, 'create', null), [], $errors); + $ctx->render('Add autoresponder', render_rule_form($ctx, 'create', $formRule) . $modal, [], $errors); }; + +/** @param array $rule */ +function create_rule_with_sync(AppContext $ctx, array $rule): array +{ + $created = $ctx->rules->create($ctx->daUser->username(), $rule); + try { + $ctx->syncDirectAdminVacations(); + } catch (Throwable $syncError) { + $ctx->rules->delete($ctx->daUser->username(), (string)$created['id']); + throw $syncError; + } + return $created; +} + +/** @param array $rule @param array $active */ +function create_rule_replacing_active(AppContext $ctx, array $rule, array $active): array +{ + $activeId = (string)($active['id'] ?? ''); + if ($activeId === '') { + throw new RuntimeException($ctx->t('Rule not found')); + } + $ctx->rules->update($ctx->daUser->username(), $activeId, ['enabled' => false]); + $created = $ctx->rules->create($ctx->daUser->username(), $rule); + try { + $ctx->syncDirectAdminVacations(); + } catch (Throwable $syncError) { + $ctx->rules->delete($ctx->daUser->username(), (string)$created['id']); + $ctx->rules->update($ctx->daUser->username(), $activeId, ['enabled' => true]); + throw $syncError; + } + return $created; +} diff --git a/exec/handlers/delete.php b/exec/handlers/delete.php index c2a3516..37c480f 100644 --- a/exec/handlers/delete.php +++ b/exec/handlers/delete.php @@ -11,19 +11,16 @@ return static function (AppContext $ctx): void { if (Http::isPost()) { try { $ctx->requireCsrf('delete:' . $id); + $deletedName = display_rule_name($ctx, $rule); $ctx->rules->delete($ctx->daUser->username(), $id); - $ctx->syncActiveBackend(); + $ctx->syncDirectAdminVacations(); $ctx->audit->record('delete', $ctx->daUser->username(), ['rule_id' => $id, 'subject_prefix' => (string)($rule['subject_prefix'] ?? $rule['subject'] ?? '')]); - Http::redirect($ctx->url('index.html', ['status' => 'deleted'])); + Http::redirect($ctx->url('index.html', ['status' => 'deleted', 'name' => $deletedName])); } catch (Throwable $e) { PluginLogger::exception($e, 'DELETE'); $ctx->render('Confirm deletion', '
' . AppContext::e($e->getMessage()) . '
'); return; } } - $body = '

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

' . render_preview_box($ctx, $rule) . '
' . - '

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

' . AppContext::e($ctx->t('Deleting this rule will disable the automatic reply created by this rule for covered mailboxes.')) . '
' . - '
' . $ctx->csrfField('delete:' . $id) . '' . - '
' . AppContext::e($ctx->t('Do not delete')) . '
'; - $ctx->render('Confirm deletion', $body); + $ctx->render('Confirm deletion', render_delete_confirmation_body($ctx, $rule)); }; diff --git a/exec/handlers/index.php b/exec/handlers/index.php index 99f4b14..9889784 100644 --- a/exec/handlers/index.php +++ b/exec/handlers/index.php @@ -2,38 +2,15 @@ declare(strict_types=1); return static function (AppContext $ctx): void { - $rules = $ctx->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 === 'deleted') { + $name = $ctx->query('name'); + $ok[] = $name !== '' ? $ctx->t('Global autoresponder deleted.', ['name' => $name]) : $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')) . '

'; - $ctx->render('Global autoresponders', $body, $ok); - return; - } - - $rows = ''; - foreach ($rules as $rule) { - $id = (string)$rule['id']; - $enabled = !empty($rule['enabled']); - $subjectPrefix = (string)($rule['subject_prefix'] ?? $rule['subject'] ?? ''); - $rows .= '' . AppContext::e($ctx->t($enabled ? 'Enabled' : 'Disabled')) . ''; - $rows .= '' . AppContext::e($subjectPrefix) . '
' . date('Y-m-d H:i', (int)($rule['updated_at'] ?? time())) . ''; - $rows .= '' . AppContext::e(date('Y-m-d H:i', (int)$rule['start_ts'])) . '
' . AppContext::e($ctx->t('To')) . ' ' . AppContext::e(date('Y-m-d H:i', (int)$rule['end_ts'])) . ''; - $rows .= ''; - $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')) . '

' . - '
' . $rows . '
' . AppContext::e($ctx->t('Status')) . '' . AppContext::e($ctx->t('Subject prefix')) . '' . AppContext::e($ctx->t('Sending period')) . '' . AppContext::e($ctx->t('Actions')) . '
'; - $ctx->render('Global autoresponders', $body, $ok); + render_rules_index($ctx, $ok); }; diff --git a/exec/handlers/toggle.php b/exec/handlers/toggle.php index e7865d3..033ee02 100644 --- a/exec/handlers/toggle.php +++ b/exec/handlers/toggle.php @@ -11,12 +11,41 @@ return static function (AppContext $ctx): void { throw new RuntimeException($ctx->t('Rule not found')); } $next = ['enabled' => empty($rule['enabled'])]; - enforce_rule_backend_constraints($ctx, $id, $next); + if (!empty($next['enabled'])) { + $conflict = find_active_rule_conflict($ctx, $id); + $conflictAction = $ctx->post('conflict_action'); + $conflictRuleId = $ctx->post('conflict_rule_id'); + if ($conflict !== null && ($conflictAction !== 'replace' || $conflictRuleId !== (string)($conflict['id'] ?? ''))) { + render_rules_index($ctx, [], [], render_toggle_conflict_modal($ctx, $rule, $conflict)); + return; + } + if ($conflict !== null) { + toggle_rule_replacing_active($ctx, $id, (string)($conflict['id'] ?? '')); + Http::redirect($ctx->url('index.html', ['status' => 'toggled'])); + return; + } + } $ctx->rules->update($ctx->daUser->username(), $id, $next); - $ctx->syncActiveBackend(); + $ctx->syncDirectAdminVacations(); Http::redirect($ctx->url('index.html', ['status' => 'toggled'])); } catch (Throwable $e) { PluginLogger::exception($e, 'TOGGLE'); throw $e; } }; + +function toggle_rule_replacing_active(AppContext $ctx, string $targetId, string $activeId): void +{ + if ($activeId === '') { + throw new RuntimeException($ctx->t('Rule not found')); + } + $ctx->rules->update($ctx->daUser->username(), $activeId, ['enabled' => false]); + $ctx->rules->update($ctx->daUser->username(), $targetId, ['enabled' => true]); + try { + $ctx->syncDirectAdminVacations(); + } catch (Throwable $syncError) { + $ctx->rules->update($ctx->daUser->username(), $targetId, ['enabled' => false]); + $ctx->rules->update($ctx->daUser->username(), $activeId, ['enabled' => true]); + throw $syncError; + } +} diff --git a/exec/handlers/update.php b/exec/handlers/update.php index ae303e1..36c9214 100644 --- a/exec/handlers/update.php +++ b/exec/handlers/update.php @@ -13,10 +13,10 @@ return static function (AppContext $ctx): void { try { $ctx->requireCsrf('update:' . $id); $input = normalize_rule_post($_POST); - $patch = attach_rule_scope($ctx, RuleValidator::validate($input, $ctx->settings)); - enforce_rule_backend_constraints($ctx, $id, $patch); + $patch = attach_rule_scope($ctx, RuleValidator::validate($input, $ctx->settings, true)); + enforce_rule_directadmin_constraints($ctx, $id, $patch); $ctx->rules->update($ctx->daUser->username(), $id, $patch); - $ctx->syncActiveBackend(); + $ctx->syncDirectAdminVacations(); Http::redirect($ctx->url('index.html', ['status' => 'updated'])); } catch (Throwable $e) { PluginLogger::exception($e, 'UPDATE'); diff --git a/exec/lib/AppContext.php b/exec/lib/AppContext.php index 425ff86..1a3e94c 100644 --- a/exec/lib/AppContext.php +++ b/exec/lib/AppContext.php @@ -33,9 +33,10 @@ final class AppContext return $url; } - public function t(string $key): string + /** @param array $vars */ + public function t(string $key, array $vars = []): string { - return $this->lang->t($key); + return $this->lang->t($key, $vars); } public function post(string $key, string $default = ''): string @@ -70,11 +71,8 @@ final class AppContext } } - public function syncActiveBackend(): void + public function syncDirectAdminVacations(): void { - if ($this->settings->backendMode() !== 'da') { - return; - } $service = new DirectAdminVacationSyncService( $this->rules, new DirectAdminSyncRepository(), @@ -98,7 +96,7 @@ final class AppContext 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 '

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

'; echo ''; echo '
' . $alerts . $body . '
'; @@ -113,6 +111,12 @@ final class AppContext private function styles(): string { $skin = $this->daUser->skin(); + if ($skin === 'evolution') { + $base = PLUGIN_ROOT . '/user/enhanced.css'; + $override = PLUGIN_ROOT . '/user/evolution.css'; + return (is_file($base) ? (string)file_get_contents($base) : '') . "\n" . + (is_file($override) ? (string)file_get_contents($override) : ''); + } $path = PLUGIN_ROOT . '/user/' . $skin . '.css'; if (!is_file($path)) { $path = PLUGIN_ROOT . '/user/enhanced.css'; @@ -122,14 +126,39 @@ final class AppContext private function scripts(): string { + $skin = json_encode($this->daUser->skin(), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT); $selectedLabel = json_encode($this->t('Selected'), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT); $monthNames = json_encode($this->lang->code() === 'pl' ? ['Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec', 'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień'] : ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT); return <<backendMode(); - $current = self::currentBackend($statePath); - if ($current === $desired) { - return; - } - - if (!is_file($backendScript) || !is_executable($backendScript)) { - self::log($errorLog, 'Backend script not executable: ' . $backendScript); - return; - } - - $process = proc_open([$backendScript, $desired], [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $pipes); - if (!is_resource($process)) { - self::log($errorLog, 'Could not start backend script.'); - return; - } - fclose($pipes[0]); - stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - $code = proc_close($process); - if ($code !== 0) { - self::log($errorLog, 'Backend script failed with code ' . $code . ': ' . trim($stderr)); - } - } - - private static function currentBackend(string $statePath): string - { - if (!is_file($statePath)) { - return ''; - } - $data = json_decode((string)file_get_contents($statePath), true); - return is_array($data) ? (string)($data['active_backend'] ?? '') : ''; - } - - private static function log(string $errorLog, string $message): void - { - if ($errorLog === '') { - return; - } - @error_log('[' . date('c') . '] BACKEND_SYNC ' . $message . "\n", 3, $errorLog); - } -} diff --git a/exec/lib/DirectAdminUser.php b/exec/lib/DirectAdminUser.php index 4eaa97e..8108b37 100644 --- a/exec/lib/DirectAdminUser.php +++ b/exec/lib/DirectAdminUser.php @@ -14,22 +14,23 @@ final class DirectAdminUser throw new InvalidArgumentException('Invalid username'); } $this->username = $username; - $this->skin = $skin !== '' ? $skin : 'enhanced'; + $this->skin = self::normalizeSkin($skin); $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']; - } + $username = self::environmentValue(['USERNAME', 'DA_USER', 'USER']); if ($username === '') { throw new RuntimeException('Cannot determine DirectAdmin user'); } - $skin = getenv('SKIN') ?: (string)($_SERVER['SKIN'] ?? 'enhanced'); - $lang = self::readLanguage($username) ?: $settings->defaultLanguage(); + $userConf = self::readUserConf($username); + $skin = self::environmentValue(['SKIN', 'SESSION_SKIN', 'SESSION_SELECTED_SKIN', 'DA_SKIN', 'USER_SKIN', 'THEME']); + if ($skin === '') { + $skin = (string)($userConf['skin'] ?? 'enhanced'); + } + $lang = self::languageFromUserConf($userConf) ?: $settings->defaultLanguage(); return new self($username, $skin, $lang); } @@ -58,21 +59,65 @@ final class DirectAdminUser return $settings->defaultEnable(); } - private static function readLanguage(string $username): string + private static function normalizeSkin(string $skin): string { - $path = '/usr/local/directadmin/data/users/' . $username . '/user.conf'; - if (!is_file($path)) { - return ''; + $normalizedSkin = strtolower(trim($skin)); + if ($normalizedSkin === '') { + return 'enhanced'; } - $lines = file($path, FILE_IGNORE_NEW_LINES); - if ($lines === false) { - return ''; + if (str_contains($normalizedSkin, 'evolution')) { + return 'evolution'; } - foreach ($lines as $line) { - if (str_starts_with($line, 'language=')) { - return strtolower(trim(substr($line, 9))); + return $normalizedSkin; + } + + /** + * @param list $keys + */ + private static function environmentValue(array $keys): string + { + foreach ($keys as $key) { + $value = getenv($key); + if ($value !== false && trim((string)$value) !== '') { + return trim((string)$value); + } + if (isset($_SERVER[$key]) && trim((string)$_SERVER[$key]) !== '') { + return trim((string)$_SERVER[$key]); } } return ''; } + + /** + * @return array + */ + private static function readUserConf(string $username): array + { + $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 []; + } + $data = []; + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) { + continue; + } + [$key, $value] = explode('=', $line, 2); + $data[trim($key)] = trim($value); + } + return $data; + } + + /** + * @param array $userConf + */ + private static function languageFromUserConf(array $userConf): string + { + return strtolower(trim((string)($userConf['language'] ?? $userConf['lang'] ?? ''))); + } } diff --git a/exec/lib/DirectAdminVacationApi.php b/exec/lib/DirectAdminVacationApi.php index 2d582b4..4244f77 100644 --- a/exec/lib/DirectAdminVacationApi.php +++ b/exec/lib/DirectAdminVacationApi.php @@ -114,7 +114,7 @@ class DirectAdminVacationApi return [$m[1], $m[2]]; } if (!preg_match('/^([0-9]{4}-[0-9]{2}-[0-9]{2})\|(morning|afternoon|evening)$/', $value, $m)) { - throw new InvalidArgumentException('DirectAdmin API backend requires exact date-time values'); + throw new InvalidArgumentException('DirectAdmin vacation sync requires exact date-time values'); } $time = ['morning' => '08:00', 'afternoon' => '13:00', 'evening' => $start ? '18:00' : '23:59'][$m[2]]; return [$m[1], $time]; @@ -147,9 +147,6 @@ class DirectAdminVacationApi /** @param array $rule */ private function replyOnceTime(array $rule): string { - if (!empty($rule['reply_every_message'])) { - return '1m'; - } return match ((int)($rule['repeat_minutes'] ?? 1440)) { 1 => '1m', 10 => '10m', diff --git a/exec/lib/DirectAdminVacationSyncService.php b/exec/lib/DirectAdminVacationSyncService.php index e5c6b0c..52be0b7 100644 --- a/exec/lib/DirectAdminVacationSyncService.php +++ b/exec/lib/DirectAdminVacationSyncService.php @@ -28,6 +28,40 @@ final class DirectAdminVacationSyncService } } + /** @return array */ + public function untrackedNativeVacations(string $username): array + { + $tracked = $this->trackedEmails($username); + $out = []; + foreach ($this->mailboxes->domainsForUser($username) as $domain) { + foreach ($this->api->listVacations($username, $domain) as $local) { + $email = strtolower($local . '@' . $domain); + if (isset($tracked[$email])) { + continue; + } + $out[] = [ + 'domain' => $domain, + 'mailbox' => $local, + 'email' => $email, + ]; + } + } + usort($out, static fn (array $a, array $b): int => strcmp($a['email'], $b['email'])); + return $out; + } + + /** @param array> $entries */ + public function deleteNativeVacations(string $username, array $entries): void + { + foreach ($entries as $entry) { + $email = strtolower((string)($entry['email'] ?? '')); + if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + continue; + } + $this->api->deleteVacation($username, $email); + } + } + /** @return array> */ private function rulesById(string $username): array { @@ -47,12 +81,20 @@ final class DirectAdminVacationSyncService $existing = $this->indexedEntries($this->sync->entriesForRule($username, $ruleId)); $targets = $this->targetMailboxes($username, $rule); $next = []; + $conflicts = []; foreach ($targets as $mailbox) { $email = strtolower((string)$mailbox['email']); $isTracked = isset($existing[$email]); if (!$isTracked && $this->api->vacationExists($username, $email)) { - throw new RuntimeException('Untracked DirectAdmin vacation already exists for ' . $email); + $conflicts[] = $email; } + } + if ($conflicts !== []) { + throw new RuntimeException('Untracked DirectAdmin vacation already exists for ' . implode(', ', $conflicts)); + } + foreach ($targets as $mailbox) { + $email = strtolower((string)$mailbox['email']); + $isTracked = isset($existing[$email]); $this->api->saveVacation($username, $email, $rule, $isTracked); $next[] = [ 'rule_id' => $ruleId, @@ -95,6 +137,21 @@ final class DirectAdminVacationSyncService return $out; } + /** @return array */ + private function trackedEmails(string $username): array + { + $tracked = []; + foreach ($this->sync->allRuleEntries($username) as $entries) { + foreach ($entries as $entry) { + $email = strtolower((string)($entry['email'] ?? $entry['api_key'] ?? '')); + if ($email !== '') { + $tracked[$email] = true; + } + } + } + return $tracked; + } + /** @param array $rule @return array */ private function targetMailboxes(string $username, array $rule): array { diff --git a/exec/lib/MailSender.php b/exec/lib/MailSender.php deleted file mode 100644 index 8ea5030..0000000 --- a/exec/lib/MailSender.php +++ /dev/null @@ -1,43 +0,0 @@ -domainOwners() as $domain => $owner) { diff --git a/exec/lib/MessageClassifier.php b/exec/lib/MessageClassifier.php deleted file mode 100644 index 5ce6cd7..0000000 --- a/exec/lib/MessageClassifier.php +++ /dev/null @@ -1,31 +0,0 @@ - $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/PluginLogger.php b/exec/lib/PluginLogger.php index fa5ad07..22bf8ba 100644 --- a/exec/lib/PluginLogger.php +++ b/exec/lib/PluginLogger.php @@ -8,6 +8,7 @@ final class PluginLogger public static function init(string $path): void { self::$path = $path; + self::ensureLogFile(); @ini_set('log_errors', '1'); @ini_set('error_log', $path); @@ -67,4 +68,21 @@ final class PluginLogger default => 'E_' . $severity, }; } + + private static function ensureLogFile(): void + { + if (self::$path === '') { + return; + } + $dir = dirname(self::$path); + if (!is_dir($dir) || !is_writable($dir)) { + return; + } + if (!is_file(self::$path)) { + @file_put_contents(self::$path, ''); + } + if (is_file(self::$path)) { + @chmod(self::$path, 0600); + } + } } diff --git a/exec/lib/RepeatState.php b/exec/lib/RepeatState.php deleted file mode 100644 index cc9fcfd..0000000 --- a/exec/lib/RepeatState.php +++ /dev/null @@ -1,48 +0,0 @@ -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/RuleValidator.php b/exec/lib/RuleValidator.php index 53c9fd3..52ed46e 100644 --- a/exec/lib/RuleValidator.php +++ b/exec/lib/RuleValidator.php @@ -29,8 +29,16 @@ final class RuleValidator ]; /** @param array $input @return array */ - public static function validate(array $input, Settings $settings): array + public static function validate(array $input, Settings $settings, bool $allowPastSchedule = false): array { + $name = trim(str_replace(["\r\n", "\r", "\0"], ["\n", "\n", ''], (string)($input['name'] ?? ''))); + if (str_contains($name, "\n")) { + throw new InvalidArgumentException('Nazwa autorespondera nie może zawierać nowych linii.'); + } + if (strlen($name) > 255) { + throw new InvalidArgumentException('Nazwa autorespondera jest za długa.'); + } + $subjectPrefix = trim((string)($input['subject_prefix'] ?? $input['subject'] ?? '')); if (str_contains($subjectPrefix, "\r") || str_contains($subjectPrefix, "\n")) { throw new InvalidArgumentException('Przedrostek tematu nie może zawierać nowych linii.'); @@ -52,15 +60,22 @@ final class RuleValidator $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 (substr($startValue, 0, 10) < (new DateTimeImmutable('today', $tz))->format('Y-m-d')) { - throw new InvalidArgumentException('Data rozpoczęcia nie może być wcześniejsza niż aktualna data.'); + if (!$allowPastSchedule) { + $today = (new DateTimeImmutable('today', $tz))->format('Y-m-d'); + if (substr($startValue, 0, 10) < $today) { + throw new InvalidArgumentException('Data rozpoczęcia nie może być wcześniejsza niż aktualna data.'); + } + if (substr($endValue, 0, 10) < $today) { + throw new InvalidArgumentException('Data zakończenia nie może być wcześniejsza niż aktualna data.'); + } } if ($endTs <= $startTs) { throw new InvalidArgumentException('Data zakończenia musi być późniejsza niż data rozpoczęcia.'); } - [$replyEveryMessage, $repeatMinutes] = self::parseReplyFrequency((string)($input['reply_frequency'] ?? '1440'), $settings); + $repeatMinutes = self::parseReplyFrequency((string)($input['reply_frequency'] ?? '1440')); return [ + 'name' => $name, 'subject_prefix' => $subjectPrefix, 'body' => $body, 'schedule_mode' => $mode, @@ -70,24 +85,16 @@ final class RuleValidator 'start_ts' => $startTs, 'end_ts' => $endTs, 'enabled' => self::truthy($input['enabled'] ?? false), - 'reply_every_message' => $replyEveryMessage, 'repeat_minutes' => $repeatMinutes, 'dynamic_scope' => $settings->dynamicMailboxScope(), ]; } - /** @return array{0:bool,1:int} */ - private static function parseReplyFrequency(string $value, Settings $settings): array + private static function parseReplyFrequency(string $value): int { $value = trim(strtolower($value)); - if ($value === 'every') { - if ($settings->backendMode() === 'da') { - throw new InvalidArgumentException('Częstość odpowiedzi jest nieprawidłowa.'); - } - return [true, 0]; - } if (isset(self::REPLY_FREQUENCIES[$value])) { - return [false, self::REPLY_FREQUENCIES[$value]]; + return self::REPLY_FREQUENCIES[$value]; } throw new InvalidArgumentException('Częstość odpowiedzi jest nieprawidłowa.'); } diff --git a/exec/lib/Settings.php b/exec/lib/Settings.php index 25465f6..07837a6 100644 --- a/exec/lib/Settings.php +++ b/exec/lib/Settings.php @@ -6,7 +6,6 @@ final class Settings public const BASE_DIR = '/usr/local/hitme_plugins/global-autoresponders'; public const CONFIG_DIR = self::BASE_DIR . '/config'; public const DATA_DIR = self::BASE_DIR . '/data'; - public const STATE_DIR = self::BASE_DIR . '/state'; public const LOG_DIR = self::BASE_DIR . '/logs'; public const AUDIT_LOG = self::LOG_DIR . '/audit.log'; public const EXTERNAL_SETTINGS = self::CONFIG_DIR . '/plugin-settings.conf'; @@ -51,7 +50,6 @@ final class Settings 'DEFAULT_PLUGIN_LANGUAGE' => 'pl', 'DEFAULT_ENABLE' => 'true', 'GLOBAL_AUTORESPONDER_TIMEZONE' => 'Europe/Warsaw', - 'GLOBAL_AUTORESPONDER_BACKEND' => 'da', 'GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE' => 'true', 'GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES' => '255', 'GLOBAL_AUTORESPONDER_MAX_BODY_BYTES' => '20000', @@ -91,11 +89,6 @@ final class Settings return $this->getString('GLOBAL_AUTORESPONDER_TIMEZONE', 'Europe/Warsaw'); } - public function backendMode(): string - { - return $this->getString('GLOBAL_AUTORESPONDER_BACKEND', 'da'); - } - public function scheduleMode(): string { return 'exact_hour'; @@ -141,9 +134,6 @@ final class Settings } catch (Throwable) { throw new InvalidArgumentException('Invalid timezone: ' . $this->timezone()); } - if (!in_array($this->backendMode(), ['da', 'exim'], true)) { - throw new InvalidArgumentException('Invalid backend mode: ' . $this->backendMode()); - } if ($this->maxSubjectBytes() < 1 || $this->maxBodyBytes() < 1) { throw new InvalidArgumentException('Invalid size limits'); } diff --git a/images/user_icon.svg b/images/user_icon.svg new file mode 100644 index 0000000..14578bd --- /dev/null +++ b/images/user_icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/lang/en.php b/lang/en.php index 52f9fcd..1344b66 100644 --- a/lang/en.php +++ b/lang/en.php @@ -3,20 +3,21 @@ declare(strict_types=1); return [ 'Global autoresponders' => 'Global autoresponders', - 'Manage automatic replies for account mailboxes' => 'Manage automatic replies for account mailboxes', 'Dashboard' => 'Dashboard', 'AUTORESPONDERS' => 'Global autoresponder list', 'ADD AUTORESPONDER' => 'Add global autoresponder', 'Add autoresponder' => 'Add autoresponder', 'Edit autoresponder' => 'Edit autoresponder', 'Preview autoresponder' => 'Preview autoresponder', + 'Autoresponder name' => 'Autoresponder name', + 'Autoresponder name help' => 'This name is used only for description and is not used by the autoresponder itself.', + 'Unnamed autoresponder' => 'Unnamed autoresponder', 'Subject' => 'Subject', 'Subject prefix' => 'Subject prefix', 'Original subject suffix' => ': original subject', 'Message body' => 'Message body', 'Response content' => 'Response content', 'Response frequency' => 'Response frequency', - 'Send automatic reply for every message' => 'Send automatic reply for every message', '1 minute' => '1 minute', '10 minutes' => '10 minutes', '30 minutes' => '30 minutes', @@ -61,14 +62,19 @@ return [ 'Confirm deletion' => 'Confirm deletion', 'Delete autoresponder' => 'Delete autoresponder', 'Do not delete' => 'Do not delete', + 'Delete confirmation question' => 'Are you sure you want to delete global autoresponder {name} created on {created}?', + 'Yes' => 'Yes', + 'No' => 'No', 'No global autoresponders' => 'No global autoresponders', 'Rule created.' => 'Rule created.', 'Rule updated.' => 'Rule updated.', 'Rule deleted.' => 'Rule deleted.', + 'Global autoresponder deleted.' => 'Global autoresponder {name} has been deleted.', 'Rule status changed.' => 'Rule status changed.', - 'All valid POP/IMAP mailboxes in this account' => 'All valid POP/IMAP mailboxes in this account', 'Rule not found' => 'Rule not found', 'Sending period' => 'Sending period', + 'Created' => 'Created', + 'Updated' => 'Updated', 'Scope' => 'Scope', 'Actions' => 'Actions', 'Dynamic' => 'Dynamic', @@ -84,6 +90,18 @@ return [ 'Fri' => 'Fri', 'Sat' => 'Sat', 'Sun' => 'Sun', - 'DirectAdmin backend supports only one enabled global rule.' => 'DirectAdmin backend supports only one enabled global rule.', + 'Only one active autoresponder' => 'Only one active autoresponder', + 'Active autoresponder exists for user.' => 'A global autoresponder named {name}, created on {created}, already exists for user {user}.', + 'Only one global autoresponder can be active per user.' => 'Only one global autoresponder can be active in one user account.', + 'Enable new and disable previous' => 'Enable autoresponder ({new}) and disable previous ({old})', + 'Add as disabled' => 'Add autoresponder ({name}) as disabled', + 'Enable autoresponder' => 'Enable autoresponder', + 'Native autoresponders already exist' => 'Native autoresponders already exist', + 'Native autoresponders exist for user.' => 'User {user} already has native autoresponders / vacation messages created with DirectAdmin autoresponder / vacation message options.', + 'Email address' => 'Email address', + 'Domain' => 'Domain', + 'Mailbox' => 'Mailbox', + 'Native autoresponders removal warning' => 'Existing autoresponders / vacation messages will be removed if you continue.', + 'Remove existing autoresponders' => 'REMOVE EXISTING AUTORESPONDERS', 'Deleting this rule will disable the automatic reply created by this rule for covered mailboxes.' => 'Deleting this rule will disable the automatic reply created by this rule for covered mailboxes.', ]; diff --git a/lang/pl.php b/lang/pl.php index 7c4a9a5..96c6cf3 100644 --- a/lang/pl.php +++ b/lang/pl.php @@ -3,20 +3,21 @@ declare(strict_types=1); return [ 'Global autoresponders' => 'Globalne autorespondery', - 'Manage automatic replies for account mailboxes' => 'Zarządzanie odpowiedziami dla skrzynek pocztowych konta', 'Dashboard' => 'Pulpit', 'AUTORESPONDERS' => 'Lista globalnych autoresponderów', 'ADD AUTORESPONDER' => 'Dodaj Globalny autoresponder', 'Add autoresponder' => 'Dodaj autoresponder', 'Edit autoresponder' => 'Edytuj autoresponder', 'Preview autoresponder' => 'Podgląd autorespondera', + 'Autoresponder name' => 'Nazwa autorespondera', + 'Autoresponder name help' => 'Ta nazwa jest wykorzystywana tylko w celach opisowych i nie jest używana do pracy autorespondera.', + 'Unnamed autoresponder' => 'Autoresponder bez nazwy', 'Subject' => 'Temat', 'Subject prefix' => 'Przedrostek tematu', 'Original subject suffix' => ': pierwotny temat', 'Message body' => 'Treść wiadomości', 'Response content' => 'Treść odpowiedzi', 'Response frequency' => 'Częstość odpowiedzi', - 'Send automatic reply for every message' => 'Wysyłaj automatyczną odpowiedź dla każdej wiadomości', '1 minute' => '1 minuta', '10 minutes' => '10 minut', '30 minutes' => '30 minut', @@ -61,14 +62,19 @@ return [ 'Confirm deletion' => 'Potwierdź usunięcie', 'Delete autoresponder' => 'Usuń autoresponder', 'Do not delete' => 'Nie usuwaj', + 'Delete confirmation question' => 'Czy napewno chcesz usunąć globalny autoresponder {name} utworzony {created}?', + 'Yes' => 'Tak', + 'No' => 'Nie', '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.', + 'Global autoresponder deleted.' => 'Globalny autoresponder {name} został usunięty.', 'Rule status changed.' => 'Status reguły został zmieniony.', - 'All valid POP/IMAP mailboxes in this account' => 'Wszystkie prawidłowe skrzynki POP/IMAP konta', 'Rule not found' => 'Nie znaleziono reguły', 'Sending period' => 'Okres wysyłki', + 'Created' => 'Utworzono', + 'Updated' => 'Zaktualizowano', 'Scope' => 'Zakres', 'Actions' => 'Akcje', 'Dynamic' => 'Dynamiczny', @@ -84,6 +90,18 @@ return [ 'Fri' => 'Pt', 'Sat' => 'Sb', 'Sun' => 'Nd', - 'DirectAdmin backend supports only one enabled global rule.' => 'Backend DirectAdmin obsługuje tylko jedną aktywną regułę globalną.', + 'Only one active autoresponder' => 'Tylko jeden aktywny autoresponder', + 'Active autoresponder exists for user.' => 'Dla użytkownika {user} istnieje już globalny autoresponder o nazwie {name}, utworzony dnia {created}.', + 'Only one global autoresponder can be active per user.' => 'W ramach użytkownika może być aktywny tylko jeden globalny autoresponder.', + 'Enable new and disable previous' => 'Włącz autoresponder ({new}) i wyłącz poprzedni ({old})', + 'Add as disabled' => 'Dodaj autoresponder ({name}) jako wyłączony', + 'Enable autoresponder' => 'Włącz autoresponder', + 'Native autoresponders already exist' => 'Istnieją natywne autorespondery', + 'Native autoresponders exist for user.' => 'użytkownik {user} ma już utworzone natywne autorespondery / wiadomości przy użyciu opcji autorespondery / wiadomość urlopowa w DirectAdminie.', + 'Email address' => 'Adres e-mail', + 'Domain' => 'Domena', + 'Mailbox' => 'Skrzynka', + 'Native autoresponders removal warning' => 'Istniejące autorespondery / wiadomości urlopowe zostaną usunięte w przypadku kontynuacji.', + 'Remove existing autoresponders' => 'USUŃ ISTNIEJĄCE AUTORESPONDERY', 'Deleting this rule will disable the automatic reply created by this rule for covered mailboxes.' => 'Usunięcie reguły wyłączy odpowiedź automatyczną utworzoną przez tę regułę dla objętych skrzynek.', ]; diff --git a/plugin-settings.conf b/plugin-settings.conf index baa25ff..a7d92d1 100644 --- a/plugin-settings.conf +++ b/plugin-settings.conf @@ -8,11 +8,6 @@ DEFAULT_ENABLE=true # Strefa czasu używana do interpretacji i wyświetlania dat rozpoczęcia oraz zakończenia. GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw -# Tryb pracy pluginu. -# da = użycie natywnych autoresponderów DirectAdmin. -# exim = własna obsługa z możliwością podania dokładnej godziny i minuty. -GLOBAL_AUTORESPONDER_BACKEND=da - # 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 diff --git a/plugin.conf b/plugin.conf index ea08e76..9186864 100644 --- a/plugin.conf +++ b/plugin.conf @@ -2,7 +2,7 @@ name=global-autoresponder id=global-autoresponder type=user author=HITME.PL -version=1.2.1 +version=1.4.11 active=no installed=no user_run_as=root diff --git a/scripts/backend.sh b/scripts/backend.sh deleted file mode 100755 index 730887b..0000000 --- a/scripts/backend.sh +++ /dev/null @@ -1,186 +0,0 @@ -#!/bin/sh -set -eu - -BASE_DIR="/usr/local/hitme_plugins/global-autoresponders" -STATE_FILE="$BASE_DIR/config/backend-state.json" -PLUGIN_DIR="/usr/local/directadmin/plugins/global-autoresponder" -CUSTOM_DIR="/usr/local/directadmin/custombuild/custom/exim" -CUSTOM_CONF="$CUSTOM_DIR/exim.conf" -SOURCE_CONF="/usr/local/directadmin/custombuild/configure/exim/exim.conf" -ROUTER_SNIPPET="$PLUGIN_DIR/scripts/exim/global_autoresponder_router.conf" -TRANSPORT_SNIPPET="$PLUGIN_DIR/scripts/exim/global_autoresponder_transport.conf" -ROLLBACK_ACTIVE=0 -ROLLBACK_CREATED=0 -ROLLBACK_REBUILD=0 -ROLLBACK_FILE= - -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" - -write_state() { - backend="$1" - printf '{"active_backend":"%s","migration_status":"complete"}\n' "$backend" > "$STATE_FILE" - chmod 600 "$STATE_FILE" -} - -remove_marked_block() { - marker="$1" - file="$2" - tmp="${file}.global-autoresponder.$$" - awk -v marker="$marker" ' - $0 == "# BEGIN global-autoresponder " marker { skip=1; next } - $0 == "# END global-autoresponder " marker { skip=0; next } - skip != 1 { print } - ' "$file" > "$tmp" - mv "$tmp" "$file" -} - -insert_after_section() { - section="$1" - snippet="$2" - file="$3" - tmp="${file}.global-autoresponder.$$" - if ! awk -v section="$section" -v snippet="$snippet" ' - BEGIN { - while ((getline line < snippet) > 0) { - block = block line "\n" - } - close(snippet) - } - { print } - $0 == section && inserted != 1 { - printf "\n%s\n", block - inserted = 1 - } - END { - if (inserted != 1) { - exit 3 - } - } - ' "$file" > "$tmp"; then - rm -f "$tmp" - echo "Cannot install exim backend: section $section not found in custom exim.conf." >&2 - exit 1 - fi - mv "$tmp" "$file" -} - -backup_custom_conf() { - mkdir -p "$CUSTOM_DIR" - ROLLBACK_FILE="${CUSTOM_CONF}.global-autoresponder.backup.$$" - if [ -f "$CUSTOM_CONF" ]; then - cp "$CUSTOM_CONF" "$ROLLBACK_FILE" - ROLLBACK_CREATED=0 - else - : > "$ROLLBACK_FILE" - ROLLBACK_CREATED=1 - fi - ROLLBACK_ACTIVE=1 -} - -restore_custom_conf() { - if [ "$ROLLBACK_ACTIVE" != "1" ]; then - return - fi - set +e - if [ "$ROLLBACK_CREATED" = "1" ]; then - rm -f "$CUSTOM_CONF" - else - cp "$ROLLBACK_FILE" "$CUSTOM_CONF" - fi - rm -f "$ROLLBACK_FILE" - if [ "$ROLLBACK_REBUILD" = "1" ]; then - rebuild_and_reload_exim >/dev/null 2>&1 - fi - ROLLBACK_ACTIVE=0 -} - -commit_custom_conf() { - rm -f "$ROLLBACK_FILE" - ROLLBACK_ACTIVE=0 - ROLLBACK_CREATED=0 - ROLLBACK_REBUILD=0 -} - -trap 'restore_custom_conf' EXIT -trap 'restore_custom_conf; exit 1' HUP INT TERM - -validate_exim_conf() { - if command -v exim >/dev/null 2>&1; then - exim -C "$CUSTOM_CONF" -bV >/dev/null - elif command -v exim4 >/dev/null 2>&1; then - exim4 -C "$CUSTOM_CONF" -bV >/dev/null - else - echo "Cannot validate exim backend: exim binary not found." >&2 - exit 1 - fi -} - -rebuild_and_reload_exim() { - if command -v da >/dev/null 2>&1; then - da build exim_conf - elif [ -x /usr/local/directadmin/custombuild/build ]; then - /usr/local/directadmin/custombuild/build exim_conf - else - echo "Cannot rebuild exim configuration: da/build command not found." >&2 - exit 1 - fi - if command -v systemctl >/dev/null 2>&1; then - systemctl reload exim 2>/dev/null || systemctl restart exim - else - service exim reload 2>/dev/null || service exim restart - fi -} - -if [ "$1" = "da" ]; then - if [ -f "$CUSTOM_CONF" ]; then - backup_custom_conf - remove_marked_block "router" "$CUSTOM_CONF" - remove_marked_block "transport" "$CUSTOM_CONF" - if grep -Fq "global_autoresponder_" "$CUSTOM_CONF"; then - echo "Cannot switch to da: plugin Exim markers were not removed cleanly." >&2 - exit 1 - fi - validate_exim_conf - ROLLBACK_REBUILD=1 - rebuild_and_reload_exim - commit_custom_conf - fi - write_state "da" - echo "Backend set to da" - exit 0 -fi - -if [ ! -d "$(dirname "$SOURCE_CONF")" ]; then - echo "Cannot enable exim backend: DirectAdmin CustomBuild exim source config not found." >&2 - exit 1 -fi -if [ ! -f "$ROUTER_SNIPPET" ] || [ ! -f "$TRANSPORT_SNIPPET" ]; then - echo "Cannot enable exim backend: plugin Exim snippets are missing." >&2 - exit 1 -fi - -backup_custom_conf -if [ ! -f "$CUSTOM_CONF" ]; then - cp "$SOURCE_CONF" "$CUSTOM_CONF" -fi - -remove_marked_block "router" "$CUSTOM_CONF" -remove_marked_block "transport" "$CUSTOM_CONF" -insert_after_section "begin routers" "$ROUTER_SNIPPET" "$CUSTOM_CONF" -insert_after_section "begin transports" "$TRANSPORT_SNIPPET" "$CUSTOM_CONF" -validate_exim_conf -ROLLBACK_REBUILD=1 -rebuild_and_reload_exim -commit_custom_conf -write_state "exim" -echo "Backend set to exim" diff --git a/scripts/exim/global_autoresponder_router.conf b/scripts/exim/global_autoresponder_router.conf deleted file mode 100644 index 1bb7347..0000000 --- a/scripts/exim/global_autoresponder_router.conf +++ /dev/null @@ -1,11 +0,0 @@ -# BEGIN global-autoresponder router -# Runs an unseen delivery to the plugin worker for local recipients. Normal mailbox -# delivery continues because unseen is enabled and the worker suppresses ineligible mail. -global_autoresponder_router: - driver = accept - domains = +local_domains - condition = ${if exists{/usr/local/directadmin/plugins/global-autoresponder/scripts/global_autoresponder_worker.php}{yes}{no}} - transport = global_autoresponder_transport - unseen - no_verify -# END global-autoresponder router diff --git a/scripts/exim/global_autoresponder_transport.conf b/scripts/exim/global_autoresponder_transport.conf deleted file mode 100644 index 9cc3144..0000000 --- a/scripts/exim/global_autoresponder_transport.conf +++ /dev/null @@ -1,13 +0,0 @@ -# BEGIN global-autoresponder transport -# The worker receives the original message on stdin and Exim-provided envelope -# variables such as SENDER, RECIPIENT, LOCAL_PART, and DOMAIN in the environment. -global_autoresponder_transport: - driver = pipe - command = /usr/local/directadmin/plugins/global-autoresponder/scripts/global_autoresponder_worker.php - user = root - group = root - return_fail_output = false - log_output = true - message_prefix = - message_suffix = -# END global-autoresponder transport diff --git a/scripts/global_autoresponder_worker.php b/scripts/global_autoresponder_worker.php deleted file mode 100755 index 04ae9d9..0000000 --- a/scripts/global_autoresponder_worker.php +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/local/bin/php - $env */ - public function handle(array $env, string $message): int - { - if ($this->settings->backendMode() !== 'exim') { - return 0; - } - $sender = self::firstEnv($env, ['SENDER_ADDRESS', 'sender_address', 'SENDER', 'sender']); - $recipient = self::recipientFromEnv($env); - if ($sender === '' || $recipient === '' || !self::isEmail($sender) || !self::isEmail($recipient)) { - return 0; - } - $headers = self::parseHeaders($message); - if (MessageClassifier::shouldSuppress($headers, $sender)) { - return 0; - } - $resolved = $this->mailboxes->resolveRecipient($recipient); - if ($resolved === null) { - return 0; - } - $sent = 0; - $owner = $resolved['owner']; - $recipient = $resolved['email']; - foreach ($this->rules->all($owner) as $rule) { - if (!$this->isActiveRule($rule)) { - continue; - } - if (!$this->ruleIncludesRecipient($rule, $recipient)) { - continue; - } - $ruleId = (string)($rule['id'] ?? ''); - if ($ruleId === '') { - continue; - } - $repeatMinutes = !empty($rule['reply_every_message']) ? 0 : max(0, (int)($rule['repeat_minutes'] ?? 1440)); - if (!$this->repeat->shouldSend($owner, $ruleId, $recipient, $sender, $repeatMinutes)) { - continue; - } - $replySubject = self::replySubject( - (string)($rule['subject_prefix'] ?? $rule['subject'] ?? ''), - self::headerValue($headers, 'subject') - ); - $ok = (bool)call_user_func( - $this->send, - $recipient, - strtolower($sender), - $replySubject, - (string)$rule['body'] - ); - if ($ok) { - $this->repeat->record($owner, $ruleId, $recipient, $sender); - $sent++; - } - } - return $sent; - } - - /** @param array $rule */ - private function isActiveRule(array $rule): bool - { - if (empty($rule['enabled'])) { - return false; - } - $now = $this->now ?? time(); - return (int)($rule['start_ts'] ?? 0) <= $now && (int)($rule['end_ts'] ?? 0) >= $now; - } - - /** @param array $rule */ - private function ruleIncludesRecipient(array $rule, string $recipient): bool - { - if (!array_key_exists('dynamic_scope', $rule) || !empty($rule['dynamic_scope'])) { - return true; - } - $snapshot = $rule['mailbox_snapshot'] ?? []; - if (!is_array($snapshot)) { - return false; - } - foreach ($snapshot as $mailbox) { - if (is_array($mailbox) && strtolower((string)($mailbox['email'] ?? '')) === strtolower($recipient)) { - return true; - } - } - return false; - } - - /** @param array $env @param string[] $keys */ - private static function firstEnv(array $env, array $keys): string - { - foreach ($keys as $key) { - $value = trim((string)($env[$key] ?? '')); - if ($value !== '') { - return strtolower($value); - } - } - return ''; - } - - /** @param array $env */ - private static function recipientFromEnv(array $env): string - { - $direct = self::firstEnv($env, ['LOCAL_PART_DOMAIN', 'RECIPIENT', 'ORIGINAL_RECIPIENT', 'local_part_domain']); - if ($direct !== '') { - return $direct; - } - $local = self::firstEnv($env, ['LOCAL_PART', 'local_part']); - $domain = self::firstEnv($env, ['DOMAIN', 'domain']); - return ($local !== '' && $domain !== '') ? $local . '@' . $domain : ''; - } - - private static function isEmail(string $value): bool - { - return (bool)preg_match('/^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+$/', $value); - } - - /** @return array */ - public static function parseHeaders(string $message): array - { - $headers = []; - $current = ''; - foreach (preg_split('/\r\n|\n|\r/', $message) ?: [] as $line) { - if ($line === '') { - break; - } - if ($current !== '' && preg_match('/^[ \t]/', $line)) { - $headers[$current] .= ' ' . trim($line); - continue; - } - if (!str_contains($line, ':')) { - continue; - } - [$key, $value] = explode(':', $line, 2); - $current = trim($key); - if ($current !== '') { - $headers[$current] = trim($value); - } - } - return $headers; - } - - /** @param array $headers */ - private static function headerValue(array $headers, string $name): string - { - foreach ($headers as $key => $value) { - if (strcasecmp($key, $name) === 0) { - return trim($value); - } - } - return ''; - } - - private static function replySubject(string $prefix, string $originalSubject): string - { - $prefix = trim($prefix); - $originalSubject = trim($originalSubject); - if ($prefix !== '' && $originalSubject !== '') { - return str_ends_with($prefix, ':') ? $prefix . ' ' . $originalSubject : $prefix . ': ' . $originalSubject; - } - if ($prefix !== '') { - return $prefix; - } - return $originalSubject !== '' ? $originalSubject : 'Automatic reply'; - } -} - -if (PHP_SAPI === 'cli' && realpath((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) { - $settings = Settings::load(Settings::EXTERNAL_SETTINGS); - $sender = new MailSender(); - $worker = new GlobalAutoresponderWorker( - $settings, - new RuleRepository(), - new MailboxDirectory(), - new RepeatState(), - static fn (string $from, string $to, string $subject, string $body): bool => $sender->send('/usr/sbin/sendmail', $from, $to, $subject, $body) - ); - $worker->handle(getenv(), stream_get_contents(STDIN) ?: ''); -} diff --git a/scripts/install.sh b/scripts/install.sh index 091aab6..8df8c23 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -5,20 +5,15 @@ BASE_DIR="/usr/local/hitme_plugins/global-autoresponders" PLUGIN_DIR="/usr/local/directadmin/plugins/global-autoresponder" CRON_FILE="/etc/cron.d/global-autoresponder" -mkdir -p "$BASE_DIR/config/users" "$BASE_DIR/data/users" "$BASE_DIR/state/replies" "$BASE_DIR/logs" +mkdir -p "$BASE_DIR/config/users" "$BASE_DIR/data/users" "$BASE_DIR/logs" chmod 700 "$BASE_DIR/config" "$BASE_DIR/logs" -chmod 711 "$BASE_DIR/data" "$BASE_DIR/state" +chmod 711 "$BASE_DIR/data" if [ ! -f "$BASE_DIR/config/plugin-settings.conf" ]; then cp "$PLUGIN_DIR/plugin-settings.conf" "$BASE_DIR/config/plugin-settings.conf" chmod 600 "$BASE_DIR/config/plugin-settings.conf" fi -if [ ! -f "$BASE_DIR/config/backend-state.json" ]; then - printf '%s\n' '{"active_backend":"da","migration_status":"complete"}' > "$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" diff --git a/scripts/sync_directadmin_vacations.php b/scripts/sync_directadmin_vacations.php index d950d5a..e7ed9a6 100755 --- a/scripts/sync_directadmin_vacations.php +++ b/scripts/sync_directadmin_vacations.php @@ -38,11 +38,7 @@ try { throw new InvalidArgumentException('Invalid username'); } - $settings = Settings::load(Settings::EXTERNAL_SETTINGS); - if ($settings->backendMode() !== 'da') { - echo "DirectAdmin backend is not active; nothing to sync.\n"; - exit(0); - } + Settings::load(Settings::EXTERNAL_SETTINGS); $users = $targetUser !== '' ? [$targetUser] : users_with_rules(Settings::DATA_DIR); $service = new DirectAdminVacationSyncService( diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 88ddc48..2b1a83b 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -2,12 +2,8 @@ set -eu BASE_DIR="/usr/local/hitme_plugins/global-autoresponders" -PLUGIN_DIR="/usr/local/directadmin/plugins/global-autoresponder" CRON_FILE="/etc/cron.d/global-autoresponder" -if [ -x "$PLUGIN_DIR/scripts/backend.sh" ]; then - "$PLUGIN_DIR/scripts/backend.sh" da || true -fi rm -f "$CRON_FILE" if [ "${PURGE_GLOBAL_AUTORESPONDER_DATA:-0}" = "1" ]; then diff --git a/scripts/update.sh b/scripts/update.sh index 1314b02..1e91ccb 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -4,7 +4,7 @@ set -eu BASE_DIR="/usr/local/hitme_plugins/global-autoresponders" PLUGIN_DIR="/usr/local/directadmin/plugins/global-autoresponder" CRON_FILE="/etc/cron.d/global-autoresponder" -mkdir -p "$BASE_DIR/config/users" "$BASE_DIR/data/users" "$BASE_DIR/state/replies" "$BASE_DIR/logs" +mkdir -p "$BASE_DIR/config/users" "$BASE_DIR/data/users" "$BASE_DIR/logs" printf '%s\n' "*/15 * * * * root $PLUGIN_DIR/scripts/sync_directadmin_vacations.php >/dev/null 2>&1" > "$CRON_FILE" chmod 644 "$CRON_FILE" echo "global-autoresponder updated; external configuration preserved" diff --git a/tests/backend_runtime_config_test.php b/tests/backend_runtime_config_test.php deleted file mode 100644 index a943c89..0000000 --- a/tests/backend_runtime_config_test.php +++ /dev/null @@ -1,21 +0,0 @@ -> " . escapeshellarg(TEST_TMP . '/called.log') . "\nmkdir -p " . escapeshellarg(TEST_TMP . '/config') . "\nprintf '{\"active_backend\":\"%s\"}\\n' \"$1\" > " . escapeshellarg(TEST_TMP . '/config/backend-state.json') . "\n"); - chmod($script, 0700); - - BackendRuntimeConfig::sync($settings, $script, TEST_TMP . '/config/backend-state.json'); - assert_same("exim\n", file_get_contents(TEST_TMP . '/called.log')); - - BackendRuntimeConfig::sync($settings, $script, TEST_TMP . '/config/backend-state.json'); - assert_same("exim\n", file_get_contents(TEST_TMP . '/called.log'), 'Script should not run again when backend is already active'); -}); diff --git a/tests/bootstrap_test.php b/tests/bootstrap_test.php index f86fa6d..6b3d202 100644 --- a/tests/bootstrap_test.php +++ b/tests/bootstrap_test.php @@ -10,7 +10,6 @@ test('settings validate defaults and external paths', function (): void { 'DEFAULT_PLUGIN_LANGUAGE=pl', 'DEFAULT_ENABLE=false', 'GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw', - 'GLOBAL_AUTORESPONDER_BACKEND=exim', 'GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE=true', 'GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES=255', 'GLOBAL_AUTORESPONDER_MAX_BODY_BYTES=20000', @@ -21,7 +20,6 @@ test('settings validate defaults and external paths', function (): void { assert_false($settings->defaultEnable()); assert_same('Europe/Warsaw', $settings->timezone()); assert_same('exact_hour', $settings->scheduleMode()); - assert_same('exim', $settings->backendMode()); assert_same('/usr/local/hitme_plugins/global-autoresponders/config', Settings::CONFIG_DIR); assert_same('/usr/local/hitme_plugins/global-autoresponders/data', Settings::DATA_DIR); }); @@ -34,7 +32,7 @@ test('settings template does not contain global repeat policy settings', functio 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_BACKEND=bad\n"); + test_write($path, "GLOBAL_AUTORESPONDER_TIMEZONE=Not/AZone\n"); try { Settings::load($path); } catch (InvalidArgumentException $e) { @@ -44,16 +42,8 @@ test('settings reject invalid timezone and schedule mode', function (): void { throw new RuntimeException('Expected invalid settings to fail'); }); -test('backend mode derives schedule mode', function (): void { - $path = TEST_TMP . '/backend.conf'; - test_write($path, "GLOBAL_AUTORESPONDER_BACKEND=da\n"); - $settings = Settings::load($path); - assert_same('da', $settings->backendMode()); - assert_same('exact_hour', $settings->scheduleMode()); - - test_write($path, "GLOBAL_AUTORESPONDER_BACKEND=exim\n"); - $settings = Settings::load($path); - assert_same('exim', $settings->backendMode()); +test('settings keep exact schedule mode', function (): void { + $settings = Settings::load(TEST_TMP . '/schedule.conf'); assert_same('exact_hour', $settings->scheduleMode()); }); @@ -67,3 +57,19 @@ test('directadmin user access follows DEFAULT_ENABLE and per-user overrides', fu test_write(TEST_TMP . '/config/users/marek.conf', "enabled=true\n"); assert_true($user->hasPluginAccess($settings)); }); + +test('directadmin user normalizes evolution skin variants', function (): void { + assert_same('evolution', (new DirectAdminUser('marek', 'Evolution', 'en', TEST_TMP . '/config'))->skin()); + assert_same('evolution', (new DirectAdminUser('marek', 'evolution-ui', 'en', TEST_TMP . '/config'))->skin()); + assert_same('enhanced', (new DirectAdminUser('marek', '', 'en', TEST_TMP . '/config'))->skin()); +}); + +test('directadmin user checks common skin environment variables', function (): void { + $source = file_get_contents(PLUGIN_ROOT . '/exec/lib/DirectAdminUser.php') ?: ''; + + assert_contains('SESSION_SKIN', $source); + assert_contains('SESSION_SELECTED_SKIN', $source); + assert_contains('DA_SKIN', $source); + assert_contains("['skin']", $source); + assert_contains('readUserConf', $source); +}); diff --git a/tests/directadmin_vacation_api_test.php b/tests/directadmin_vacation_api_test.php index 4e14d2c..9e8696a 100644 --- a/tests/directadmin_vacation_api_test.php +++ b/tests/directadmin_vacation_api_test.php @@ -12,7 +12,6 @@ test('directadmin vacation api builds scoped payload for one mailbox', function $api = new DirectAdminVacationApi('/usr/local/directadmin/directadmin'); $payload = $api->buildSavePayload('info@example.com', [ 'subject_prefix' => 'Urlop', - 'reply_every_message' => false, 'repeat_minutes' => 1, 'body' => 'Body', 'start_value' => '2026-06-10T00:00', @@ -31,20 +30,6 @@ test('directadmin vacation api builds scoped payload for one mailbox', function assert_same('23:59', $payload['endtime']); }); -test('directadmin vacation api maps every-message legacy rules to one minute minimum', function (): void { - $api = new DirectAdminVacationApi('/usr/local/directadmin/directadmin'); - $payload = $api->buildSavePayload('info@example.com', [ - 'subject_prefix' => 'Urlop', - 'reply_every_message' => true, - 'repeat_minutes' => 0, - 'body' => 'Body', - 'start_value' => '2026-06-10T00:00', - 'end_value' => '2026-06-24T23:59', - ]); - - assert_same('1m', $payload['reply_once_time']); -}); - test('directadmin vacation api reports http error response bodies', function (): void { $runner = static function (array $args, string $stdin): string { if ($args[0] === '/bin/echo') { @@ -58,7 +43,6 @@ test('directadmin vacation api reports http error response bodies', function (): try { $api->saveVacation('alice', 'info@example.com', [ 'subject_prefix' => 'Urlop', - 'reply_every_message' => false, 'repeat_minutes' => 1440, 'body' => 'Body', 'start_value' => '2026-06-10|morning', @@ -85,7 +69,6 @@ test('directadmin vacation api reports legacy api error payloads', function (): try { $api->saveVacation('alice', 'info@example.com', [ 'subject_prefix' => 'Urlop', - 'reply_every_message' => false, 'repeat_minutes' => 1440, 'body' => 'Body', 'start_value' => '2026-06-10|morning', diff --git a/tests/directadmin_vacation_sync_service_test.php b/tests/directadmin_vacation_sync_service_test.php index 048a591..1443de4 100644 --- a/tests/directadmin_vacation_sync_service_test.php +++ b/tests/directadmin_vacation_sync_service_test.php @@ -16,7 +16,6 @@ test('directadmin vacation sync creates tracked entries for enabled rule mailbox $repo = new RuleRepository(TEST_TMP . '/da-sync-data'); $rule = $repo->create('alice', [ 'subject_prefix' => 'Urlop', - 'reply_every_message' => false, 'repeat_minutes' => 1440, 'body' => 'Body', 'start_value' => '2026-06-10|morning', @@ -71,7 +70,6 @@ test('directadmin vacation sync deletes stale tracked entries after rule removal $syncRepo = new DirectAdminSyncRepository($dataDir); $rule = $repo->create('alice', [ 'subject_prefix' => 'Urlop', - 'reply_every_message' => false, 'repeat_minutes' => 1440, 'body' => 'Body', 'start_value' => '2026-06-10|morning', @@ -112,7 +110,6 @@ test('directadmin vacation sync refuses to overwrite untracked native vacation', $repo = new RuleRepository($dataDir); $repo->create('alice', [ 'subject_prefix' => 'Urlop', - 'reply_every_message' => false, 'repeat_minutes' => 1440, 'body' => 'Body', 'start_value' => '2026-06-10|morning', @@ -153,3 +150,98 @@ test('directadmin vacation sync refuses to overwrite untracked native vacation', assert_true($thrown); assert_same([], $calls); }); + +test('directadmin vacation sync preflights all target mailboxes before saving', function (): void { + $virtualRoot = TEST_TMP . '/da-sync-preflight-virtual'; + test_write($virtualRoot . '/domainowners', "example.com: alice\n"); + test_write($virtualRoot . '/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"); + + $dataDir = TEST_TMP . '/da-sync-preflight-data'; + $repo = new RuleRepository($dataDir); + $repo->create('alice', [ + 'subject_prefix' => 'Urlop', + 'repeat_minutes' => 1440, + 'body' => 'Body', + 'start_value' => '2026-06-10|morning', + 'end_value' => '2026-06-24|evening', + 'start_ts' => 1, + 'end_ts' => 2, + 'enabled' => true, + 'dynamic_scope' => true, + ]); + + $calls = []; + $api = new class($calls) extends DirectAdminVacationApi { + public function __construct(private array &$calls) {} + public function vacationExists(string $owner, string $email): bool + { + return $email === 'sales@example.com'; + } + public function saveVacation(string $owner, string $email, array $rule, bool $exists): void + { + $this->calls[] = ['save', $owner, $email]; + } + }; + + $sync = new DirectAdminVacationSyncService( + $repo, + new DirectAdminSyncRepository($dataDir), + new MailboxDirectory($virtualRoot), + $api + ); + + $thrown = false; + try { + $sync->syncUser('alice'); + } catch (RuntimeException $e) { + $thrown = true; + assert_contains('sales@example.com', $e->getMessage()); + } + assert_true($thrown); + assert_same([], $calls, 'No vacation should be saved before all conflicts are known'); +}); + +test('directadmin vacation sync lists and disables untracked native vacations for user domains', function (): void { + $virtualRoot = TEST_TMP . '/da-native-list-virtual'; + test_write($virtualRoot . '/domainowners', "example.com: alice\nother.com: bob\n"); + test_write($virtualRoot . '/example.com/passwd', "info:x:1000:12::/home/alice/imap/example.com/info:/bin/false\n"); + + $dataDir = TEST_TMP . '/da-native-list-data'; + $repo = new RuleRepository($dataDir); + $syncRepo = new DirectAdminSyncRepository($dataDir); + $rule = $repo->create('alice', [ + 'subject_prefix' => 'Urlop', + 'body' => 'Body', + 'start_ts' => 1, + 'end_ts' => 2, + 'enabled' => true, + ]); + $syncRepo->replaceRuleEntries('alice', (string)$rule['id'], [ + ['email' => 'tracked@example.com', 'domain' => 'example.com', 'mailbox' => 'tracked', 'api_key' => 'tracked@example.com'], + ]); + + $calls = []; + $api = new class($calls) extends DirectAdminVacationApi { + public function __construct(private array &$calls) {} + public function listVacations(string $owner, string $domain): array + { + return $domain === 'example.com' ? ['manual', 'tracked'] : ['foreign']; + } + public function deleteVacation(string $owner, string $email): void + { + $this->calls[] = ['delete', $owner, $email]; + } + }; + + $sync = new DirectAdminVacationSyncService($repo, $syncRepo, new MailboxDirectory($virtualRoot), $api); + $native = $sync->untrackedNativeVacations('alice'); + + assert_same([[ + 'domain' => 'example.com', + 'mailbox' => 'manual', + 'email' => 'manual@example.com', + ]], $native); + + $sync->deleteNativeVacations('alice', $native); + assert_same([['delete', 'alice', 'manual@example.com']], $calls); +}); diff --git a/tests/exim_worker_test.php b/tests/exim_worker_test.php deleted file mode 100644 index 002f90d..0000000 --- a/tests/exim_worker_test.php +++ /dev/null @@ -1,151 +0,0 @@ -create('alice', [ - 'subject' => 'Urlop', - 'subject_prefix' => 'Urlop', - 'reply_every_message' => false, - 'repeat_minutes' => 60, - 'body' => 'Odpowiem później', - 'start_ts' => 900, - 'end_ts' => 1100, - 'enabled' => true, - ]); - - $sent = []; - $worker = new GlobalAutoresponderWorker( - $settings, - $repo, - new MailboxDirectory($virtualRoot), - new RepeatState(TEST_TMP . '/worker-state', 1000), - static function (string $from, string $to, string $subject, string $body) use (&$sent): bool { - $sent[] = compact('from', 'to', 'subject', 'body'); - return true; - }, - 1000 - ); - - $count = $worker->handle( - ['SENDER_ADDRESS' => 'person@example.net', 'LOCAL_PART' => 'info', 'DOMAIN' => 'example.com'], - "Auto-Submitted: no\nSubject: hello\n\nBody" - ); - - assert_same(1, $count); - assert_same('info@example.com', $sent[0]['from']); - assert_same('person@example.net', $sent[0]['to']); - assert_same('Urlop: hello', $sent[0]['subject']); - assert_same('Odpowiem później', $sent[0]['body']); - - $count = $worker->handle( - ['SENDER_ADDRESS' => 'person@example.net', 'RECIPIENT' => 'info@example.com'], - "Auto-Submitted: no\nSubject: hello\n\nBody" - ); - assert_same(0, $count, 'repeat policy should block immediate second response'); - - assert_true(isset($created['id'])); -}); - -test('exim worker suppresses automated messages and ignores unresolved recipients', function (): void { - $settingsPath = TEST_TMP . '/worker-suppress-settings.conf'; - test_write($settingsPath, "GLOBAL_AUTORESPONDER_BACKEND=exim\n"); - $settings = Settings::load($settingsPath); - - $virtualRoot = TEST_TMP . '/worker-suppress-virtual'; - test_write($virtualRoot . '/domainowners', "example.com: alice\n"); - test_write($virtualRoot . '/example.com/passwd', "info:x:1000:12::/home/alice/imap/example.com/info:/bin/false\n"); - test_write($virtualRoot . '/example.com/aliases', "alias: info\n"); - - $repo = new RuleRepository(TEST_TMP . '/worker-suppress-data'); - $repo->create('alice', [ - 'subject_prefix' => 'Urlop', - 'reply_every_message' => true, - 'repeat_minutes' => 0, - 'body' => 'Body', - 'start_ts' => 1, - 'end_ts' => 2000, - 'enabled' => true, - ]); - - $sent = 0; - $worker = new GlobalAutoresponderWorker( - $settings, - $repo, - new MailboxDirectory($virtualRoot), - new RepeatState(TEST_TMP . '/worker-suppress-state', 1000), - static function () use (&$sent): bool { - $sent++; - return true; - }, - 1000 - ); - - assert_same(0, $worker->handle( - ['SENDER_ADDRESS' => 'person@example.net', 'RECIPIENT' => 'info@example.com'], - "Precedence: bulk\n\nBody" - )); - assert_same(0, $worker->handle( - ['SENDER_ADDRESS' => 'person@example.net', 'RECIPIENT' => 'alias@example.com'], - "Auto-Submitted: no\n\nBody" - )); - assert_same(0, $sent); -}); - -test('exim worker respects static mailbox snapshot scope', function (): void { - $settingsPath = TEST_TMP . '/worker-snapshot-settings.conf'; - test_write($settingsPath, "GLOBAL_AUTORESPONDER_BACKEND=exim\n"); - $settings = Settings::load($settingsPath); - - $virtualRoot = TEST_TMP . '/worker-snapshot-virtual'; - test_write($virtualRoot . '/domainowners', "example.com: alice\n"); - test_write($virtualRoot . '/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"); - - $repo = new RuleRepository(TEST_TMP . '/worker-snapshot-data'); - $repo->create('alice', [ - 'subject_prefix' => 'Urlop', - 'reply_every_message' => true, - 'repeat_minutes' => 0, - 'body' => 'Body', - 'start_ts' => 1, - 'end_ts' => 2000, - 'enabled' => true, - 'dynamic_scope' => false, - 'mailbox_snapshot' => [ - ['domain' => 'example.com', 'mailbox' => 'info', 'email' => 'info@example.com'], - ], - ]); - - $sent = 0; - $worker = new GlobalAutoresponderWorker( - $settings, - $repo, - new MailboxDirectory($virtualRoot), - new RepeatState(TEST_TMP . '/worker-snapshot-state', 1000), - static function () use (&$sent): bool { - $sent++; - return true; - }, - 1000 - ); - - assert_same(0, $worker->handle( - ['SENDER_ADDRESS' => 'person@example.net', 'RECIPIENT' => 'sales@example.com'], - "Auto-Submitted: no\n\nBody" - )); - assert_same(1, $worker->handle( - ['SENDER_ADDRESS' => 'person@example.net', 'RECIPIENT' => 'info@example.com'], - "Auto-Submitted: no\n\nBody" - )); - assert_same(1, $sent); -}); diff --git a/tests/handler_test.php b/tests/handler_test.php index 04fa0f4..b7471ed 100644 --- a/tests/handler_test.php +++ b/tests/handler_test.php @@ -1,12 +1,12 @@ t('Add autoresponder')") !== false); }); -test('rule form uses minute time input and no summary box in exim mode', function (): void { +test('rule form uses minute time input and directadmin frequency range', function (): void { require_once PLUGIN_ROOT . '/exec/lib/Settings.php'; require_once PLUGIN_ROOT . '/exec/lib/Lang.php'; require_once PLUGIN_ROOT . '/exec/lib/DirectAdminUser.php'; @@ -57,7 +57,7 @@ test('rule form uses minute time input and no summary box in exim mode', functio require_once PLUGIN_ROOT . '/exec/handlers/_form_helpers.php'; $settingsPath = TEST_TMP . '/form.conf'; - test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\nGLOBAL_AUTORESPONDER_BACKEND=exim\n"); + test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\n"); $settings = Settings::load($settingsPath); $ctx = new AppContext( new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'), @@ -69,21 +69,230 @@ test('rule form uses minute time input and no summary box in exim mode', functio ); $html = render_rule_form($ctx, 'create', null); + assert_contains('name="name"', $html); + assert_contains('Nazwa autorespondera', $html); + assert_contains('Ta nazwa jest wykorzystywana tylko w celach opisowych', $html); assert_contains('type="time"', $html); assert_contains('step="60"', $html); assert_false(strpos($html, 'summary-card') !== false, 'Summary box should not be rendered in create/edit form'); assert_contains('Przedrostek tematu', $html); assert_contains(': pierwotny temat', $html); assert_contains('Częstość odpowiedzi', $html); - assert_contains('Wysyłaj automatyczną odpowiedź dla każdej wiadomości', $html); + assert_false(strpos($html, 'Wysyłaj automatyczną odpowiedź dla każdej wiadomości') !== false); assert_contains('14 dni', $html); + assert_contains('1 minuta', $html); assert_contains('1 dzień', $html); assert_contains('Treść wiadomości', $html); }); -test('rule form uses exact hour selector and directadmin frequency range in da mode', function (): void { +test('autoresponder index shows descriptive name and created timestamp columns', function (): void { + $settingsPath = TEST_TMP . '/index-columns.conf'; + test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\n"); + $settings = Settings::load($settingsPath); + $repo = new RuleRepository(TEST_TMP . '/index-data'); + $repo->create('alice', [ + 'name' => 'Urlop zarządu', + 'subject_prefix' => 'Urlop', + 'repeat_minutes' => 1440, + 'body' => 'Body', + 'enabled' => true, + 'start_ts' => 1780495200, + 'end_ts' => 1780581540, + ]); + $ctx = new AppContext( + new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'), + $settings, + $repo, + new CsrfGuard('secret', 'alice', 'sid'), + Lang::load('pl', $settings), + new AuditLog(TEST_TMP . '/audit.log') + ); + + $html = render_rules_index_body($ctx); + assert_contains('Nazwa autorespondera', $html); + assert_contains('Utworzono', $html); + assert_contains('Urlop zarządu', $html); + assert_false(strpos($html, 'Zaktualizowano') !== false); +}); + +test('dashboard omits account mailbox helper subtitles', function (): void { + $settingsPath = TEST_TMP . '/index-subtitles.conf'; + test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\n"); + $settings = Settings::load($settingsPath); + $repo = new RuleRepository(TEST_TMP . '/index-subtitles-data'); + $repo->create('alice', [ + 'name' => 'Urlop zarządu', + 'subject_prefix' => 'Urlop', + 'repeat_minutes' => 1440, + 'body' => 'Body', + 'enabled' => true, + 'start_ts' => 1780495200, + 'end_ts' => 1780581540, + ]); + $ctx = new AppContext( + new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'), + $settings, + $repo, + new CsrfGuard('secret', 'alice', 'sid'), + Lang::load('pl', $settings), + new AuditLog(TEST_TMP . '/audit.log') + ); + + $index = render_rules_index_body($ctx); + $chrome = file_get_contents(PLUGIN_ROOT . '/exec/lib/AppContext.php') ?: ''; + + assert_false(strpos($index, 'Wszystkie prawidłowe skrzynki POP/IMAP konta') !== false); + assert_false(strpos($chrome, 'Manage automatic replies for account mailboxes') !== false); +}); + +test('create conflict modal offers replacing active rule or adding disabled rule', function (): void { + $settingsPath = TEST_TMP . '/create-conflict.conf'; + test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\n"); + $settings = Settings::load($settingsPath); + $repo = new RuleRepository(TEST_TMP . '/create-conflict-data'); + $existing = $repo->create('alice', [ + 'name' => 'Stary urlop', + 'subject_prefix' => 'Urlop', + 'repeat_minutes' => 1440, + 'body' => 'Body', + 'enabled' => true, + 'start_ts' => 1780495200, + 'end_ts' => 1780581540, + ]); + $ctx = new AppContext( + new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'), + $settings, + $repo, + new CsrfGuard('secret', 'alice', 'sid'), + Lang::load('pl', $settings), + new AuditLog(TEST_TMP . '/audit.log') + ); + + $modal = render_create_conflict_modal($ctx, [ + 'name' => 'Nowy urlop', + 'subject_prefix' => 'Nowy', + 'repeat_minutes' => 1440, + 'body' => 'Nowa treść', + 'enabled' => true, + 'start_value' => '2026-06-10T00:00', + 'end_value' => '2026-06-24T23:59', + ], $existing); + + assert_contains('Dla użytkownika alice istnieje już globalny autoresponder', $modal); + assert_contains('Stary urlop', $modal); + assert_contains('Nowy urlop', $modal); + assert_contains('name="conflict_action" value="replace"', $modal); + assert_contains('name="conflict_action" value="create_disabled"', $modal); + assert_contains('Włącz autoresponder (Nowy urlop) i wyłącz poprzedni (Stary urlop)', $modal); + assert_contains('Dodaj autoresponder (Nowy urlop) jako wyłączony', $modal); +}); + +test('native directadmin autoresponder modal lists entries before creating global rule', function (): void { + $settingsPath = TEST_TMP . '/native-conflict.conf'; + test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\n"); + $settings = Settings::load($settingsPath); + $ctx = new AppContext( + new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'), + $settings, + new RuleRepository(TEST_TMP . '/native-conflict-data'), + new CsrfGuard('secret', 'alice', 'sid'), + Lang::load('pl', $settings), + new AuditLog(TEST_TMP . '/audit.log') + ); + + $modal = render_native_vacation_conflict_modal($ctx, [ + 'name' => 'Nowy urlop', + 'subject_prefix' => 'Nowy', + 'repeat_minutes' => 1440, + 'body' => 'Nowa treść', + 'enabled' => true, + 'start_value' => '2026-06-10T00:00', + 'end_value' => '2026-06-24T23:59', + ], [ + ['domain' => 'example.com', 'mailbox' => 'info', 'email' => 'info@example.com'], + ['domain' => 'example.com', 'mailbox' => 'sales', 'email' => 'sales@example.com'], + ]); + + assert_contains('użytkownik alice ma już utworzone natywne autorespondery', $modal); + assert_contains('info@example.com', $modal); + assert_contains('sales@example.com', $modal); + assert_contains('name="native_conflict_action" value="disable_native"', $modal); + assert_contains('Istniejące autorespondery / wiadomości urlopowe zostaną usunięte w przypadku kontynuacji.', $modal); + assert_contains('class="danger-fill" type="submit">USUŃ ISTNIEJĄCE AUTORESPONDERY', $modal); + assert_contains('Anuluj', $modal); +}); + +test('delete confirmation only asks yes no with rule name and created date', function (): void { + $settingsPath = TEST_TMP . '/delete-confirm.conf'; + test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\nGLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw\n"); + $settings = Settings::load($settingsPath); + $ctx = new AppContext( + new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'), + $settings, + new RuleRepository(TEST_TMP . '/delete-confirm-data'), + new CsrfGuard('secret', 'alice', 'sid'), + Lang::load('pl', $settings), + new AuditLog(TEST_TMP . '/audit.log') + ); + $rule = [ + 'id' => 'rule-delete', + 'name' => 'Urlop testowy', + 'subject_prefix' => 'Urlop', + 'created_at' => 1780495200, + ]; + + $html = render_delete_confirmation_body($ctx, $rule); + + assert_contains('Czy napewno chcesz usunąć globalny autoresponder Urlop testowy utworzony 2026-06-03 16:00?', $html); + assert_contains('>Tak', $html); + assert_contains('>Nie', $html); + assert_false(strpos($html, 'Preview autoresponder') !== false); + assert_false(strpos($html, 'Deleting this rule will disable') !== false); + assert_false(strpos($html, 'Treść odpowiedzi') !== false); +}); + +test('toggle conflict modal asks before replacing currently active rule', function (): void { + $settingsPath = TEST_TMP . '/toggle-conflict.conf'; + test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\n"); + $settings = Settings::load($settingsPath); + $repo = new RuleRepository(TEST_TMP . '/toggle-conflict-data'); + $existing = $repo->create('alice', [ + 'name' => 'Aktywny urlop', + 'subject_prefix' => 'Urlop', + 'repeat_minutes' => 1440, + 'body' => 'Body', + 'enabled' => true, + 'start_ts' => 1780495200, + 'end_ts' => 1780581540, + ]); + $target = $repo->create('alice', [ + 'name' => 'Nowy aktywny', + 'subject_prefix' => 'Nowy', + 'repeat_minutes' => 1440, + 'body' => 'Body', + 'enabled' => false, + 'start_ts' => 1780495200, + 'end_ts' => 1780581540, + ]); + $ctx = new AppContext( + new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'), + $settings, + $repo, + new CsrfGuard('secret', 'alice', 'sid'), + Lang::load('pl', $settings), + new AuditLog(TEST_TMP . '/audit.log') + ); + + $modal = render_toggle_conflict_modal($ctx, $target, $existing); + assert_contains('może być aktywny tylko jeden globalny autoresponder', $modal); + assert_contains('Włącz autoresponder', $modal); + assert_contains('name="conflict_action" value="replace"', $modal); + assert_contains('name="id" value="' . $target['id'] . '"', $modal); +}); + +test('rule form uses exact hour selector and directadmin frequency range', function (): void { $settingsPath = TEST_TMP . '/form-da.conf'; - test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\nGLOBAL_AUTORESPONDER_BACKEND=da\n"); + test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\n"); $settings = Settings::load($settingsPath); $ctx = new AppContext( new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'), @@ -102,22 +311,69 @@ test('rule form uses exact hour selector and directadmin frequency range in da m assert_false(strpos($html, 'name="start_value_period"') !== false); }); +test('rule form groups start and end calendars in one schedule panel', function (): void { + $settingsPath = TEST_TMP . '/form-schedule-layout.conf'; + test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\n"); + $settings = Settings::load($settingsPath); + $ctx = new AppContext( + new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'), + $settings, + new RuleRepository(TEST_TMP . '/data'), + new CsrfGuard('secret', 'alice', 'sid'), + Lang::load('pl', $settings), + new AuditLog(TEST_TMP . '/audit.log') + ); + + $html = render_rule_form($ctx, 'create', null); + assert_same(1, substr_count($html, 'schedule-panel')); + assert_contains('schedule-grid', $html); + assert_contains('schedule-column schedule-start', $html); + assert_contains('schedule-column schedule-end', $html); + assert_contains('Data rozpoczęcia', $html); + assert_contains('Godzina rozpoczęcia', $html); + assert_contains('Data zakończenia', $html); + assert_contains('Godzina zakończenia', $html); +}); + +test('skins define different schedule calendar layouts', function (): void { + $enhanced = file_get_contents(PLUGIN_ROOT . '/user/enhanced.css') ?: ''; + $evolution = file_get_contents(PLUGIN_ROOT . '/user/evolution.css') ?: ''; + $appContext = file_get_contents(PLUGIN_ROOT . '/exec/lib/AppContext.php') ?: ''; + + assert_contains('.schedule-grid { grid-template-columns: 1fr;', $enhanced); + assert_contains('.schedule-grid { grid-template-columns: repeat(2, minmax(460px, 1fr));', $evolution); + assert_contains('.schedule-scroll { overflow-x: auto;', $evolution); + assert_contains('width: 100% !important;', $evolution); + assert_contains('margin-left: 0 !important;', $evolution); + assert_contains('max-width: none !important;', $evolution); + assert_false(str_contains($evolution, '#content'), 'Evolution CSS must not override DirectAdmin shell containers globally'); + assert_false((bool)preg_match('/^\\.container,$/m', $evolution), 'Evolution CSS must not override every DirectAdmin container globally'); + assert_false(str_contains($evolution, '.content-wrapper'), 'Evolution CSS must not override shell wrappers globally'); + assert_false(str_contains($evolution, '@import')); + assert_contains("skin === 'evolution'", $appContext); + assert_contains("enhanced.css", $appContext); + assert_contains("evolution.css", $appContext); + assert_contains('expandDirectAdminPluginContainers', $appContext); + assert_contains("pluginSkin !== 'evolution'", $appContext); + assert_contains("wrap.parentElement", $appContext); +}); + test('autoresponder table does not expose mailbox scope column', function (): void { $source = file_get_contents(PLUGIN_ROOT . '/exec/handlers/index.php') ?: ''; assert_false(strpos($source, "ctx->t('Scope')") !== false); assert_false(strpos($source, "ctx->t(\$rule['dynamic_scope']") !== false); }); -test('mutating handlers synchronize active backend after storage changes', function (): void { +test('mutating handlers synchronize directadmin vacations after storage changes', function (): void { foreach (['create.php', 'update.php', 'delete.php', 'toggle.php'] as $handler) { $source = file_get_contents(PLUGIN_ROOT . '/exec/handlers/' . $handler) ?: ''; - assert_contains('syncActiveBackend', $source, $handler . ' must synchronize backend after mutation'); + assert_contains('syncDirectAdminVacations', $source, $handler . ' must synchronize native vacations after mutation'); } }); test('calendar picker is based on selected rule month and supports month navigation', function (): void { $settingsPath = TEST_TMP . '/form-calendar.conf'; - test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\nGLOBAL_AUTORESPONDER_BACKEND=exim\n"); + test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\n"); $settings = Settings::load($settingsPath); $ctx = new AppContext( new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'), @@ -131,7 +387,6 @@ test('calendar picker is based on selected rule month and supports month navigat $html = render_rule_form($ctx, 'update', [ 'id' => 'abc', 'subject_prefix' => 'Urlop', - 'reply_every_message' => false, 'repeat_minutes' => 1440, 'body' => 'Body', 'enabled' => true, @@ -148,7 +403,7 @@ test('calendar picker is based on selected rule month and supports month navigat test('calendar picker disables dates before today', function (): void { $settingsPath = TEST_TMP . '/form-calendar-min.conf'; - test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\nGLOBAL_AUTORESPONDER_BACKEND=exim\n"); + test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\n"); $settings = Settings::load($settingsPath); $ctx = new AppContext( new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'), @@ -164,9 +419,39 @@ test('calendar picker disables dates before today', function (): void { assert_false(str_contains($html, 'data-date-value="2000-01-15"'), 'Past dates must not be clickable'); }); +test('update form calendar allows existing past schedule dates', function (): void { + $settingsPath = TEST_TMP . '/form-calendar-update-past.conf'; + test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\nGLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw\n"); + $settings = Settings::load($settingsPath); + $ctx = new AppContext( + new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'), + $settings, + new RuleRepository(TEST_TMP . '/data'), + new CsrfGuard('secret', 'alice', 'sid'), + Lang::load('pl', $settings), + new AuditLog(TEST_TMP . '/audit.log') + ); + + $html = render_rule_form($ctx, 'update', [ + 'id' => 'abc', + 'name' => 'Historyczny urlop', + 'subject_prefix' => 'Urlop', + 'repeat_minutes' => 1440, + 'body' => 'Body', + 'enabled' => true, + 'start_value' => '2000-01-10T09:00', + 'end_value' => '2000-01-11T17:00', + ]); + + assert_contains('data-min-date=""', $html); + assert_contains('data-date-value="2000-01-10"', $html); + assert_contains('data-date-value="2000-01-11"', $html); + assert_false(str_contains($html, 'br-cal-day-disabled'), 'Update form must not disable past dates'); +}); + test('new rule form selects current date for start and end calendars by default', function (): void { $settingsPath = TEST_TMP . '/form-calendar-defaults.conf'; - test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\nGLOBAL_AUTORESPONDER_BACKEND=exim\nGLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw\n"); + test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\nGLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw\n"); $settings = Settings::load($settingsPath); $ctx = new AppContext( new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'), @@ -188,7 +473,7 @@ test('new rule form selects current date for start and end calendars by default' test('rule form localizes calendar and preview labels without translating user content globally', function (): void { $settingsPath = TEST_TMP . '/form-i18n.conf'; - test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=en\nGLOBAL_AUTORESPONDER_BACKEND=exim\n"); + test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=en\n"); $settings = Settings::load($settingsPath); $ctx = new AppContext( new DirectAdminUser('alice', 'enhanced', 'en', TEST_TMP . '/config'), @@ -210,14 +495,13 @@ test('rule form localizes calendar and preview labels without translating user c assert_contains('Cancel', $preview); }); -test('directadmin backend allows only one enabled global rule per account', function (): void { +test('plugin allows only one enabled global rule per account', function (): void { $settingsPath = TEST_TMP . '/form-da-single.conf'; - test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\nGLOBAL_AUTORESPONDER_BACKEND=da\n"); + test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\n"); $settings = Settings::load($settingsPath); $repo = new RuleRepository(TEST_TMP . '/single-data'); $repo->create('alice', [ 'subject_prefix' => 'Pierwsza', - 'reply_every_message' => false, 'repeat_minutes' => 1440, 'body' => 'Body', 'enabled' => true, @@ -235,10 +519,10 @@ test('directadmin backend allows only one enabled global rule per account', func $thrown = false; try { - enforce_rule_backend_constraints($ctx, null, ['enabled' => true]); + enforce_rule_directadmin_constraints($ctx, null, ['enabled' => true]); } catch (RuntimeException $e) { $thrown = true; - assert_contains('Backend DirectAdmin obsługuje tylko jedną aktywną regułę globalną.', $e->getMessage()); + assert_contains('W ramach użytkownika może być aktywny tylko jeden globalny autoresponder.', $e->getMessage()); } assert_true($thrown); }); diff --git a/tests/mail_sender_test.php b/tests/mail_sender_test.php deleted file mode 100644 index 3bc81be..0000000 --- a/tests/mail_sender_test.php +++ /dev/null @@ -1,17 +0,0 @@ -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/message_classifier_test.php b/tests/message_classifier_test.php deleted file mode 100644 index f916512..0000000 --- a/tests/message_classifier_test.php +++ /dev/null @@ -1,13 +0,0 @@ - '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 index 882e1f9..e58d26a 100644 --- a/tests/package_contents_test.php +++ b/tests/package_contents_test.php @@ -29,24 +29,30 @@ test('shipped plugin files do not contain placeholders', function (): void { continue; } $content = file_get_contents($path) ?: ''; - if (preg_match('/placeholder|TODO|FIXME|not implemented|pending_exim_validation|stub/i', $content)) { + if (preg_match('/placeholder|TODO|FIXME|not implemented|stub/i', $content)) { $bad[] = $relative; } } assert_same([], $bad); }); -test('backend script protects custom exim config with rollback and exact section insertion checks', function (): void { - $script = file_get_contents(PLUGIN_ROOT . '/scripts/backend.sh') ?: ''; - assert_contains('backup_custom_conf()', $script); - assert_contains('restore_custom_conf()', $script); - assert_contains("trap 'restore_custom_conf' EXIT", $script); - assert_contains('ROLLBACK_REBUILD=1', $script); - assert_contains('if ! awk -v section="$section" -v snippet="$snippet"', $script); - assert_contains('if (inserted != 1)', $script); +test('package source does not ship removed custom mail integration files', function (): void { + $removedPaths = [ + 'scripts/' . 'back' . 'end.sh', + 'scripts/' . 'ex' . 'im', + 'scripts/global_auto' . 'responder_worker.php', + 'exec/lib/Back' . 'endRuntimeConfig.php', + 'exec/lib/Message' . 'Classifier.php', + 'exec/lib/Mail' . 'Sender.php', + 'exec/lib/Repeat' . 'State.php', + ]; + foreach ($removedPaths as $relative) { + $path = PLUGIN_ROOT . '/' . $relative; + assert_false(is_file($path) || is_dir($path), $relative . ' must not exist'); + } }); -test('lifecycle scripts install cron sync and remove exim integration on uninstall', function (): void { +test('lifecycle scripts install directadmin sync cron only', function (): void { $install = file_get_contents(PLUGIN_ROOT . '/scripts/install.sh') ?: ''; $update = file_get_contents(PLUGIN_ROOT . '/scripts/update.sh') ?: ''; $uninstall = file_get_contents(PLUGIN_ROOT . '/scripts/uninstall.sh') ?: ''; @@ -55,5 +61,7 @@ test('lifecycle scripts install cron sync and remove exim integration on uninsta assert_contains('sync_directadmin_vacations.php', $install); assert_contains('/etc/cron.d/global-autoresponder', $update); assert_contains('sync_directadmin_vacations.php', $update); - assert_contains('backend.sh" da', $uninstall); + assert_false(str_contains($install, 'back' . 'end-state.json')); + assert_false(str_contains($install, 'state/' . 'replies')); + assert_false(str_contains($uninstall, 'back' . 'end.sh')); }); diff --git a/tests/plugin_logger_test.php b/tests/plugin_logger_test.php index 0f00cdd..96b0b7a 100644 --- a/tests/plugin_logger_test.php +++ b/tests/plugin_logger_test.php @@ -7,6 +7,9 @@ test('plugin logger writes exceptions to plugin log file', function (): void { $path = TEST_TMP . '/plugin.log'; PluginLogger::init($path); + assert_true(is_file($path), 'Logger should create plugin.log during init'); + assert_same('0600', substr(sprintf('%o', fileperms($path)), -4)); + PluginLogger::exception(new RuntimeException('DirectAdmin API command failed'), 'CREATE'); $log = file_get_contents($path) ?: ''; diff --git a/tests/repeat_state_test.php b/tests/repeat_state_test.php deleted file mode 100644 index 8e14876..0000000 --- a/tests/repeat_state_test.php +++ /dev/null @@ -1,14 +0,0 @@ -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_validator_test.php b/tests/rule_validator_test.php index 986a0ad..0d2e70a 100644 --- a/tests/rule_validator_test.php +++ b/tests/rule_validator_test.php @@ -6,9 +6,10 @@ 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_BACKEND=exim\n"); + test_write($settingsPath, "GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw\n"); $settings = Settings::load($settingsPath); $rule = RuleValidator::validate([ + 'name' => 'Urlop czerwcowy', 'subject_prefix' => 'Urlop', 'reply_frequency' => '120', 'body' => "Dzień dobry\r\nWracam jutro", @@ -17,8 +18,8 @@ test('rule validator normalizes exact hour schedule', function (): void { 'enabled' => '1', ], $settings); + assert_same('Urlop czerwcowy', $rule['name']); assert_same('Urlop', $rule['subject_prefix']); - assert_false($rule['reply_every_message']); assert_same(120, $rule['repeat_minutes']); assert_same("Dzień dobry\nWracam jutro", $rule['body']); assert_same('exact_hour', $rule['schedule_mode']); @@ -26,10 +27,8 @@ test('rule validator normalizes exact hour schedule', function (): void { assert_true($rule['start_ts'] < $rule['end_ts']); }); -test('rule validator accepts da exact hour schedule when backend is da', function (): void { - $settingsPath = TEST_TMP . '/validator-da.conf'; - test_write($settingsPath, "GLOBAL_AUTORESPONDER_BACKEND=da\n"); - $settings = Settings::load($settingsPath); +test('rule validator accepts exact hour schedule', function (): void { + $settings = Settings::load(TEST_TMP . '/validator-da.conf'); $rule = RuleValidator::validate([ 'subject_prefix' => 'Urlop', 'reply_frequency' => '1', @@ -39,16 +38,13 @@ test('rule validator accepts da exact hour schedule when backend is da', functio 'enabled' => '1', ], $settings); assert_same('exact_hour', $rule['schedule_mode']); - assert_false($rule['reply_every_message']); assert_same(1, $rule['repeat_minutes']); assert_same('2026-06-10T00:00', $rule['start_value']); assert_same('2026-06-24T23:59', $rule['end_value']); }); -test('rule validator rejects every message frequency for da backend', function (): void { - $settingsPath = TEST_TMP . '/validator-da-every.conf'; - test_write($settingsPath, "GLOBAL_AUTORESPONDER_BACKEND=da\n"); - $settings = Settings::load($settingsPath); +test('rule validator rejects unsupported non-interval frequency', function (): void { + $settings = Settings::load(TEST_TMP . '/validator-da-every.conf'); try { RuleValidator::validate([ 'subject_prefix' => 'Urlop', @@ -61,7 +57,7 @@ test('rule validator rejects every message frequency for da backend', function ( assert_contains('Częstość odpowiedzi', $e->getMessage()); return; } - throw new RuntimeException('Expected every-message frequency to fail for da backend'); + throw new RuntimeException('Expected every-message frequency to fail'); }); test('rule validator rejects header injection and reversed dates', function (): void { @@ -99,7 +95,7 @@ test('rule validator rejects unsupported response frequency', function (): void test('rule validator rejects start date before current date', function (): void { $settingsPath = TEST_TMP . '/validator-past.conf'; - test_write($settingsPath, "GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw\nGLOBAL_AUTORESPONDER_BACKEND=exim\n"); + test_write($settingsPath, "GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw\n"); $settings = Settings::load($settingsPath); $tz = new DateTimeZone($settings->timezone()); $yesterday = (new DateTimeImmutable('yesterday', $tz))->format('Y-m-d'); @@ -119,3 +115,44 @@ test('rule validator rejects start date before current date', function (): void } throw new RuntimeException('Expected past start date to fail'); }); + +test('rule validator rejects end date before current date when creating', function (): void { + $settingsPath = TEST_TMP . '/validator-past-end.conf'; + test_write($settingsPath, "GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw\n"); + $settings = Settings::load($settingsPath); + $tz = new DateTimeZone($settings->timezone()); + $yesterday = (new DateTimeImmutable('yesterday', $tz))->format('Y-m-d'); + $today = (new DateTimeImmutable('today', $tz))->format('Y-m-d'); + + try { + RuleValidator::validate([ + 'subject_prefix' => 'Urlop', + 'reply_frequency' => '1440', + 'body' => 'Body', + 'start_value' => $today . 'T09:00', + 'end_value' => $yesterday . 'T17:00', + ], $settings); + } catch (InvalidArgumentException $e) { + assert_contains('Data zakończenia nie może być wcześniejsza niż aktualna data.', $e->getMessage()); + return; + } + throw new RuntimeException('Expected past end date to fail on create'); +}); + +test('rule validator allows past schedule when editing', function (): void { + $settingsPath = TEST_TMP . '/validator-edit-past.conf'; + test_write($settingsPath, "GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw\n"); + $settings = Settings::load($settingsPath); + + $rule = RuleValidator::validate([ + 'subject_prefix' => 'Urlop', + 'reply_frequency' => '1440', + 'body' => 'Body', + 'start_value' => '2000-01-10T09:00', + 'end_value' => '2000-01-11T17:00', + ], $settings, true); + + assert_same('2000-01-10T09:00', $rule['start_value']); + assert_same('2000-01-11T17:00', $rule['end_value']); + assert_true($rule['start_ts'] < $rule['end_ts']); +}); diff --git a/user/enhanced.css b/user/enhanced.css index ef15900..109760e 100644 --- a/user/enhanced.css +++ b/user/enhanced.css @@ -35,6 +35,7 @@ header p { margin: 0; color: var(--amber-strong); font-size: 15px; } label { display: block; margin-bottom: 6px; font-weight: 600; font-size: 15px; } input[type="text"], input[type="time"], select, textarea { width: 100%; border: 1px solid #cdd4e0; border-radius: 6px; padding: 10px 12px; font-size: 15px; background: #fff; } textarea { min-height: 170px; resize: vertical; line-height: 1.45; } +.field-help { color: var(--muted); font-size: 13px; line-height: 1.35; margin-top: 5px; } .subject-prefix-control { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: stretch; } .subject-prefix-control input { border-top-right-radius: 0; border-bottom-right-radius: 0; } .subject-prefix-control span { display: inline-flex; align-items: center; border: 1px solid #cdd4e0; border-left: 0; border-radius: 0 6px 6px 0; background: #f8fafc; padding: 0 12px; color: var(--muted); white-space: nowrap; font-size: 15px; } @@ -42,8 +43,8 @@ button, .btn { border: 1px solid #b7c6da; background: #fff; color: #1f2d3d; bord .btn.amber, button.primary { background: var(--amber); border-color: var(--amber); color: #fff; } .btn.amber.active { background: var(--amber-strong); border-color: var(--amber-strong); } .btn.danger, button.danger { border-color: var(--danger); color: var(--danger); background: #fff; } -button.danger-fill { background: var(--danger); border-color: var(--danger); color: #fff; } -button.success { background: var(--ok); border-color: var(--ok); color: #fff; } +button.danger-fill, .btn.danger-fill { background: var(--danger); border-color: var(--danger); color: #fff; } +button.success, .btn.success { background: var(--ok); border-color: var(--ok); color: #fff; } .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; align-items: center; } .table-actions { display: inline-flex; flex-wrap: wrap; gap: 6px; } table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 15px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: #fff; } @@ -53,6 +54,11 @@ th { background: #f6f8fb; color: #4a5566; font-weight: 600; } .badge { display: inline-block; background: #fce2b7; color: #7b4b00; border: 1px solid #efc57f; border-radius: 5px; padding: 2px 8px; font-size: 13px; white-space: nowrap; } .badge.ok { background: #def5e5; color: #12562b; border-color: #a8ddb8; } .badge.off { background: #eef1f5; color: #5f6a7b; border-color: #d7dde8; } +.schedule-scroll { overflow: visible; } +.schedule-grid { grid-template-columns: 1fr; display: grid; gap: 14px; align-items: start; } +.schedule-column { border: 1px solid var(--border); border-radius: 12px; padding: 12px; background: #f8fafc; } +.schedule-column .br-restore-calendar { border: 0; border-radius: 0; padding: 0; background: transparent; } +.schedule-column .br-time-picker { margin: 12px 0 0; padding-top: 10px; border-top: 1px solid var(--border); } .br-restore-layout { display: grid; grid-template-columns: 280px 1fr; gap: 16px; align-items: start; } .br-restore-calendar { border: 1px solid var(--border); border-radius: 12px; padding: 12px; background: #f8fafc; } .br-restore-calendar h4 { margin: 0 0 10px; font-size: 16px; font-weight: 700; } @@ -74,4 +80,11 @@ th { background: #f6f8fb; color: #4a5566; font-weight: 600; } .summary-card, .preview-box { border: 1px solid var(--border); background: #f8fafc; border-radius: 10px; padding: 12px; display: grid; gap: 8px; } .kv { display: grid; grid-template-columns: 180px 1fr; gap: 6px 10px; font-size: 15px; } .empty-state { min-height: 240px; display: grid; place-items: center; text-align: center; border: 1px dashed #cfd6e3; border-radius: 12px; background: #f8fafc; padding: 24px; } +body.modal-open { overflow: hidden; } +.br-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.6); display: flex; align-items: center; justify-content: center; z-index: 9999; padding: 16px; } +.br-modal { width: min(640px, 94vw); background: #fff; border-radius: 12px; padding: 18px; border: 2px solid var(--border); box-shadow: 0 20px 40px rgba(15, 23, 42, 0.25); max-height: 90vh; overflow: auto; } +.br-modal h3 { margin: 0 0 10px; font-size: 22px; } +.br-modal p { margin: 0 0 10px; line-height: 1.45; } +.br-modal-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; margin-top: 14px; } +.br-modal-actions form { display: inline-flex; margin: 0; } @media (max-width: 760px) { header { display: block; } header h1 { font-size: 30px; margin-bottom: 4px; } .br-restore-layout { grid-template-columns: 1fr; } .top-actions { justify-content: flex-start; } .kv { grid-template-columns: 1fr; } .subject-prefix-control { grid-template-columns: 1fr; } .subject-prefix-control input { border-radius: 6px 6px 0 0; } .subject-prefix-control span { border-left: 1px solid #cdd4e0; border-top: 0; border-radius: 0 0 6px 6px; min-height: 38px; } } diff --git a/user/evolution.css b/user/evolution.css index f13f476..7b99fca 100644 --- a/user/evolution.css +++ b/user/evolution.css @@ -1 +1,17 @@ -@import url("enhanced.css"); +.wrap { + width: 100% !important; + max-width: none !important; + margin-left: 0 !important; + margin-right: 0 !important; +} +.main-section, +.schedule-panel { + width: 100%; + max-width: none; +} +.schedule-scroll { overflow-x: auto; padding-bottom: 2px; } +.schedule-grid { grid-template-columns: repeat(2, minmax(460px, 1fr)); min-width: 960px; } + +@media (min-width: 1240px) { + .schedule-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); min-width: 0; } +}