feat: use vacation subject prefix

This commit is contained in:
Marek Miklewicz
2026-06-02 21:28:31 +02:00
parent 64b62a5793
commit 56709817b5
18 changed files with 220 additions and 68 deletions
+40 -3
View File
@@ -5,7 +5,8 @@ function render_rule_form(AppContext $ctx, string $mode, ?array $rule): string
{ {
$isUpdate = $mode === 'update'; $isUpdate = $mode === 'update';
$id = $isUpdate ? (string)$rule['id'] : ''; $id = $isUpdate ? (string)$rule['id'] : '';
$subject = $rule['subject'] ?? ''; $subjectPrefix = $rule['subject_prefix'] ?? $rule['subject'] ?? '';
$replyFrequency = selected_reply_frequency($rule);
$body = $rule['body'] ?? ''; $body = $rule['body'] ?? '';
$enabled = $rule === null || !empty($rule['enabled']); $enabled = $rule === null || !empty($rule['enabled']);
$startValue = (string)($rule['start_value'] ?? '2026-06-10T09:00'); $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 ? '<input type="hidden" name="id" value="' . AppContext::e($id) . '">' : ''; $hiddenId = $isUpdate ? '<input type="hidden" name="id" value="' . AppContext::e($id) . '">' : '';
return '<form method="post" action="' . AppContext::e($action) . '">' . $csrf . $hiddenId . return '<form method="post" action="' . AppContext::e($action) . '">' . $csrf . $hiddenId .
'<div class="panel"><h3>' . AppContext::e($ctx->t('Response content')) . '</h3><div class="row"><div><label>' . AppContext::e($ctx->t('Subject')) . '</label><input type="text" name="subject" value="' . AppContext::e((string)$subject) . '"></div>' . '<div class="panel"><h3>' . AppContext::e($ctx->t('Response content')) . '</h3><div class="row"><div><label>' . AppContext::e($ctx->t('Subject prefix')) . '</label><div class="subject-prefix-control"><input type="text" name="subject_prefix" value="' . AppContext::e((string)$subjectPrefix) . '"><span>' . AppContext::e($ctx->t('Original subject suffix')) . '</span></div></div>' .
'<div><label>' . AppContext::e($ctx->t('Response frequency')) . '</label>' . render_reply_frequency_select($ctx, $replyFrequency) . '</div>' .
'<div><label>' . AppContext::e($ctx->t('Status')) . '</label><label class="br-time-item"><input type="checkbox" name="enabled" value="1" ' . ($enabled ? 'checked' : '') . '> ' . AppContext::e($ctx->t('Enabled')) . '</label></div></div>' . '<div><label>' . AppContext::e($ctx->t('Status')) . '</label><label class="br-time-item"><input type="checkbox" name="enabled" value="1" ' . ($enabled ? 'checked' : '') . '> ' . AppContext::e($ctx->t('Enabled')) . '</label></div></div>' .
'<label>' . AppContext::e($ctx->t('Message body')) . '</label><textarea name="body">' . AppContext::e((string)$body) . '</textarea></div>' . '<label>' . AppContext::e($ctx->t('Message body')) . '</label><textarea name="body">' . AppContext::e((string)$body) . '</textarea></div>' .
render_calendar_picker($ctx, 'start_value', $ctx->t('Start date'), $startValue) . 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
'</form>'; '</form>';
} }
/** @param array<string,mixed>|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 = '<select name="reply_frequency">';
foreach ($options as $value => $label) {
$value = (string)$value;
$html .= '<option value="' . AppContext::e($value) . '"' . ($selected === $value ? ' selected' : '') . '>' . AppContext::e($ctx->t($label)) . '</option>';
}
return $html . '</select>';
}
/** @param array<string,mixed> $post @return array<string,mixed> */ /** @param array<string,mixed> $post @return array<string,mixed> */
function normalize_rule_post(array $post): 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 function render_preview_box(AppContext $ctx, array $rule): string
{ {
return '<div class="preview-box"><div><strong>' . AppContext::e($ctx->t('Subject')) . ':</strong> ' . AppContext::e((string)$rule['subject']) . '</div><br><div>' . nl2br(AppContext::e((string)$rule['body'])) . '</div></div>'; $subjectPrefix = (string)($rule['subject_prefix'] ?? $rule['subject'] ?? '');
return '<div class="preview-box"><div><strong>' . AppContext::e($ctx->t('Subject prefix')) . ':</strong> ' . AppContext::e($subjectPrefix) . '</div><br><div>' . nl2br(AppContext::e((string)$rule['body'])) . '</div></div>';
} }
function render_weekday_headers(AppContext $ctx): string function render_weekday_headers(AppContext $ctx): string
+1 -1
View File
@@ -13,7 +13,7 @@ return static function (AppContext $ctx): void {
$ctx->requireCsrf('delete:' . $id); $ctx->requireCsrf('delete:' . $id);
$ctx->rules->delete($ctx->daUser->username(), $id); $ctx->rules->delete($ctx->daUser->username(), $id);
$ctx->syncActiveBackend(); $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'])); Http::redirect($ctx->url('index.html', ['status' => 'deleted']));
} catch (Throwable $e) { } catch (Throwable $e) {
$ctx->render('Confirm deletion', '<div class="alert alert-err">' . AppContext::e($e->getMessage()) . '</div>'); $ctx->render('Confirm deletion', '<div class="alert alert-err">' . AppContext::e($e->getMessage()) . '</div>');
+3 -2
View File
@@ -22,8 +22,9 @@ return static function (AppContext $ctx): void {
foreach ($rules as $rule) { foreach ($rules as $rule) {
$id = (string)$rule['id']; $id = (string)$rule['id'];
$enabled = !empty($rule['enabled']); $enabled = !empty($rule['enabled']);
$subjectPrefix = (string)($rule['subject_prefix'] ?? $rule['subject'] ?? '');
$rows .= '<tr><td><span class="badge ' . ($enabled ? 'ok' : 'off') . '">' . AppContext::e($ctx->t($enabled ? 'Enabled' : 'Disabled')) . '</span></td>'; $rows .= '<tr><td><span class="badge ' . ($enabled ? 'ok' : 'off') . '">' . AppContext::e($ctx->t($enabled ? 'Enabled' : 'Disabled')) . '</span></td>';
$rows .= '<td>' . AppContext::e((string)$rule['subject']) . '<br><span class="muted">' . date('Y-m-d H:i', (int)($rule['updated_at'] ?? time())) . '</span></td>'; $rows .= '<td>' . AppContext::e($subjectPrefix) . '<br><span class="muted">' . date('Y-m-d H:i', (int)($rule['updated_at'] ?? time())) . '</span></td>';
$rows .= '<td>' . AppContext::e(date('Y-m-d H:i', (int)$rule['start_ts'])) . '<br><span class="muted">' . AppContext::e($ctx->t('To')) . ' ' . AppContext::e(date('Y-m-d H:i', (int)$rule['end_ts'])) . '</span></td>'; $rows .= '<td>' . AppContext::e(date('Y-m-d H:i', (int)$rule['start_ts'])) . '<br><span class="muted">' . AppContext::e($ctx->t('To')) . ' ' . AppContext::e(date('Y-m-d H:i', (int)$rule['end_ts'])) . '</span></td>';
$rows .= '<td>' . AppContext::e($ctx->t($rule['dynamic_scope'] ? 'Dynamic' : 'Snapshot')) . '</td><td><span class="table-actions">'; $rows .= '<td>' . AppContext::e($ctx->t($rule['dynamic_scope'] ? 'Dynamic' : 'Snapshot')) . '</td><td><span class="table-actions">';
$rows .= '<a class="btn" href="' . AppContext::e($ctx->url('update.html', ['id' => $id])) . '">' . AppContext::e($ctx->t('Edit')) . '</a>'; $rows .= '<a class="btn" href="' . AppContext::e($ctx->url('update.html', ['id' => $id])) . '">' . AppContext::e($ctx->t('Edit')) . '</a>';
@@ -35,6 +36,6 @@ return static function (AppContext $ctx): void {
$body = '<div class="panel"><div class="panel-head"><div><h3>' . AppContext::e($ctx->t('Global autoresponders')) . '</h3><p class="muted">' . AppContext::e($ctx->t('All valid POP/IMAP mailboxes in this account')) . '</p></div>' . $body = '<div class="panel"><div class="panel-head"><div><h3>' . AppContext::e($ctx->t('Global autoresponders')) . '</h3><p class="muted">' . AppContext::e($ctx->t('All valid POP/IMAP mailboxes in this account')) . '</p></div>' .
'<a class="btn amber" href="' . AppContext::e($ctx->url('create.html')) . '">' . AppContext::e($ctx->t('Add autoresponder')) . '</a></div>' . '<a class="btn amber" href="' . AppContext::e($ctx->url('create.html')) . '">' . AppContext::e($ctx->t('Add autoresponder')) . '</a></div>' .
'<table><thead><tr><th>' . AppContext::e($ctx->t('Status')) . '</th><th>' . AppContext::e($ctx->t('Subject')) . '</th><th>' . AppContext::e($ctx->t('Sending period')) . '</th><th>' . AppContext::e($ctx->t('Scope')) . '</th><th>' . AppContext::e($ctx->t('Actions')) . '</th></tr></thead><tbody>' . $rows . '</tbody></table></div>'; '<table><thead><tr><th>' . AppContext::e($ctx->t('Status')) . '</th><th>' . AppContext::e($ctx->t('Subject prefix')) . '</th><th>' . AppContext::e($ctx->t('Sending period')) . '</th><th>' . AppContext::e($ctx->t('Scope')) . '</th><th>' . AppContext::e($ctx->t('Actions')) . '</th></tr></thead><tbody>' . $rows . '</tbody></table></div>';
$ctx->render('Global autoresponders', $body, $ok); $ctx->render('Global autoresponders', $body, $ok);
}; };
+6 -1
View File
@@ -24,13 +24,18 @@ class DirectAdminVacationApi
[$endDate, $endTime] = $this->splitPeriod((string)($rule['end_value'] ?? '')); [$endDate, $endTime] = $this->splitPeriod((string)($rule['end_value'] ?? ''));
[$startYear, $startMonth, $startDay] = explode('-', $startDate); [$startYear, $startMonth, $startDay] = explode('-', $startDate);
[$endYear, $endMonth, $endDay] = explode('-', $endDate); [$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 [ return [
'action' => $exists ? 'modify' : 'create', 'action' => $exists ? 'modify' : 'create',
'domain' => $domain, 'domain' => $domain,
'user' => $local, 'user' => $local,
'email' => $email, 'email' => $email,
'text' => (string)($rule['body'] ?? ''), 'text' => (string)($rule['body'] ?? ''),
'subject' => (string)($rule['subject'] ?? ''), 'subject_prefix' => $subjectPrefix,
'subject' => $subjectPrefix,
'reply_frequency' => $replyFrequency,
'reply_once_time' => $replyFrequency,
'starttime' => $startTime, 'starttime' => $startTime,
'startyear' => $startYear, 'startyear' => $startYear,
'startmonth' => $startMonth, 'startmonth' => $startMonth,
+34 -9
View File
@@ -3,18 +3,27 @@ declare(strict_types=1);
final class RuleValidator final class RuleValidator
{ {
/** @var array<string,int> */
private const REPLY_FREQUENCIES = [
'30' => 30,
'60' => 60,
'120' => 120,
'360' => 360,
'720' => 720,
'1440' => 1440,
'2880' => 2880,
'10080' => 10080,
];
/** @param array<string,mixed> $input @return array<string,mixed> */ /** @param array<string,mixed> $input @return array<string,mixed> */
public static function validate(array $input, Settings $settings): array public static function validate(array $input, Settings $settings): array
{ {
$subject = trim((string)($input['subject'] ?? '')); $subjectPrefix = trim((string)($input['subject_prefix'] ?? $input['subject'] ?? ''));
if ($subject === '') { if (str_contains($subjectPrefix, "\r") || str_contains($subjectPrefix, "\n")) {
throw new InvalidArgumentException('Temat jest wymagany.'); throw new InvalidArgumentException('Przedrostek tematu nie może zawierać nowych linii.');
} }
if (str_contains($subject, "\r") || str_contains($subject, "\n")) { if (strlen($subjectPrefix) > $settings->maxSubjectBytes()) {
throw new InvalidArgumentException('Temat nie może zawierać nowych linii.'); throw new InvalidArgumentException('Przedrostek tematu jest za długi.');
}
if (strlen($subject) > $settings->maxSubjectBytes()) {
throw new InvalidArgumentException('Temat jest za długi.');
} }
$body = str_replace(["\r\n", "\r", "\0"], ["\n", "\n", ''], (string)($input['body'] ?? '')); $body = str_replace(["\r\n", "\r", "\0"], ["\n", "\n", ''], (string)($input['body'] ?? ''));
@@ -33,9 +42,10 @@ final class RuleValidator
if ($endTs <= $startTs) { if ($endTs <= $startTs) {
throw new InvalidArgumentException('Data zakończenia musi być późniejsza niż data rozpoczęcia.'); throw new InvalidArgumentException('Data zakończenia musi być późniejsza niż data rozpoczęcia.');
} }
[$replyEveryMessage, $repeatMinutes] = self::parseReplyFrequency((string)($input['reply_frequency'] ?? '1440'));
return [ return [
'subject' => $subject, 'subject_prefix' => $subjectPrefix,
'body' => $body, 'body' => $body,
'schedule_mode' => $mode, 'schedule_mode' => $mode,
'timezone' => $settings->timezone(), 'timezone' => $settings->timezone(),
@@ -44,10 +54,25 @@ final class RuleValidator
'start_ts' => $startTs, 'start_ts' => $startTs,
'end_ts' => $endTs, 'end_ts' => $endTs,
'enabled' => self::truthy($input['enabled'] ?? false), 'enabled' => self::truthy($input['enabled'] ?? false),
'reply_every_message' => $replyEveryMessage,
'repeat_minutes' => $repeatMinutes,
'dynamic_scope' => $settings->dynamicMailboxScope(), '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} */ /** @return array{0:int,1:string} */
private static function parseScheduleValue(string $value, string $mode, DateTimeZone $tz, bool $start): array private static function parseScheduleValue(string $value, string $mode, DateTimeZone $tz, bool $start): array
{ {
-15
View File
@@ -52,8 +52,6 @@ final class Settings
'DEFAULT_ENABLE' => 'true', 'DEFAULT_ENABLE' => 'true',
'GLOBAL_AUTORESPONDER_TIMEZONE' => 'Europe/Warsaw', 'GLOBAL_AUTORESPONDER_TIMEZONE' => 'Europe/Warsaw',
'GLOBAL_AUTORESPONDER_BACKEND' => 'da', 'GLOBAL_AUTORESPONDER_BACKEND' => 'da',
'GLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE' => 'false',
'GLOBAL_AUTORESPONDER_REPEAT_MINUTES' => '1440',
'GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE' => 'true', 'GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE' => 'true',
'GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES' => '255', 'GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES' => '255',
'GLOBAL_AUTORESPONDER_MAX_BODY_BYTES' => '20000', 'GLOBAL_AUTORESPONDER_MAX_BODY_BYTES' => '20000',
@@ -103,16 +101,6 @@ final class Settings
return $this->backendMode() === 'da' ? 'directadmin_period' : 'exact_hour'; 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 public function dynamicMailboxScope(): bool
{ {
return $this->getBool('GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE', true); return $this->getBool('GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE', true);
@@ -156,9 +144,6 @@ final class Settings
if (!in_array($this->backendMode(), ['da', 'exim'], true)) { if (!in_array($this->backendMode(), ['da', 'exim'], true)) {
throw new InvalidArgumentException('Invalid backend mode: ' . $this->backendMode()); 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) { if ($this->maxSubjectBytes() < 1 || $this->maxBodyBytes() < 1) {
throw new InvalidArgumentException('Invalid size limits'); throw new InvalidArgumentException('Invalid size limits');
} }
+12
View File
@@ -11,8 +11,20 @@ return [
'Edit autoresponder' => 'Edit autoresponder', 'Edit autoresponder' => 'Edit autoresponder',
'Preview autoresponder' => 'Preview autoresponder', 'Preview autoresponder' => 'Preview autoresponder',
'Subject' => 'Subject', 'Subject' => 'Subject',
'Subject prefix' => 'Subject prefix',
'Original subject suffix' => ': original subject',
'Message body' => 'Message body', 'Message body' => 'Message body',
'Response content' => 'Response content', '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', 'Status' => 'Status',
'Enabled' => 'Enabled', 'Enabled' => 'Enabled',
'Disabled' => 'Disabled', 'Disabled' => 'Disabled',
+12
View File
@@ -11,8 +11,20 @@ return [
'Edit autoresponder' => 'Edytuj autoresponder', 'Edit autoresponder' => 'Edytuj autoresponder',
'Preview autoresponder' => 'Podgląd autorespondera', 'Preview autoresponder' => 'Podgląd autorespondera',
'Subject' => 'Temat', 'Subject' => 'Temat',
'Subject prefix' => 'Przedrostek tematu',
'Original subject suffix' => ': pierwotny temat',
'Message body' => 'Treść wiadomości', 'Message body' => 'Treść wiadomości',
'Response content' => 'Treść odpowiedzi', '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', 'Status' => 'Status',
'Enabled' => 'Włączony', 'Enabled' => 'Włączony',
'Disabled' => 'Wyłączony', 'Disabled' => 'Wyłączony',
+1 -8
View File
@@ -13,18 +13,11 @@ GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw
# exim = własna obsługa z możliwością podania dokładnej godziny i minuty. # exim = własna obsługa z możliwością podania dokładnej godziny i minuty.
GLOBAL_AUTORESPONDER_BACKEND=da 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. # Czy reguła obejmuje także skrzynki POP/IMAP dodane po utworzeniu reguły.
# true = zakres dynamiczny, false = snapshot skrzynek przy tworzeniu/edycji. # true = zakres dynamiczny, false = snapshot skrzynek przy tworzeniu/edycji.
GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE=true 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 GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES=255
# Maksymalna długość treści wiadomości po normalizacji. # Maksymalna długość treści wiadomości po normalizacji.
+1 -1
View File
@@ -2,7 +2,7 @@ name=global-autoresponder
id=global-autoresponder id=global-autoresponder
type=user type=user
author=HITME.PL author=HITME.PL
version=1.0.7 version=1.0.8
active=no active=no
installed=no installed=no
user_run_as=root user_run_as=root
+30 -2
View File
@@ -55,15 +55,19 @@ final class GlobalAutoresponderWorker
if ($ruleId === '') { if ($ruleId === '') {
continue; 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)) { if (!$this->repeat->shouldSend($owner, $ruleId, $recipient, $sender, $repeatMinutes)) {
continue; continue;
} }
$replySubject = self::replySubject(
(string)($rule['subject_prefix'] ?? $rule['subject'] ?? ''),
self::headerValue($headers, 'subject')
);
$ok = (bool)call_user_func( $ok = (bool)call_user_func(
$this->send, $this->send,
$recipient, $recipient,
strtolower($sender), strtolower($sender),
(string)$rule['subject'], $replySubject,
(string)$rule['body'] (string)$rule['body']
); );
if ($ok) { if ($ok) {
@@ -155,6 +159,30 @@ final class GlobalAutoresponderWorker
} }
return $headers; return $headers;
} }
/** @param array<string,string> $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__) { if (PHP_SAPI === 'cli' && realpath((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
+6 -3
View File
@@ -11,8 +11,6 @@ test('settings validate defaults and external paths', function (): void {
'DEFAULT_ENABLE=false', 'DEFAULT_ENABLE=false',
'GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw', 'GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw',
'GLOBAL_AUTORESPONDER_BACKEND=exim', 'GLOBAL_AUTORESPONDER_BACKEND=exim',
'GLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE=false',
'GLOBAL_AUTORESPONDER_REPEAT_MINUTES=60',
'GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE=true', 'GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE=true',
'GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES=255', 'GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES=255',
'GLOBAL_AUTORESPONDER_MAX_BODY_BYTES=20000', '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('Europe/Warsaw', $settings->timezone());
assert_same('exact_hour', $settings->scheduleMode()); assert_same('exact_hour', $settings->scheduleMode());
assert_same('exim', $settings->backendMode()); 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/config', Settings::CONFIG_DIR);
assert_same('/usr/local/hitme_plugins/global-autoresponders/data', Settings::DATA_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 { test('settings reject invalid timezone and schedule mode', function (): void {
$path = TEST_TMP . '/bad-settings.conf'; $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\nGLOBAL_AUTORESPONDER_BACKEND=bad\n");
+5 -1
View File
@@ -6,7 +6,9 @@ require_once PLUGIN_ROOT . '/exec/lib/DirectAdminVacationApi.php';
test('directadmin vacation api builds scoped payload for one mailbox', function (): void { test('directadmin vacation api builds scoped payload for one mailbox', function (): void {
$api = new DirectAdminVacationApi('/usr/local/directadmin/directadmin'); $api = new DirectAdminVacationApi('/usr/local/directadmin/directadmin');
$payload = $api->buildSavePayload('info@example.com', [ $payload = $api->buildSavePayload('info@example.com', [
'subject' => 'Urlop', 'subject_prefix' => 'Urlop',
'reply_every_message' => false,
'repeat_minutes' => 1440,
'body' => 'Body', 'body' => 'Body',
'start_value' => '2026-06-10|morning', 'start_value' => '2026-06-10|morning',
'end_value' => '2026-06-24|evening', '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('info', $payload['user']);
assert_same('example.com', $payload['domain']); assert_same('example.com', $payload['domain']);
assert_same('Body', $payload['text']); assert_same('Body', $payload['text']);
assert_same('Urlop', $payload['subject_prefix']);
assert_same('1440', $payload['reply_frequency']);
assert_same('morning', $payload['starttime']); assert_same('morning', $payload['starttime']);
assert_same('evening', $payload['endtime']); assert_same('evening', $payload['endtime']);
}); });
@@ -15,7 +15,9 @@ test('directadmin vacation sync creates tracked entries for enabled rule mailbox
$repo = new RuleRepository(TEST_TMP . '/da-sync-data'); $repo = new RuleRepository(TEST_TMP . '/da-sync-data');
$rule = $repo->create('alice', [ $rule = $repo->create('alice', [
'subject' => 'Urlop', 'subject_prefix' => 'Urlop',
'reply_every_message' => false,
'repeat_minutes' => 1440,
'body' => 'Body', 'body' => 'Body',
'start_value' => '2026-06-10|morning', 'start_value' => '2026-06-10|morning',
'end_value' => '2026-06-24|evening', '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 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 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); $repo = new RuleRepository($dataDir);
$syncRepo = new DirectAdminSyncRepository($dataDir); $syncRepo = new DirectAdminSyncRepository($dataDir);
$rule = $repo->create('alice', [ $rule = $repo->create('alice', [
'subject' => 'Urlop', 'subject_prefix' => 'Urlop',
'reply_every_message' => false,
'repeat_minutes' => 1440,
'body' => 'Body', 'body' => 'Body',
'start_value' => '2026-06-10|morning', 'start_value' => '2026-06-10|morning',
'end_value' => '2026-06-24|evening', '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'; $dataDir = TEST_TMP . '/da-sync-conflict-data';
$repo = new RuleRepository($dataDir); $repo = new RuleRepository($dataDir);
$repo->create('alice', [ $repo->create('alice', [
'subject' => 'Urlop', 'subject_prefix' => 'Urlop',
'reply_every_message' => false,
'repeat_minutes' => 1440,
'body' => 'Body', 'body' => 'Body',
'start_value' => '2026-06-10|morning', 'start_value' => '2026-06-10|morning',
'end_value' => '2026-06-24|evening', 'end_value' => '2026-06-24|evening',
+13 -6
View File
@@ -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 { test('exim worker sends active rule for resolved mailbox only', function (): void {
$settingsPath = TEST_TMP . '/worker-settings.conf'; $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); $settings = Settings::load($settingsPath);
$virtualRoot = TEST_TMP . '/worker-virtual'; $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'); $repo = new RuleRepository(TEST_TMP . '/worker-data');
$created = $repo->create('alice', [ $created = $repo->create('alice', [
'subject' => 'Urlop', 'subject' => 'Urlop',
'subject_prefix' => 'Urlop',
'reply_every_message' => false,
'repeat_minutes' => 60,
'body' => 'Odpowiem później', 'body' => 'Odpowiem później',
'start_ts' => 900, 'start_ts' => 900,
'end_ts' => 1100, '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(1, $count);
assert_same('info@example.com', $sent[0]['from']); assert_same('info@example.com', $sent[0]['from']);
assert_same('person@example.net', $sent[0]['to']); 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']); assert_same('Odpowiem później', $sent[0]['body']);
$count = $worker->handle( $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 { test('exim worker suppresses automated messages and ignores unresolved recipients', function (): void {
$settingsPath = TEST_TMP . '/worker-suppress-settings.conf'; $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); $settings = Settings::load($settingsPath);
$virtualRoot = TEST_TMP . '/worker-suppress-virtual'; $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 = new RuleRepository(TEST_TMP . '/worker-suppress-data');
$repo->create('alice', [ $repo->create('alice', [
'subject' => 'Urlop', 'subject_prefix' => 'Urlop',
'reply_every_message' => true,
'repeat_minutes' => 0,
'body' => 'Body', 'body' => 'Body',
'start_ts' => 1, 'start_ts' => 1,
'end_ts' => 2000, '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 { test('exim worker respects static mailbox snapshot scope', function (): void {
$settingsPath = TEST_TMP . '/worker-snapshot-settings.conf'; $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); $settings = Settings::load($settingsPath);
$virtualRoot = TEST_TMP . '/worker-snapshot-virtual'; $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 = new RuleRepository(TEST_TMP . '/worker-snapshot-data');
$repo->create('alice', [ $repo->create('alice', [
'subject' => 'Urlop', 'subject_prefix' => 'Urlop',
'reply_every_message' => true,
'repeat_minutes' => 0,
'body' => 'Body', 'body' => 'Body',
'start_ts' => 1, 'start_ts' => 1,
'end_ts' => 2000, 'end_ts' => 2000,
+13 -5
View File
@@ -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('type="time"', $html);
assert_contains('step="60"', $html); assert_contains('step="60"', $html);
assert_false(strpos($html, 'summary-card') !== false, 'Summary box should not be rendered in create/edit form'); 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); 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', [ $html = render_rule_form($ctx, 'update', [
'id' => 'abc', 'id' => 'abc',
'subject' => 'Urlop', 'subject_prefix' => 'Urlop',
'reply_every_message' => false,
'repeat_minutes' => 1440,
'body' => 'Body', 'body' => 'Body',
'enabled' => true, 'enabled' => true,
'start_value' => '2027-02-14T20:00', '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, 'Wybrano') !== false);
assert_false(strpos($html, '<th>Pn</th>') !== false); assert_false(strpos($html, '<th>Pn</th>') !== false);
$preview = render_preview_box($ctx, ['subject' => 'Delete', 'body' => 'Cancel']); $preview = render_preview_box($ctx, ['subject_prefix' => 'Delete', 'body' => 'Cancel']);
assert_contains('<strong>Subject:</strong> Delete', $preview); assert_contains('<strong>Subject prefix:</strong> Delete', $preview);
assert_contains('Cancel', $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); $settings = Settings::load($settingsPath);
$repo = new RuleRepository(TEST_TMP . '/single-data'); $repo = new RuleRepository(TEST_TMP . '/single-data');
$repo->create('alice', [ $repo->create('alice', [
'subject' => 'Pierwsza', 'subject_prefix' => 'Pierwsza',
'reply_every_message' => false,
'repeat_minutes' => 1440,
'body' => 'Body', 'body' => 'Body',
'enabled' => true, 'enabled' => true,
'start_value' => '2026-06-10|morning', 'start_value' => '2026-06-10|morning',
+28 -5
View File
@@ -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"); test_write($settingsPath, "GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw\nGLOBAL_AUTORESPONDER_BACKEND=exim\n");
$settings = Settings::load($settingsPath); $settings = Settings::load($settingsPath);
$rule = RuleValidator::validate([ $rule = RuleValidator::validate([
'subject' => 'Urlop', 'subject_prefix' => 'Urlop',
'reply_frequency' => '120',
'body' => "Dzień dobry\r\nWracam jutro", 'body' => "Dzień dobry\r\nWracam jutro",
'start_value' => '2026-06-10T09:37', 'start_value' => '2026-06-10T09:37',
'end_value' => '2026-06-24T17:12', 'end_value' => '2026-06-24T17:12',
'enabled' => '1', 'enabled' => '1',
], $settings); ], $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("Dzień dobry\nWracam jutro", $rule['body']);
assert_same('exact_hour', $rule['schedule_mode']); assert_same('exact_hour', $rule['schedule_mode']);
assert_same('2026-06-10T09:37', $rule['start_value']); 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"); test_write($settingsPath, "GLOBAL_AUTORESPONDER_BACKEND=da\n");
$settings = Settings::load($settingsPath); $settings = Settings::load($settingsPath);
$rule = RuleValidator::validate([ $rule = RuleValidator::validate([
'subject' => 'Urlop', 'subject_prefix' => 'Urlop',
'reply_frequency' => 'every',
'body' => 'Body', 'body' => 'Body',
'start_value' => '2026-06-10|morning', 'start_value' => '2026-06-10|morning',
'end_value' => '2026-06-24|evening', 'end_value' => '2026-06-24|evening',
'enabled' => '1', 'enabled' => '1',
], $settings); ], $settings);
assert_same('directadmin_period', $rule['schedule_mode']); 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 { test('rule validator rejects header injection and reversed dates', function (): void {
$settings = Settings::load(TEST_TMP . '/missing.conf'); $settings = Settings::load(TEST_TMP . '/missing.conf');
try { try {
RuleValidator::validate([ RuleValidator::validate([
'subject' => "Bad\nSubject", 'subject_prefix' => "Bad\nSubject",
'body' => 'Body', 'body' => 'Body',
'start_value' => '2026-06-24T17:00', 'start_value' => '2026-06-24T17:00',
'end_value' => '2026-06-10T09:00', 'end_value' => '2026-06-10T09:00',
], $settings); ], $settings);
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
assert_contains('Temat', $e->getMessage()); assert_contains('Przedrostek tematu', $e->getMessage());
return; return;
} }
throw new RuntimeException('Expected validation failure'); 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');
});
+5 -2
View File
@@ -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)); } .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; } .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; } 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; } 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; } 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, button.primary { background: var(--amber); border-color: var(--amber); color: #fff; }
.btn.amber.active { background: var(--amber-strong); border-color: var(--amber-strong); } .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; } .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; } .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; } .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; } }