diff --git a/exec/handlers/_form_helpers.php b/exec/handlers/_form_helpers.php index 5ff5347..6675532 100644 --- a/exec/handlers/_form_helpers.php +++ b/exec/handlers/_form_helpers.php @@ -5,7 +5,8 @@ function render_rule_form(AppContext $ctx, string $mode, ?array $rule): string { $isUpdate = $mode === 'update'; $id = $isUpdate ? (string)$rule['id'] : ''; - $subject = $rule['subject'] ?? ''; + $subjectPrefix = $rule['subject_prefix'] ?? $rule['subject'] ?? ''; + $replyFrequency = selected_reply_frequency($rule); $body = $rule['body'] ?? ''; $enabled = $rule === null || !empty($rule['enabled']); $startValue = (string)($rule['start_value'] ?? '2026-06-10T09:00'); @@ -16,7 +17,8 @@ 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('Response content')) . '

' . AppContext::e($ctx->t('Original subject suffix')) . '
' . + '
' . render_reply_frequency_select($ctx, $replyFrequency) . '
' . '
' . '
' . render_calendar_picker($ctx, 'start_value', $ctx->t('Start date'), $startValue) . @@ -25,6 +27,40 @@ function render_rule_form(AppContext $ctx, string $mode, ?array $rule): string ''; } +/** @param array|null $rule */ +function selected_reply_frequency(?array $rule): string +{ + if ($rule === null) { + return '1440'; + } + if (!empty($rule['reply_every_message'])) { + return 'every'; + } + $minutes = (string)(int)($rule['repeat_minutes'] ?? 1440); + return in_array($minutes, ['30', '60', '120', '360', '720', '1440', '2880', '10080'], true) ? $minutes : '1440'; +} + +function render_reply_frequency_select(AppContext $ctx, string $selected): string +{ + $options = [ + 'every' => 'Send automatic reply for every message', + '30' => '30 minutes', + '60' => '1 hour', + '120' => '2 hours', + '360' => '6 hours', + '720' => '12 hours', + '1440' => '1 day', + '2880' => '2 days', + '10080' => '7 days', + ]; + $html = ''; +} + /** @param array $post @return array */ function normalize_rule_post(array $post): array { @@ -104,7 +140,8 @@ function render_calendar_picker(AppContext $ctx, string $name, string $title, st function render_preview_box(AppContext $ctx, array $rule): string { - return '
' . AppContext::e($ctx->t('Subject')) . ': ' . AppContext::e((string)$rule['subject']) . '

' . nl2br(AppContext::e((string)$rule['body'])) . '
'; + $subjectPrefix = (string)($rule['subject_prefix'] ?? $rule['subject'] ?? ''); + return '
' . AppContext::e($ctx->t('Subject prefix')) . ': ' . AppContext::e($subjectPrefix) . '

' . nl2br(AppContext::e((string)$rule['body'])) . '
'; } function render_weekday_headers(AppContext $ctx): string diff --git a/exec/handlers/delete.php b/exec/handlers/delete.php index 9a2d9f0..d9c225f 100644 --- a/exec/handlers/delete.php +++ b/exec/handlers/delete.php @@ -13,7 +13,7 @@ return static function (AppContext $ctx): void { $ctx->requireCsrf('delete:' . $id); $ctx->rules->delete($ctx->daUser->username(), $id); $ctx->syncActiveBackend(); - $ctx->audit->record('delete', $ctx->daUser->username(), ['rule_id' => $id, 'subject' => (string)$rule['subject']]); + $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'])); } catch (Throwable $e) { $ctx->render('Confirm deletion', '
' . AppContext::e($e->getMessage()) . '
'); diff --git a/exec/handlers/index.php b/exec/handlers/index.php index 75c2b35..35bf9af 100644 --- a/exec/handlers/index.php +++ b/exec/handlers/index.php @@ -22,8 +22,9 @@ return static function (AppContext $ctx): void { 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((string)$rule['subject']) . '
' . date('Y-m-d H:i', (int)($rule['updated_at'] ?? time())) . ''; + $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 .= '' . AppContext::e($ctx->t($rule['dynamic_scope'] ? 'Dynamic' : 'Snapshot')) . ''; $rows .= '' . AppContext::e($ctx->t('Edit')) . ''; @@ -35,6 +36,6 @@ return static function (AppContext $ctx): void { $body = '

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

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

' . '' . AppContext::e($ctx->t('Add autoresponder')) . '
' . - '' . $rows . '
' . AppContext::e($ctx->t('Status')) . '' . AppContext::e($ctx->t('Subject')) . '' . AppContext::e($ctx->t('Sending period')) . '' . AppContext::e($ctx->t('Scope')) . '' . AppContext::e($ctx->t('Actions')) . '
'; + '' . $rows . '
' . AppContext::e($ctx->t('Status')) . '' . AppContext::e($ctx->t('Subject prefix')) . '' . AppContext::e($ctx->t('Sending period')) . '' . AppContext::e($ctx->t('Scope')) . '' . AppContext::e($ctx->t('Actions')) . '
'; $ctx->render('Global autoresponders', $body, $ok); }; diff --git a/exec/lib/DirectAdminVacationApi.php b/exec/lib/DirectAdminVacationApi.php index 0bf4475..7b8adbb 100644 --- a/exec/lib/DirectAdminVacationApi.php +++ b/exec/lib/DirectAdminVacationApi.php @@ -24,13 +24,18 @@ class DirectAdminVacationApi [$endDate, $endTime] = $this->splitPeriod((string)($rule['end_value'] ?? '')); [$startYear, $startMonth, $startDay] = explode('-', $startDate); [$endYear, $endMonth, $endDay] = explode('-', $endDate); + $subjectPrefix = (string)($rule['subject_prefix'] ?? $rule['subject'] ?? ''); + $replyFrequency = !empty($rule['reply_every_message']) ? '0' : (string)max(0, (int)($rule['repeat_minutes'] ?? 1440)); return [ 'action' => $exists ? 'modify' : 'create', 'domain' => $domain, 'user' => $local, 'email' => $email, 'text' => (string)($rule['body'] ?? ''), - 'subject' => (string)($rule['subject'] ?? ''), + 'subject_prefix' => $subjectPrefix, + 'subject' => $subjectPrefix, + 'reply_frequency' => $replyFrequency, + 'reply_once_time' => $replyFrequency, 'starttime' => $startTime, 'startyear' => $startYear, 'startmonth' => $startMonth, diff --git a/exec/lib/RuleValidator.php b/exec/lib/RuleValidator.php index d608669..df9cdf9 100644 --- a/exec/lib/RuleValidator.php +++ b/exec/lib/RuleValidator.php @@ -3,18 +3,27 @@ declare(strict_types=1); final class RuleValidator { + /** @var array */ + private const REPLY_FREQUENCIES = [ + '30' => 30, + '60' => 60, + '120' => 120, + '360' => 360, + '720' => 720, + '1440' => 1440, + '2880' => 2880, + '10080' => 10080, + ]; + /** @param array $input @return array */ public static function validate(array $input, Settings $settings): array { - $subject = trim((string)($input['subject'] ?? '')); - if ($subject === '') { - throw new InvalidArgumentException('Temat jest wymagany.'); + $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.'); } - if (str_contains($subject, "\r") || str_contains($subject, "\n")) { - throw new InvalidArgumentException('Temat nie może zawierać nowych linii.'); - } - if (strlen($subject) > $settings->maxSubjectBytes()) { - throw new InvalidArgumentException('Temat jest za długi.'); + if (strlen($subjectPrefix) > $settings->maxSubjectBytes()) { + throw new InvalidArgumentException('Przedrostek tematu jest za długi.'); } $body = str_replace(["\r\n", "\r", "\0"], ["\n", "\n", ''], (string)($input['body'] ?? '')); @@ -33,9 +42,10 @@ final class RuleValidator 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')); return [ - 'subject' => $subject, + 'subject_prefix' => $subjectPrefix, 'body' => $body, 'schedule_mode' => $mode, 'timezone' => $settings->timezone(), @@ -44,10 +54,25 @@ 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): array + { + $value = trim(strtolower($value)); + if ($value === 'every') { + return [true, 0]; + } + if (isset(self::REPLY_FREQUENCIES[$value])) { + return [false, self::REPLY_FREQUENCIES[$value]]; + } + throw new InvalidArgumentException('Częstość odpowiedzi jest nieprawidłowa.'); + } + /** @return array{0:int,1:string} */ private static function parseScheduleValue(string $value, string $mode, DateTimeZone $tz, bool $start): array { diff --git a/exec/lib/Settings.php b/exec/lib/Settings.php index 5407cf3..ac8adc9 100644 --- a/exec/lib/Settings.php +++ b/exec/lib/Settings.php @@ -52,8 +52,6 @@ final class Settings 'DEFAULT_ENABLE' => 'true', 'GLOBAL_AUTORESPONDER_TIMEZONE' => 'Europe/Warsaw', 'GLOBAL_AUTORESPONDER_BACKEND' => 'da', - 'GLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE' => 'false', - 'GLOBAL_AUTORESPONDER_REPEAT_MINUTES' => '1440', 'GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE' => 'true', 'GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES' => '255', 'GLOBAL_AUTORESPONDER_MAX_BODY_BYTES' => '20000', @@ -103,16 +101,6 @@ final class Settings return $this->backendMode() === 'da' ? 'directadmin_period' : 'exact_hour'; } - public function replyEveryMessage(): bool - { - return $this->getBool('GLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE', false); - } - - public function repeatMinutes(): int - { - return $this->getInt('GLOBAL_AUTORESPONDER_REPEAT_MINUTES', 1440); - } - public function dynamicMailboxScope(): bool { return $this->getBool('GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE', true); @@ -156,9 +144,6 @@ final class Settings if (!in_array($this->backendMode(), ['da', 'exim'], true)) { throw new InvalidArgumentException('Invalid backend mode: ' . $this->backendMode()); } - if ($this->repeatMinutes() < 0) { - throw new InvalidArgumentException('Invalid repeat minutes'); - } if ($this->maxSubjectBytes() < 1 || $this->maxBodyBytes() < 1) { throw new InvalidArgumentException('Invalid size limits'); } diff --git a/lang/en.php b/lang/en.php index 69b6c45..dc7418a 100644 --- a/lang/en.php +++ b/lang/en.php @@ -11,8 +11,20 @@ return [ 'Edit autoresponder' => 'Edit autoresponder', 'Preview autoresponder' => 'Preview 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', + '30 minutes' => '30 minutes', + '1 hour' => '1 hour', + '2 hours' => '2 hours', + '6 hours' => '6 hours', + '12 hours' => '12 hours', + '1 day' => '1 day', + '2 days' => '2 days', + '7 days' => '7 days', 'Status' => 'Status', 'Enabled' => 'Enabled', 'Disabled' => 'Disabled', diff --git a/lang/pl.php b/lang/pl.php index 5f9678a..70b23fe 100644 --- a/lang/pl.php +++ b/lang/pl.php @@ -11,8 +11,20 @@ return [ 'Edit autoresponder' => 'Edytuj autoresponder', 'Preview autoresponder' => 'Podgląd autorespondera', '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', + '30 minutes' => '30 minut', + '1 hour' => '1 godzina', + '2 hours' => '2 godziny', + '6 hours' => '6 godzin', + '12 hours' => '12 godzin', + '1 day' => '1 dzień', + '2 days' => '2 dni', + '7 days' => '7 dni', 'Status' => 'Status', 'Enabled' => 'Włączony', 'Disabled' => 'Wyłączony', diff --git a/plugin-settings.conf b/plugin-settings.conf index 4014c53..baa25ff 100644 --- a/plugin-settings.conf +++ b/plugin-settings.conf @@ -13,18 +13,11 @@ GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw # exim = własna obsługa z możliwością podania dokładnej godziny i minuty. GLOBAL_AUTORESPONDER_BACKEND=da -# Czy odpowiadać automatycznie na każdą kwalifikującą się wiadomość. -GLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE=false - -# Minimalny odstęp w minutach między autoresponderami do tego samego nadawcy, -# używany tylko gdy GLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE=false. -GLOBAL_AUTORESPONDER_REPEAT_MINUTES=1440 - # Czy reguła obejmuje także skrzynki POP/IMAP dodane po utworzeniu reguły. # true = zakres dynamiczny, false = snapshot skrzynek przy tworzeniu/edycji. GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE=true -# Maksymalna długość tematu po przycięciu białych znaków. +# Maksymalna długość przedrostka tematu po przycięciu białych znaków. GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES=255 # Maksymalna długość treści wiadomości po normalizacji. diff --git a/plugin.conf b/plugin.conf index a3bf457..c5d6910 100644 --- a/plugin.conf +++ b/plugin.conf @@ -2,7 +2,7 @@ name=global-autoresponder id=global-autoresponder type=user author=HITME.PL -version=1.0.7 +version=1.0.8 active=no installed=no user_run_as=root diff --git a/scripts/global_autoresponder_worker.php b/scripts/global_autoresponder_worker.php index 95b60dd..04ae9d9 100755 --- a/scripts/global_autoresponder_worker.php +++ b/scripts/global_autoresponder_worker.php @@ -55,15 +55,19 @@ final class GlobalAutoresponderWorker if ($ruleId === '') { continue; } - $repeatMinutes = $this->settings->replyEveryMessage() ? 0 : $this->settings->repeatMinutes(); + $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), - (string)$rule['subject'], + $replySubject, (string)$rule['body'] ); if ($ok) { @@ -155,6 +159,30 @@ final class GlobalAutoresponderWorker } 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__) { diff --git a/tests/bootstrap_test.php b/tests/bootstrap_test.php index b302d32..152cc8f 100644 --- a/tests/bootstrap_test.php +++ b/tests/bootstrap_test.php @@ -11,8 +11,6 @@ test('settings validate defaults and external paths', function (): void { 'DEFAULT_ENABLE=false', 'GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw', 'GLOBAL_AUTORESPONDER_BACKEND=exim', - 'GLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE=false', - 'GLOBAL_AUTORESPONDER_REPEAT_MINUTES=60', 'GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE=true', 'GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES=255', 'GLOBAL_AUTORESPONDER_MAX_BODY_BYTES=20000', @@ -24,11 +22,16 @@ test('settings validate defaults and external paths', function (): void { assert_same('Europe/Warsaw', $settings->timezone()); assert_same('exact_hour', $settings->scheduleMode()); assert_same('exim', $settings->backendMode()); - assert_same(60, $settings->repeatMinutes()); assert_same('/usr/local/hitme_plugins/global-autoresponders/config', Settings::CONFIG_DIR); assert_same('/usr/local/hitme_plugins/global-autoresponders/data', Settings::DATA_DIR); }); +test('settings template does not contain global repeat policy settings', function (): void { + $template = file_get_contents(PLUGIN_ROOT . '/plugin-settings.conf') ?: ''; + assert_false(str_contains($template, 'GLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE')); + assert_false(str_contains($template, 'GLOBAL_AUTORESPONDER_REPEAT_MINUTES')); +}); + 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"); diff --git a/tests/directadmin_vacation_api_test.php b/tests/directadmin_vacation_api_test.php index 6d0a3f0..1671c1a 100644 --- a/tests/directadmin_vacation_api_test.php +++ b/tests/directadmin_vacation_api_test.php @@ -6,7 +6,9 @@ require_once PLUGIN_ROOT . '/exec/lib/DirectAdminVacationApi.php'; test('directadmin vacation api builds scoped payload for one mailbox', function (): void { $api = new DirectAdminVacationApi('/usr/local/directadmin/directadmin'); $payload = $api->buildSavePayload('info@example.com', [ - 'subject' => 'Urlop', + 'subject_prefix' => 'Urlop', + 'reply_every_message' => false, + 'repeat_minutes' => 1440, 'body' => 'Body', 'start_value' => '2026-06-10|morning', 'end_value' => '2026-06-24|evening', @@ -15,6 +17,8 @@ test('directadmin vacation api builds scoped payload for one mailbox', function assert_same('info', $payload['user']); assert_same('example.com', $payload['domain']); assert_same('Body', $payload['text']); + assert_same('Urlop', $payload['subject_prefix']); + assert_same('1440', $payload['reply_frequency']); assert_same('morning', $payload['starttime']); assert_same('evening', $payload['endtime']); }); diff --git a/tests/directadmin_vacation_sync_service_test.php b/tests/directadmin_vacation_sync_service_test.php index 8e33c2d..048a591 100644 --- a/tests/directadmin_vacation_sync_service_test.php +++ b/tests/directadmin_vacation_sync_service_test.php @@ -15,7 +15,9 @@ test('directadmin vacation sync creates tracked entries for enabled rule mailbox $repo = new RuleRepository(TEST_TMP . '/da-sync-data'); $rule = $repo->create('alice', [ - 'subject' => 'Urlop', + 'subject_prefix' => 'Urlop', + 'reply_every_message' => false, + 'repeat_minutes' => 1440, 'body' => 'Body', 'start_value' => '2026-06-10|morning', 'end_value' => '2026-06-24|evening', @@ -34,7 +36,7 @@ test('directadmin vacation sync creates tracked entries for enabled rule mailbox } public function saveVacation(string $owner, string $email, array $rule, bool $exists): void { - $this->calls[] = ['save', $owner, $email, $exists, $rule['subject']]; + $this->calls[] = ['save', $owner, $email, $exists, $rule['subject_prefix']]; } public function deleteVacation(string $owner, string $email): void { @@ -68,7 +70,9 @@ test('directadmin vacation sync deletes stale tracked entries after rule removal $repo = new RuleRepository($dataDir); $syncRepo = new DirectAdminSyncRepository($dataDir); $rule = $repo->create('alice', [ - 'subject' => 'Urlop', + 'subject_prefix' => 'Urlop', + 'reply_every_message' => false, + 'repeat_minutes' => 1440, 'body' => 'Body', 'start_value' => '2026-06-10|morning', 'end_value' => '2026-06-24|evening', @@ -107,7 +111,9 @@ test('directadmin vacation sync refuses to overwrite untracked native vacation', $dataDir = TEST_TMP . '/da-sync-conflict-data'; $repo = new RuleRepository($dataDir); $repo->create('alice', [ - 'subject' => 'Urlop', + 'subject_prefix' => 'Urlop', + 'reply_every_message' => false, + 'repeat_minutes' => 1440, 'body' => 'Body', 'start_value' => '2026-06-10|morning', 'end_value' => '2026-06-24|evening', diff --git a/tests/exim_worker_test.php b/tests/exim_worker_test.php index d2bb632..002f90d 100644 --- a/tests/exim_worker_test.php +++ b/tests/exim_worker_test.php @@ -5,7 +5,7 @@ require_once PLUGIN_ROOT . '/scripts/global_autoresponder_worker.php'; test('exim worker sends active rule for resolved mailbox only', function (): void { $settingsPath = TEST_TMP . '/worker-settings.conf'; - test_write($settingsPath, "GLOBAL_AUTORESPONDER_BACKEND=exim\nGLOBAL_AUTORESPONDER_REPEAT_MINUTES=60\n"); + test_write($settingsPath, "GLOBAL_AUTORESPONDER_BACKEND=exim\n"); $settings = Settings::load($settingsPath); $virtualRoot = TEST_TMP . '/worker-virtual'; @@ -15,6 +15,9 @@ test('exim worker sends active rule for resolved mailbox only', function (): voi $repo = new RuleRepository(TEST_TMP . '/worker-data'); $created = $repo->create('alice', [ 'subject' => 'Urlop', + 'subject_prefix' => 'Urlop', + 'reply_every_message' => false, + 'repeat_minutes' => 60, 'body' => 'Odpowiem później', 'start_ts' => 900, 'end_ts' => 1100, @@ -42,7 +45,7 @@ test('exim worker sends active rule for resolved mailbox only', function (): voi assert_same(1, $count); assert_same('info@example.com', $sent[0]['from']); assert_same('person@example.net', $sent[0]['to']); - assert_same('Urlop', $sent[0]['subject']); + assert_same('Urlop: hello', $sent[0]['subject']); assert_same('Odpowiem później', $sent[0]['body']); $count = $worker->handle( @@ -56,7 +59,7 @@ test('exim worker sends active rule for resolved mailbox only', function (): voi 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\nGLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE=true\n"); + test_write($settingsPath, "GLOBAL_AUTORESPONDER_BACKEND=exim\n"); $settings = Settings::load($settingsPath); $virtualRoot = TEST_TMP . '/worker-suppress-virtual'; @@ -66,7 +69,9 @@ test('exim worker suppresses automated messages and ignores unresolved recipient $repo = new RuleRepository(TEST_TMP . '/worker-suppress-data'); $repo->create('alice', [ - 'subject' => 'Urlop', + 'subject_prefix' => 'Urlop', + 'reply_every_message' => true, + 'repeat_minutes' => 0, 'body' => 'Body', 'start_ts' => 1, 'end_ts' => 2000, @@ -99,7 +104,7 @@ test('exim worker suppresses automated messages and ignores unresolved recipient test('exim worker respects static mailbox snapshot scope', function (): void { $settingsPath = TEST_TMP . '/worker-snapshot-settings.conf'; - test_write($settingsPath, "GLOBAL_AUTORESPONDER_BACKEND=exim\nGLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE=true\n"); + test_write($settingsPath, "GLOBAL_AUTORESPONDER_BACKEND=exim\n"); $settings = Settings::load($settingsPath); $virtualRoot = TEST_TMP . '/worker-snapshot-virtual'; @@ -108,7 +113,9 @@ test('exim worker respects static mailbox snapshot scope', function (): void { $repo = new RuleRepository(TEST_TMP . '/worker-snapshot-data'); $repo->create('alice', [ - 'subject' => 'Urlop', + 'subject_prefix' => 'Urlop', + 'reply_every_message' => true, + 'repeat_minutes' => 0, 'body' => 'Body', 'start_ts' => 1, 'end_ts' => 2000, diff --git a/tests/handler_test.php b/tests/handler_test.php index 4f0879b..ff1cc76 100644 --- a/tests/handler_test.php +++ b/tests/handler_test.php @@ -55,7 +55,11 @@ test('rule form uses minute time input and no summary box in exim mode', functio 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('Temat', $html); + 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_contains('1 dzień', $html); assert_contains('Treść wiadomości', $html); }); @@ -99,7 +103,9 @@ test('calendar picker is based on selected rule month and supports month navigat $html = render_rule_form($ctx, 'update', [ 'id' => 'abc', - 'subject' => 'Urlop', + 'subject_prefix' => 'Urlop', + 'reply_every_message' => false, + 'repeat_minutes' => 1440, 'body' => 'Body', 'enabled' => true, 'start_value' => '2027-02-14T20:00', @@ -132,8 +138,8 @@ test('rule form localizes calendar and preview labels without translating user c assert_false(strpos($html, 'Wybrano') !== false); assert_false(strpos($html, 'Pn') !== false); - $preview = render_preview_box($ctx, ['subject' => 'Delete', 'body' => 'Cancel']); - assert_contains('Subject: Delete', $preview); + $preview = render_preview_box($ctx, ['subject_prefix' => 'Delete', 'body' => 'Cancel']); + assert_contains('Subject prefix: Delete', $preview); assert_contains('Cancel', $preview); }); @@ -143,7 +149,9 @@ test('directadmin backend allows only one enabled global rule per account', func $settings = Settings::load($settingsPath); $repo = new RuleRepository(TEST_TMP . '/single-data'); $repo->create('alice', [ - 'subject' => 'Pierwsza', + 'subject_prefix' => 'Pierwsza', + 'reply_every_message' => false, + 'repeat_minutes' => 1440, 'body' => 'Body', 'enabled' => true, 'start_value' => '2026-06-10|morning', diff --git a/tests/rule_validator_test.php b/tests/rule_validator_test.php index 1e93e2b..ce3c86a 100644 --- a/tests/rule_validator_test.php +++ b/tests/rule_validator_test.php @@ -9,14 +9,17 @@ test('rule validator normalizes exact hour schedule', function (): void { test_write($settingsPath, "GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw\nGLOBAL_AUTORESPONDER_BACKEND=exim\n"); $settings = Settings::load($settingsPath); $rule = RuleValidator::validate([ - 'subject' => 'Urlop', + 'subject_prefix' => 'Urlop', + 'reply_frequency' => '120', 'body' => "Dzień dobry\r\nWracam jutro", 'start_value' => '2026-06-10T09:37', 'end_value' => '2026-06-24T17:12', 'enabled' => '1', ], $settings); - assert_same('Urlop', $rule['subject']); + 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']); assert_same('2026-06-10T09:37', $rule['start_value']); @@ -28,27 +31,47 @@ test('rule validator accepts da period schedule when backend is da', function () test_write($settingsPath, "GLOBAL_AUTORESPONDER_BACKEND=da\n"); $settings = Settings::load($settingsPath); $rule = RuleValidator::validate([ - 'subject' => 'Urlop', + 'subject_prefix' => 'Urlop', + 'reply_frequency' => 'every', 'body' => 'Body', 'start_value' => '2026-06-10|morning', 'end_value' => '2026-06-24|evening', 'enabled' => '1', ], $settings); assert_same('directadmin_period', $rule['schedule_mode']); + assert_true($rule['reply_every_message']); + assert_same(0, $rule['repeat_minutes']); }); test('rule validator rejects header injection and reversed dates', function (): void { $settings = Settings::load(TEST_TMP . '/missing.conf'); try { RuleValidator::validate([ - 'subject' => "Bad\nSubject", + 'subject_prefix' => "Bad\nSubject", 'body' => 'Body', 'start_value' => '2026-06-24T17:00', 'end_value' => '2026-06-10T09:00', ], $settings); } catch (InvalidArgumentException $e) { - assert_contains('Temat', $e->getMessage()); + assert_contains('Przedrostek tematu', $e->getMessage()); return; } throw new RuntimeException('Expected validation failure'); }); + +test('rule validator rejects unsupported response frequency', function (): void { + $settings = Settings::load(TEST_TMP . '/missing-frequency.conf'); + try { + RuleValidator::validate([ + 'subject_prefix' => 'Urlop', + 'reply_frequency' => '45', + 'body' => 'Body', + 'start_value' => '2026-06-10|morning', + 'end_value' => '2026-06-24|evening', + ], $settings); + } catch (InvalidArgumentException $e) { + assert_contains('Częstość odpowiedzi', $e->getMessage()); + return; + } + throw new RuntimeException('Expected invalid frequency to fail'); +}); diff --git a/user/enhanced.css b/user/enhanced.css index f9b3ac8..2877ec0 100644 --- a/user/enhanced.css +++ b/user/enhanced.css @@ -33,8 +33,11 @@ header p { margin: 0; color: var(--amber-strong); font-size: 15px; } .grid-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } .row { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; margin-bottom: 12px; } label { display: block; margin-bottom: 6px; font-weight: 600; font-size: 15px; } -input[type="text"], select, textarea { width: 100%; border: 1px solid #cdd4e0; border-radius: 6px; padding: 10px 12px; font-size: 15px; background: #fff; } +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; } +.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; } button, .btn { border: 1px solid #b7c6da; background: #fff; color: #1f2d3d; border-radius: 999px; padding: 8px 14px; cursor: pointer; text-decoration: none; font-size: 13px; line-height: 1; display: inline-flex; align-items: center; gap: 6px; } .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); } @@ -71,4 +74,4 @@ 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; } -@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; } } +@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; } }