diff --git a/exec/bootstrap.php b/exec/bootstrap.php index f4ea95b..05633d7 100644 --- a/exec/bootstrap.php +++ b/exec/bootstrap.php @@ -20,6 +20,8 @@ foreach ([ 'CsrfGuard.php', 'RuleRepository.php', 'DirectAdminSyncRepository.php', + 'DirectAdminVacationApi.php', + 'DirectAdminVacationSyncService.php', 'RuleValidator.php', 'MailboxDirectory.php', 'AuditLog.php', diff --git a/exec/handlers/_form_helpers.php b/exec/handlers/_form_helpers.php index 8b1fadd..73c1056 100644 --- a/exec/handlers/_form_helpers.php +++ b/exec/handlers/_form_helpers.php @@ -41,10 +41,22 @@ function normalize_rule_post(array $post): array return $post; } +/** @param array $rule @return array */ +function attach_rule_scope(AppContext $ctx, array $rule): array +{ + if (empty($rule['dynamic_scope'])) { + $rule['mailbox_snapshot'] = (new MailboxDirectory())->mailboxesForUser($ctx->daUser->username()); + } + return $rule; +} + function render_calendar_picker(AppContext $ctx, string $name, string $title, string $value): string { $mode = $ctx->settings->scheduleMode(); $selectedDate = substr($value, 0, 10); + if (!preg_match('/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/', $selectedDate)) { + $selectedDate = date('Y-m-d'); + } $selectedTime = '09:00'; $selectedPeriod = 'morning'; if ($mode === 'exact_hour' && preg_match('/T([0-9]{2}:[0-9]{2})$/', $value, $m)) { @@ -52,33 +64,62 @@ function render_calendar_picker(AppContext $ctx, string $name, string $title, st } elseif ($mode === 'directadmin_period' && preg_match('/\|(morning|afternoon|evening)$/', $value, $m)) { $selectedPeriod = $m[1]; } - $days = ''; - for ($week = 0, $day = 1; $week < 4; $week++) { - $days .= ''; - for ($i = 0; $i < 7; $i++, $day++) { - $date = sprintf('2026-06-%02d', $day); - $class = $date === $selectedDate ? ' is-selected' : ''; - $days .= ''; - } - $days .= ''; - } + $selectedDt = DateTimeImmutable::createFromFormat('!Y-m-d', $selectedDate) ?: new DateTimeImmutable('today'); + $monthStart = $selectedDt->modify('first day of this month'); + $days = render_calendar_days($name, $selectedDate, $monthStart); + $visibleMonth = $monthStart->format('Y-m'); + $monthLabel = calendar_month_label($ctx, $monthStart); if ($mode === 'exact_hour') { - $timeControl = ''; + $timeControl = ''; } else { - $timeControl = '' . '' . '' . '' . ''; } - return '

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

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

Czerwiec 2026
' . - '' . $days . '
PnWtŚrCzPtSbNd
' . + return '

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

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

' . AppContext::e($monthLabel) . '
' . + '' . $days . '
PnWtŚrCzPtSbNd
' . '
Wybrano: ' . AppContext::e($selectedDate) . '
' . '

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

' . $timeControl . '
' . - '
'; + '
'; } function render_preview_box(array $rule): string { return '
Temat: ' . AppContext::e((string)$rule['subject']) . '

' . nl2br(AppContext::e((string)$rule['body'])) . '
'; } + +function render_calendar_days(string $name, string $selectedDate, DateTimeImmutable $monthStart): string +{ + $offset = ((int)$monthStart->format('N')) - 1; + $daysInMonth = (int)$monthStart->format('t'); + $html = ''; + $day = 1; + for ($week = 0; $week < 6; $week++) { + $html .= ''; + for ($i = 0; $i < 7; $i++) { + if (($week === 0 && $i < $offset) || $day > $daysInMonth) { + $html .= ''; + continue; + } + $date = $monthStart->setDate((int)$monthStart->format('Y'), (int)$monthStart->format('m'), $day)->format('Y-m-d'); + $class = $date === $selectedDate ? ' is-selected' : ''; + $html .= ''; + $day++; + } + $html .= ''; + if ($day > $daysInMonth) { + break; + } + } + return $html; +} + +function calendar_month_label(AppContext $ctx, DateTimeImmutable $monthStart): string +{ + $pl = ['Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec', 'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień']; + $en = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + $months = $ctx->lang->code() === 'pl' ? $pl : $en; + return $months[((int)$monthStart->format('n')) - 1] . ' ' . $monthStart->format('Y'); +} diff --git a/exec/handlers/create.php b/exec/handlers/create.php index f120008..5d69f1e 100644 --- a/exec/handlers/create.php +++ b/exec/handlers/create.php @@ -7,8 +7,9 @@ return static function (AppContext $ctx): void { try { $ctx->requireCsrf('create'); $input = normalize_rule_post($_POST); - $rule = RuleValidator::validate($input, $ctx->settings); + $rule = attach_rule_scope($ctx, RuleValidator::validate($input, $ctx->settings)); $ctx->rules->create($ctx->daUser->username(), $rule); + $ctx->syncActiveBackend(); Http::redirect($ctx->url('index.html', ['status' => 'created'])); } catch (Throwable $e) { $errors[] = $e->getMessage(); diff --git a/exec/handlers/delete.php b/exec/handlers/delete.php index a50ed1e..a37462b 100644 --- a/exec/handlers/delete.php +++ b/exec/handlers/delete.php @@ -12,6 +12,7 @@ return static function (AppContext $ctx): void { try { $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']]); Http::redirect($ctx->url('index.html', ['status' => 'deleted'])); } catch (Throwable $e) { diff --git a/exec/handlers/toggle.php b/exec/handlers/toggle.php index d758ed1..c7869ee 100644 --- a/exec/handlers/toggle.php +++ b/exec/handlers/toggle.php @@ -10,5 +10,6 @@ return static function (AppContext $ctx): void { throw new RuntimeException($ctx->t('Rule not found')); } $ctx->rules->update($ctx->daUser->username(), $id, ['enabled' => empty($rule['enabled'])]); + $ctx->syncActiveBackend(); Http::redirect($ctx->url('index.html', ['status' => 'toggled'])); }; diff --git a/exec/handlers/update.php b/exec/handlers/update.php index 114f6b5..8e06e94 100644 --- a/exec/handlers/update.php +++ b/exec/handlers/update.php @@ -13,8 +13,9 @@ return static function (AppContext $ctx): void { try { $ctx->requireCsrf('update:' . $id); $input = normalize_rule_post($_POST); - $patch = RuleValidator::validate($input, $ctx->settings); + $patch = attach_rule_scope($ctx, RuleValidator::validate($input, $ctx->settings)); $ctx->rules->update($ctx->daUser->username(), $id, $patch); + $ctx->syncActiveBackend(); Http::redirect($ctx->url('index.html', ['status' => 'updated'])); } catch (Throwable $e) { $errors[] = $e->getMessage(); diff --git a/exec/lib/AppContext.php b/exec/lib/AppContext.php index 2ef86bb..a7fbad6 100644 --- a/exec/lib/AppContext.php +++ b/exec/lib/AppContext.php @@ -70,6 +70,20 @@ final class AppContext } } + public function syncActiveBackend(): void + { + if ($this->settings->backendMode() !== 'da') { + return; + } + $service = new DirectAdminVacationSyncService( + $this->rules, + new DirectAdminSyncRepository(), + new MailboxDirectory(), + new DirectAdminVacationApi() + ); + $service->syncUser($this->daUser->username()); + } + public function render(string $title, string $body, array $ok = [], array $errors = []): void { header('Content-Type: text/html; charset=UTF-8'); @@ -109,17 +123,89 @@ final class AppContext private function scripts(): string { $selectedLabel = json_encode($this->t('Selected'), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT); + $monthNames = json_encode($this->lang->code() === 'pl' + ? ['Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec', 'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień'] + : ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT); return << days) { + html += ''; + continue; + } + var value = year + '-' + pad(month) + '-' + pad(day); + var cls = value === selected ? ' is-selected' : ''; + html += ''; + day++; + } + html += ''; + if (day > days) { break; } + } + tbody.innerHTML = html; + label.textContent = monthNames[month - 1] + ' ' + year; + calendar.setAttribute('data-visible-month', year + '-' + pad(month)); + tbody.querySelectorAll('[data-date-value]').forEach(bindDateButton); + } + document.querySelectorAll('[data-date-value]').forEach(function (btn) { + bindDateButton(btn); + }); + document.querySelectorAll('[data-calendar-picker]').forEach(function (calendar) { + var name = calendar.getAttribute('data-calendar-name'); + calendar.querySelectorAll('[data-calendar-prev],[data-calendar-next]').forEach(function (btn) { + btn.addEventListener('click', function () { + var parts = (calendar.getAttribute('data-visible-month') || '').split('-'); + var year = parseInt(parts[0], 10); + var month = parseInt(parts[1], 10); + if (!year || !month) { return; } + month += btn.hasAttribute('data-calendar-next') ? 1 : -1; + if (month < 1) { month = 12; year--; } + if (month > 12) { month = 1; year++; } + renderMonth(calendar, year, month); + }); + }); + document.querySelectorAll('[data-time-for="' + name + '"],[data-period-for="' + name + '"]').forEach(function (control) { + control.addEventListener('change', function () { updateCanonical(name); }); + control.addEventListener('input', function () { updateCanonical(name); }); }); }); })(); diff --git a/exec/lib/DirectAdminSyncRepository.php b/exec/lib/DirectAdminSyncRepository.php index fe05949..444e0a5 100644 --- a/exec/lib/DirectAdminSyncRepository.php +++ b/exec/lib/DirectAdminSyncRepository.php @@ -22,6 +22,12 @@ final class DirectAdminSyncRepository return array_values($data['rules'][$ruleId] ?? []); } + /** @return array>> */ + public function allRuleEntries(string $username): array + { + return $this->read($username)['rules']; + } + /** @return array> */ public function deleteRuleEntries(string $username, string $ruleId): array { diff --git a/exec/lib/DirectAdminVacationApi.php b/exec/lib/DirectAdminVacationApi.php index b0d1236..93dc8cb 100644 --- a/exec/lib/DirectAdminVacationApi.php +++ b/exec/lib/DirectAdminVacationApi.php @@ -1,14 +1,18 @@ request = $request; } /** @param array $rule @return array */ - public function buildSavePayload(string $email, array $rule): array + public function buildSavePayload(string $email, array $rule, bool $exists = true): array { if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new InvalidArgumentException('Invalid mailbox email'); @@ -16,20 +20,46 @@ final class DirectAdminVacationApi [$local, $domain] = explode('@', strtolower($email), 2); [$startDate, $startTime] = $this->splitPeriod((string)($rule['start_value'] ?? '')); [$endDate, $endTime] = $this->splitPeriod((string)($rule['end_value'] ?? '')); + [$startYear, $startMonth, $startDay] = explode('-', $startDate); + [$endYear, $endMonth, $endDay] = explode('-', $endDate); return [ - 'action' => 'modify', + 'action' => $exists ? 'modify' : 'create', 'domain' => $domain, 'user' => $local, 'email' => $email, 'text' => (string)($rule['body'] ?? ''), 'subject' => (string)($rule['subject'] ?? ''), - 'startdate' => $startDate, 'starttime' => $startTime, - 'enddate' => $endDate, + 'startyear' => $startYear, + 'startmonth' => $startMonth, + 'startday' => $startDay, 'endtime' => $endTime, + 'endyear' => $endYear, + 'endmonth' => $endMonth, + 'endday' => $endDay, + 'create' => $exists ? '' : 'Create', ]; } + /** @param array $rule */ + public function saveVacation(string $owner, string $email, array $rule, bool $exists): void + { + $this->requestAsUser($owner, 'CMD_API_EMAIL_VACATION', $this->buildSavePayload($email, $rule, $exists)); + } + + public function deleteVacation(string $owner, string $email): void + { + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new InvalidArgumentException('Invalid mailbox email'); + } + [$local, $domain] = explode('@', strtolower($email), 2); + $this->requestAsUser($owner, 'CMD_API_EMAIL_VACATION', [ + 'action' => 'delete', + 'domain' => $domain, + 'select0' => $local, + ]); + } + /** @return array{0:string,1:string} */ private function splitPeriod(string $value): array { @@ -38,4 +68,58 @@ final class DirectAdminVacationApi } return [$m[1], $m[2]]; } + + /** @param array $payload */ + private function requestAsUser(string $owner, string $endpoint, array $payload): void + { + if (!preg_match('/^[A-Za-z0-9._-]+$/', $owner)) { + throw new InvalidArgumentException('Invalid DirectAdmin owner'); + } + if ($this->request !== null) { + $result = call_user_func($this->request, $owner, $endpoint, $payload); + if ($result === false) { + throw new RuntimeException('DirectAdmin API request failed'); + } + return; + } + $baseUrl = $this->apiUrl($owner); + $url = rtrim($baseUrl, '/') . '/' . $endpoint; + $args = ['curl', '-fsS', '-X', 'POST']; + foreach ($payload as $key => $value) { + $args[] = '--data-urlencode'; + $args[] = $key . '=' . $value; + } + $args[] = $url; + $this->run($args, ''); + } + + private function apiUrl(string $owner): string + { + $output = $this->run([$this->daBinary, 'api-url', '--user=' . $owner], ''); + $url = trim($output); + if ($url === '' || !preg_match('#^https?://#', $url)) { + throw new RuntimeException('Cannot determine DirectAdmin API URL for user'); + } + return $url; + } + + /** @param string[] $args */ + private function run(array $args, string $stdin): string + { + $process = proc_open($args, [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $pipes); + if (!is_resource($process)) { + throw new RuntimeException('Cannot start DirectAdmin API command'); + } + fwrite($pipes[0], $stdin); + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]) ?: ''; + $stderr = stream_get_contents($pipes[2]) ?: ''; + fclose($pipes[1]); + fclose($pipes[2]); + $code = proc_close($process); + if ($code !== 0) { + throw new RuntimeException('DirectAdmin API command failed: ' . trim($stderr)); + } + return $stdout; + } } diff --git a/exec/lib/DirectAdminVacationSyncService.php b/exec/lib/DirectAdminVacationSyncService.php new file mode 100644 index 0000000..07029cd --- /dev/null +++ b/exec/lib/DirectAdminVacationSyncService.php @@ -0,0 +1,106 @@ +rulesById($username); + foreach ($this->sync->allRuleEntries($username) as $ruleId => $entries) { + if (!isset($rules[$ruleId]) || empty($rules[$ruleId]['enabled'])) { + $this->deleteEntries($username, $ruleId, $entries); + } + } + + foreach ($rules as $ruleId => $rule) { + if (empty($rule['enabled'])) { + continue; + } + $this->syncRule($username, $ruleId, $rule); + } + } + + /** @return array> */ + private function rulesById(string $username): array + { + $out = []; + foreach ($this->rules->all($username) as $rule) { + $id = (string)($rule['id'] ?? ''); + if ($id !== '') { + $out[$id] = $rule; + } + } + return $out; + } + + /** @param array $rule */ + private function syncRule(string $username, string $ruleId, array $rule): void + { + $existing = $this->indexedEntries($this->sync->entriesForRule($username, $ruleId)); + $targets = $this->targetMailboxes($username, $rule); + $next = []; + foreach ($targets as $mailbox) { + $email = strtolower((string)$mailbox['email']); + $this->api->saveVacation($username, $email, $rule, isset($existing[$email])); + $next[] = [ + 'rule_id' => $ruleId, + 'domain' => (string)$mailbox['domain'], + 'mailbox' => (string)$mailbox['mailbox'], + 'email' => $email, + 'api_key' => $email, + 'last_sync_at' => time(), + ]; + unset($existing[$email]); + } + foreach ($existing as $entry) { + $this->api->deleteVacation($username, (string)$entry['email']); + } + $this->sync->replaceRuleEntries($username, $ruleId, $next); + } + + /** @param array> $entries */ + private function deleteEntries(string $username, string $ruleId, array $entries): void + { + foreach ($entries as $entry) { + $email = (string)($entry['email'] ?? $entry['api_key'] ?? ''); + if ($email !== '') { + $this->api->deleteVacation($username, $email); + } + } + $this->sync->deleteRuleEntries($username, $ruleId); + } + + /** @param array> $entries @return array> */ + private function indexedEntries(array $entries): array + { + $out = []; + foreach ($entries as $entry) { + $email = strtolower((string)($entry['email'] ?? $entry['api_key'] ?? '')); + if ($email !== '') { + $out[$email] = $entry + ['email' => $email]; + } + } + return $out; + } + + /** @param array $rule @return array */ + private function targetMailboxes(string $username, array $rule): array + { + if (empty($rule['dynamic_scope']) && isset($rule['mailbox_snapshot']) && is_array($rule['mailbox_snapshot'])) { + return array_values(array_filter($rule['mailbox_snapshot'], static function (mixed $mailbox): bool { + return is_array($mailbox) + && isset($mailbox['domain'], $mailbox['mailbox'], $mailbox['email']) + && filter_var((string)$mailbox['email'], FILTER_VALIDATE_EMAIL); + })); + } + return $this->mailboxes->mailboxesForUser($username); + } +} diff --git a/plugin.conf b/plugin.conf index 77b4107..97b479e 100644 --- a/plugin.conf +++ b/plugin.conf @@ -2,6 +2,7 @@ name=global-autoresponder id=global-autoresponder type=user author=HITME.PL -version=1.0.5 +version=1.0.6 active=no installed=no +user_run_as=root diff --git a/scripts/backend.sh b/scripts/backend.sh index 075775c..730887b 100755 --- a/scripts/backend.sh +++ b/scripts/backend.sh @@ -3,6 +3,16 @@ set -eu BASE_DIR="/usr/local/hitme_plugins/global-autoresponders" STATE_FILE="$BASE_DIR/config/backend-state.json" +PLUGIN_DIR="/usr/local/directadmin/plugins/global-autoresponder" +CUSTOM_DIR="/usr/local/directadmin/custombuild/custom/exim" +CUSTOM_CONF="$CUSTOM_DIR/exim.conf" +SOURCE_CONF="/usr/local/directadmin/custombuild/configure/exim/exim.conf" +ROUTER_SNIPPET="$PLUGIN_DIR/scripts/exim/global_autoresponder_router.conf" +TRANSPORT_SNIPPET="$PLUGIN_DIR/scripts/exim/global_autoresponder_transport.conf" +ROLLBACK_ACTIVE=0 +ROLLBACK_CREATED=0 +ROLLBACK_REBUILD=0 +ROLLBACK_FILE= usage() { echo "Usage: scripts/backend.sh da|exim" >&2 @@ -16,21 +26,161 @@ fi mkdir -p "$BASE_DIR/config" "$BASE_DIR/data" "$BASE_DIR/state" "$BASE_DIR/logs" chmod 700 "$BASE_DIR/config" "$BASE_DIR/logs" +write_state() { + backend="$1" + printf '{"active_backend":"%s","migration_status":"complete"}\n' "$backend" > "$STATE_FILE" + chmod 600 "$STATE_FILE" +} + +remove_marked_block() { + marker="$1" + file="$2" + tmp="${file}.global-autoresponder.$$" + awk -v marker="$marker" ' + $0 == "# BEGIN global-autoresponder " marker { skip=1; next } + $0 == "# END global-autoresponder " marker { skip=0; next } + skip != 1 { print } + ' "$file" > "$tmp" + mv "$tmp" "$file" +} + +insert_after_section() { + section="$1" + snippet="$2" + file="$3" + tmp="${file}.global-autoresponder.$$" + if ! awk -v section="$section" -v snippet="$snippet" ' + BEGIN { + while ((getline line < snippet) > 0) { + block = block line "\n" + } + close(snippet) + } + { print } + $0 == section && inserted != 1 { + printf "\n%s\n", block + inserted = 1 + } + END { + if (inserted != 1) { + exit 3 + } + } + ' "$file" > "$tmp"; then + rm -f "$tmp" + echo "Cannot install exim backend: section $section not found in custom exim.conf." >&2 + exit 1 + fi + mv "$tmp" "$file" +} + +backup_custom_conf() { + mkdir -p "$CUSTOM_DIR" + ROLLBACK_FILE="${CUSTOM_CONF}.global-autoresponder.backup.$$" + if [ -f "$CUSTOM_CONF" ]; then + cp "$CUSTOM_CONF" "$ROLLBACK_FILE" + ROLLBACK_CREATED=0 + else + : > "$ROLLBACK_FILE" + ROLLBACK_CREATED=1 + fi + ROLLBACK_ACTIVE=1 +} + +restore_custom_conf() { + if [ "$ROLLBACK_ACTIVE" != "1" ]; then + return + fi + set +e + if [ "$ROLLBACK_CREATED" = "1" ]; then + rm -f "$CUSTOM_CONF" + else + cp "$ROLLBACK_FILE" "$CUSTOM_CONF" + fi + rm -f "$ROLLBACK_FILE" + if [ "$ROLLBACK_REBUILD" = "1" ]; then + rebuild_and_reload_exim >/dev/null 2>&1 + fi + ROLLBACK_ACTIVE=0 +} + +commit_custom_conf() { + rm -f "$ROLLBACK_FILE" + ROLLBACK_ACTIVE=0 + ROLLBACK_CREATED=0 + ROLLBACK_REBUILD=0 +} + +trap 'restore_custom_conf' EXIT +trap 'restore_custom_conf; exit 1' HUP INT TERM + +validate_exim_conf() { + if command -v exim >/dev/null 2>&1; then + exim -C "$CUSTOM_CONF" -bV >/dev/null + elif command -v exim4 >/dev/null 2>&1; then + exim4 -C "$CUSTOM_CONF" -bV >/dev/null + else + echo "Cannot validate exim backend: exim binary not found." >&2 + exit 1 + fi +} + +rebuild_and_reload_exim() { + if command -v da >/dev/null 2>&1; then + da build exim_conf + elif [ -x /usr/local/directadmin/custombuild/build ]; then + /usr/local/directadmin/custombuild/build exim_conf + else + echo "Cannot rebuild exim configuration: da/build command not found." >&2 + exit 1 + fi + if command -v systemctl >/dev/null 2>&1; then + systemctl reload exim 2>/dev/null || systemctl restart exim + else + service exim reload 2>/dev/null || service exim restart + fi +} + if [ "$1" = "da" ]; then - cat > "$STATE_FILE" <<'EOF' -{"active_backend":"da","migration_status":"complete"} -EOF + if [ -f "$CUSTOM_CONF" ]; then + backup_custom_conf + remove_marked_block "router" "$CUSTOM_CONF" + remove_marked_block "transport" "$CUSTOM_CONF" + if grep -Fq "global_autoresponder_" "$CUSTOM_CONF"; then + echo "Cannot switch to da: plugin Exim markers were not removed cleanly." >&2 + exit 1 + fi + validate_exim_conf + ROLLBACK_REBUILD=1 + rebuild_and_reload_exim + commit_custom_conf + fi + write_state "da" echo "Backend set to da" exit 0 fi -CUSTOM_DIR="/usr/local/directadmin/custombuild/custom/exim" -if [ ! -d "$CUSTOM_DIR" ]; then - echo "Cannot enable exim backend: safe CustomBuild exim customization directory not found." >&2 +if [ ! -d "$(dirname "$SOURCE_CONF")" ]; then + echo "Cannot enable exim backend: DirectAdmin CustomBuild exim source config not found." >&2 + exit 1 +fi +if [ ! -f "$ROUTER_SNIPPET" ] || [ ! -f "$TRANSPORT_SNIPPET" ]; then + echo "Cannot enable exim backend: plugin Exim snippets are missing." >&2 exit 1 fi -cat > "$STATE_FILE" <<'EOF' -{"active_backend":"exim","migration_status":"pending_exim_validation"} -EOF -echo "Backend prepared for exim; validate Exim customization before production use." +backup_custom_conf +if [ ! -f "$CUSTOM_CONF" ]; then + cp "$SOURCE_CONF" "$CUSTOM_CONF" +fi + +remove_marked_block "router" "$CUSTOM_CONF" +remove_marked_block "transport" "$CUSTOM_CONF" +insert_after_section "begin routers" "$ROUTER_SNIPPET" "$CUSTOM_CONF" +insert_after_section "begin transports" "$TRANSPORT_SNIPPET" "$CUSTOM_CONF" +validate_exim_conf +ROLLBACK_REBUILD=1 +rebuild_and_reload_exim +commit_custom_conf +write_state "exim" +echo "Backend set to exim" diff --git a/scripts/exim/global_autoresponder_router.conf b/scripts/exim/global_autoresponder_router.conf index 5d60379..1bb7347 100644 --- a/scripts/exim/global_autoresponder_router.conf +++ b/scripts/exim/global_autoresponder_router.conf @@ -1,2 +1,11 @@ -# global-autoresponder router placeholder -# This file must only be installed through DirectAdmin/CustomBuild-safe Exim customization paths. +# BEGIN global-autoresponder router +# Runs an unseen delivery to the plugin worker for local recipients. Normal mailbox +# delivery continues because unseen is enabled and the worker suppresses ineligible mail. +global_autoresponder_router: + driver = accept + domains = +local_domains + condition = ${if exists{/usr/local/directadmin/plugins/global-autoresponder/scripts/global_autoresponder_worker.php}{yes}{no}} + transport = global_autoresponder_transport + unseen + no_verify +# END global-autoresponder router diff --git a/scripts/exim/global_autoresponder_transport.conf b/scripts/exim/global_autoresponder_transport.conf index 4adc9ff..9cc3144 100644 --- a/scripts/exim/global_autoresponder_transport.conf +++ b/scripts/exim/global_autoresponder_transport.conf @@ -1,2 +1,13 @@ -# global-autoresponder transport placeholder -# This file must only be installed through DirectAdmin/CustomBuild-safe Exim customization paths. +# BEGIN global-autoresponder transport +# The worker receives the original message on stdin and Exim-provided envelope +# variables such as SENDER, RECIPIENT, LOCAL_PART, and DOMAIN in the environment. +global_autoresponder_transport: + driver = pipe + command = /usr/local/directadmin/plugins/global-autoresponder/scripts/global_autoresponder_worker.php + user = root + group = root + return_fail_output = false + log_output = true + message_prefix = + message_suffix = +# END global-autoresponder transport diff --git a/scripts/global_autoresponder_worker.php b/scripts/global_autoresponder_worker.php index 278be5c..95b60dd 100755 --- a/scripts/global_autoresponder_worker.php +++ b/scripts/global_autoresponder_worker.php @@ -48,6 +48,9 @@ final class GlobalAutoresponderWorker if (!$this->isActiveRule($rule)) { continue; } + if (!$this->ruleIncludesRecipient($rule, $recipient)) { + continue; + } $ruleId = (string)($rule['id'] ?? ''); if ($ruleId === '') { continue; @@ -81,6 +84,24 @@ final class GlobalAutoresponderWorker return (int)($rule['start_ts'] ?? 0) <= $now && (int)($rule['end_ts'] ?? 0) >= $now; } + /** @param array $rule */ + private function ruleIncludesRecipient(array $rule, string $recipient): bool + { + if (!array_key_exists('dynamic_scope', $rule) || !empty($rule['dynamic_scope'])) { + return true; + } + $snapshot = $rule['mailbox_snapshot'] ?? []; + if (!is_array($snapshot)) { + return false; + } + foreach ($snapshot as $mailbox) { + if (is_array($mailbox) && strtolower((string)($mailbox['email'] ?? '')) === strtolower($recipient)) { + return true; + } + } + return false; + } + /** @param array $env @param string[] $keys */ private static function firstEnv(array $env, array $keys): string { diff --git a/scripts/sync_directadmin_vacations.php b/scripts/sync_directadmin_vacations.php index f205e99..d950d5a 100755 --- a/scripts/sync_directadmin_vacations.php +++ b/scripts/sync_directadmin_vacations.php @@ -2,4 +2,81 @@ backendMode() !== 'da') { + echo "DirectAdmin backend is not active; nothing to sync.\n"; + exit(0); + } + + $users = $targetUser !== '' ? [$targetUser] : users_with_rules(Settings::DATA_DIR); + $service = new DirectAdminVacationSyncService( + new RuleRepository(), + new DirectAdminSyncRepository(), + new MailboxDirectory(), + new DirectAdminVacationApi() + ); + foreach ($users as $username) { + $service->syncUser($username); + echo "Synced {$username}\n"; + } + exit(0); +} catch (Throwable $e) { + fwrite(STDERR, "DirectAdmin vacation synchronization failed: " . $e->getMessage() . "\n"); + exit(1); +} + +/** @return string[] */ +function users_with_rules(string $baseDir): array +{ + $dir = rtrim($baseDir, '/') . '/users'; + if (!is_dir($dir)) { + return []; + } + $users = []; + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..' || !preg_match('/^[A-Za-z0-9._-]+$/', $entry)) { + continue; + } + if (is_file($dir . '/' . $entry . '/rules.json')) { + $users[] = $entry; + } + } + sort($users); + return $users; +} diff --git a/tests/directadmin_vacation_sync_service_test.php b/tests/directadmin_vacation_sync_service_test.php new file mode 100644 index 0000000..140da85 --- /dev/null +++ b/tests/directadmin_vacation_sync_service_test.php @@ -0,0 +1,96 @@ +create('alice', [ + 'subject' => 'Urlop', + 'body' => 'Body', + 'start_value' => '2026-06-10|morning', + 'end_value' => '2026-06-24|evening', + 'start_ts' => 1, + 'end_ts' => 2, + 'enabled' => true, + 'dynamic_scope' => true, + ]); + + $calls = []; + $api = new class($calls) extends DirectAdminVacationApi { + public function __construct(private array &$calls) {} + public function saveVacation(string $owner, string $email, array $rule, bool $exists): void + { + $this->calls[] = ['save', $owner, $email, $exists, $rule['subject']]; + } + public function deleteVacation(string $owner, string $email): void + { + $this->calls[] = ['delete', $owner, $email]; + } + }; + + $sync = new DirectAdminVacationSyncService( + $repo, + new DirectAdminSyncRepository(TEST_TMP . '/da-sync-data'), + new MailboxDirectory($virtualRoot), + $api + ); + $sync->syncUser('alice'); + + assert_same([ + ['save', 'alice', 'info@example.com', false, 'Urlop'], + ['save', 'alice', 'sales@example.com', false, 'Urlop'], + ], $calls); + + $tracked = (new DirectAdminSyncRepository(TEST_TMP . '/da-sync-data'))->entriesForRule('alice', (string)$rule['id']); + assert_same(['info@example.com', 'sales@example.com'], array_column($tracked, 'email')); +}); + +test('directadmin vacation sync deletes stale tracked entries after rule removal', function (): void { + $virtualRoot = TEST_TMP . '/da-sync-delete-virtual'; + test_write($virtualRoot . '/domainowners', "example.com: alice\n"); + test_write($virtualRoot . '/example.com/passwd', "info:x:1000:12::/home/alice/imap/example.com/info:/bin/false\n"); + + $dataDir = TEST_TMP . '/da-sync-delete-data'; + $repo = new RuleRepository($dataDir); + $syncRepo = new DirectAdminSyncRepository($dataDir); + $rule = $repo->create('alice', [ + 'subject' => 'Urlop', + 'body' => 'Body', + 'start_value' => '2026-06-10|morning', + 'end_value' => '2026-06-24|evening', + 'start_ts' => 1, + 'end_ts' => 2, + 'enabled' => true, + 'dynamic_scope' => true, + ]); + $syncRepo->replaceRuleEntries('alice', (string)$rule['id'], [ + ['email' => 'info@example.com', 'domain' => 'example.com', 'mailbox' => 'info', 'api_key' => 'info@example.com'], + ]); + $repo->delete('alice', (string)$rule['id']); + + $calls = []; + $api = new class($calls) extends DirectAdminVacationApi { + public function __construct(private array &$calls) {} + public function saveVacation(string $owner, string $email, array $rule, bool $exists): void {} + public function deleteVacation(string $owner, string $email): void + { + $this->calls[] = ['delete', $owner, $email]; + } + }; + + $sync = new DirectAdminVacationSyncService($repo, $syncRepo, new MailboxDirectory($virtualRoot), $api); + $sync->syncUser('alice'); + + assert_same([['delete', 'alice', 'info@example.com']], $calls); + assert_same([], $syncRepo->entriesForRule('alice', (string)$rule['id'])); +}); diff --git a/tests/exim_worker_test.php b/tests/exim_worker_test.php index 520921d..d2bb632 100644 --- a/tests/exim_worker_test.php +++ b/tests/exim_worker_test.php @@ -96,3 +96,49 @@ test('exim worker suppresses automated messages and ignores unresolved recipient )); assert_same(0, $sent); }); + +test('exim worker respects static mailbox snapshot scope', function (): void { + $settingsPath = TEST_TMP . '/worker-snapshot-settings.conf'; + test_write($settingsPath, "GLOBAL_AUTORESPONDER_BACKEND=exim\nGLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE=true\n"); + $settings = Settings::load($settingsPath); + + $virtualRoot = TEST_TMP . '/worker-snapshot-virtual'; + test_write($virtualRoot . '/domainowners', "example.com: alice\n"); + test_write($virtualRoot . '/example.com/passwd', "info:x:1000:12::/home/alice/imap/example.com/info:/bin/false\nsales:x:1000:12::/home/alice/imap/example.com/sales:/bin/false\n"); + + $repo = new RuleRepository(TEST_TMP . '/worker-snapshot-data'); + $repo->create('alice', [ + 'subject' => 'Urlop', + 'body' => 'Body', + 'start_ts' => 1, + 'end_ts' => 2000, + 'enabled' => true, + 'dynamic_scope' => false, + 'mailbox_snapshot' => [ + ['domain' => 'example.com', 'mailbox' => 'info', 'email' => 'info@example.com'], + ], + ]); + + $sent = 0; + $worker = new GlobalAutoresponderWorker( + $settings, + $repo, + new MailboxDirectory($virtualRoot), + new RepeatState(TEST_TMP . '/worker-snapshot-state', 1000), + static function () use (&$sent): bool { + $sent++; + return true; + }, + 1000 + ); + + assert_same(0, $worker->handle( + ['SENDER_ADDRESS' => 'person@example.net', 'RECIPIENT' => 'sales@example.com'], + "Auto-Submitted: no\n\nBody" + )); + assert_same(1, $worker->handle( + ['SENDER_ADDRESS' => 'person@example.net', 'RECIPIENT' => 'info@example.com'], + "Auto-Submitted: no\n\nBody" + )); + assert_same(1, $sent); +}); diff --git a/tests/handler_test.php b/tests/handler_test.php index 11ae277..9cb7cf7 100644 --- a/tests/handler_test.php +++ b/tests/handler_test.php @@ -76,3 +76,39 @@ test('rule form uses native period selector in da mode', function (): void { assert_contains('Rano', $html); assert_false(strpos($html, 'type="time"') !== false); }); + +test('mutating handlers synchronize active backend after storage changes', function (): void { + foreach (['create.php', 'update.php', 'delete.php', 'toggle.php'] as $handler) { + $source = file_get_contents(PLUGIN_ROOT . '/exec/handlers/' . $handler) ?: ''; + assert_contains('syncActiveBackend', $source, $handler . ' must synchronize backend after mutation'); + } +}); + +test('calendar picker is based on selected rule month and supports month navigation', function (): void { + $settingsPath = TEST_TMP . '/form-calendar.conf'; + test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\nGLOBAL_AUTORESPONDER_BACKEND=exim\n"); + $settings = Settings::load($settingsPath); + $ctx = new AppContext( + new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'), + $settings, + new RuleRepository(TEST_TMP . '/data'), + new CsrfGuard('secret', 'alice', 'sid'), + Lang::load('pl', $settings), + new AuditLog(TEST_TMP . '/audit.log') + ); + + $html = render_rule_form($ctx, 'update', [ + 'id' => 'abc', + 'subject' => 'Urlop', + 'body' => 'Body', + 'enabled' => true, + 'start_value' => '2027-02-14T20:00', + 'end_value' => '2027-03-01T09:30', + ]); + + assert_contains('data-calendar-picker', $html); + assert_contains('data-calendar-prev', $html); + assert_contains('data-calendar-next', $html); + assert_contains('Luty 2027', $html); + assert_false(strpos($html, 'Czerwiec 2026') !== false); +}); diff --git a/tests/package_contents_test.php b/tests/package_contents_test.php index a5a907f..7cda04f 100644 --- a/tests/package_contents_test.php +++ b/tests/package_contents_test.php @@ -7,3 +7,40 @@ test('packing guidelines exclude docs and allow later manual icon', function (): assert_contains('must not contain `docs/`', $packing); assert_contains('may be added later at `src/images/user_icon.svg`', $packing); }); + +test('plugin metadata runs user level as root on directadmin 1.689 plus', function (): void { + $conf = file_get_contents(PLUGIN_ROOT . '/plugin.conf') ?: ''; + assert_contains('type=user', $conf); + assert_contains('user_run_as=root', $conf); +}); + +test('shipped plugin files do not contain placeholders', function (): void { + $bad = []; + $root = new RecursiveDirectoryIterator(PLUGIN_ROOT, FilesystemIterator::SKIP_DOTS); + $it = new RecursiveIteratorIterator($root); + foreach ($it as $file) { + if (!$file->isFile()) { + continue; + } + $path = $file->getPathname(); + $relative = substr($path, strlen(PLUGIN_ROOT) + 1); + if (str_starts_with($relative, '.git/') || str_starts_with($relative, 'tests/')) { + continue; + } + $content = file_get_contents($path) ?: ''; + if (preg_match('/placeholder|TODO|FIXME|not implemented|pending_exim_validation|stub/i', $content)) { + $bad[] = $relative; + } + } + assert_same([], $bad); +}); + +test('backend script protects custom exim config with rollback and exact section insertion checks', function (): void { + $script = file_get_contents(PLUGIN_ROOT . '/scripts/backend.sh') ?: ''; + assert_contains('backup_custom_conf()', $script); + assert_contains('restore_custom_conf()', $script); + assert_contains("trap 'restore_custom_conf' EXIT", $script); + assert_contains('ROLLBACK_REBUILD=1', $script); + assert_contains('if ! awk -v section="$section" -v snippet="$snippet"', $script); + assert_contains('if (inserted != 1)', $script); +});