' . 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('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')) . ' | ' . $rows . ' ';
$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; } }
|