feat: use vacation subject prefix
This commit is contained in:
@@ -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 ? '<input type="hidden" name="id" value="' . AppContext::e($id) . '">' : '';
|
||||
|
||||
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>' .
|
||||
'<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) .
|
||||
@@ -25,6 +27,40 @@ function render_rule_form(AppContext $ctx, string $mode, ?array $rule): string
|
||||
'</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> */
|
||||
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 '<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
|
||||
|
||||
@@ -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', '<div class="alert alert-err">' . AppContext::e($e->getMessage()) . '</div>');
|
||||
|
||||
@@ -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 .= '<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($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>';
|
||||
@@ -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>' .
|
||||
'<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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,18 +3,27 @@ declare(strict_types=1);
|
||||
|
||||
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> */
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
+12
@@ -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',
|
||||
|
||||
+12
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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<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__) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
+13
-5
@@ -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, '<th>Pn</th>') !== false);
|
||||
|
||||
$preview = render_preview_box($ctx, ['subject' => 'Delete', 'body' => 'Cancel']);
|
||||
assert_contains('<strong>Subject:</strong> Delete', $preview);
|
||||
$preview = render_preview_box($ctx, ['subject_prefix' => 'Delete', 'body' => 'Cancel']);
|
||||
assert_contains('<strong>Subject prefix:</strong> 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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
+5
-2
@@ -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; } }
|
||||
|
||||
Reference in New Issue
Block a user