daBinary = $daBinary; $this->request = $request; $this->runner = $runner; } public function directAdminBinary(): string { return $this->daBinary; } /** @param array $rule @return 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'); } [$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); $subjectPrefix = (string)($rule['subject_prefix'] ?? $rule['subject'] ?? ''); $payload = [ 'action' => $exists ? 'modify' : 'create', 'domain' => $domain, 'user' => $local, 'text' => (string)($rule['body'] ?? ''), 'subject' => $subjectPrefix, 'reply_encoding' => 'UTF-8', 'reply_content_type' => 'text/plain', 'reply_once_time' => $this->replyOnceTime($rule), 'starttime' => $startTime, 'startyear' => $startYear, 'startmonth' => $startMonth, 'startday' => $startDay, 'endtime' => $endTime, 'endyear' => $endYear, 'endmonth' => $endMonth, 'endday' => $endDay, ]; if (!$exists) { $payload['create'] = 'Create'; } return $payload; } /** @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 vacationExists(string $owner, string $email): bool { if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new InvalidArgumentException('Invalid mailbox email'); } [$local, $domain] = explode('@', strtolower($email), 2); return in_array($local, $this->listVacations($owner, $domain), true); } /** @return string[] */ public function listVacations(string $owner, string $domain): array { if (!preg_match('/^[A-Za-z0-9.-]+$/', $domain)) { throw new InvalidArgumentException('Invalid domain'); } $response = $this->requestAsUser($owner, 'CMD_API_EMAIL_VACATION', ['domain' => strtolower($domain)]); $users = []; foreach (explode('&', trim($response)) as $pair) { if ($pair === '' || !str_contains($pair, '=')) { continue; } [$key] = explode('=', $pair, 2); $local = strtolower(urldecode($key)); if (preg_match('/^[A-Za-z0-9._+-]+$/', $local)) { $users[] = $local; } } sort($users); return array_values(array_unique($users)); } 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 { if (!preg_match('/^([0-9]{4}-[0-9]{2}-[0-9]{2})\|(morning|afternoon|evening)$/', $value, $m)) { throw new InvalidArgumentException('DirectAdmin API backend requires directadmin_period values'); } return [$m[1], $m[2]]; } /** @param array $payload */ private function requestAsUser(string $owner, string $endpoint, array $payload): string { 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 (string)$result; } $baseUrl = $this->apiUrl($owner); $url = rtrim($baseUrl, '/') . '/' . $endpoint; $args = ['curl', '-sS', '-X', 'POST', '-w', "\n%{http_code}"]; foreach ($payload as $key => $value) { $args[] = '--data-urlencode'; $args[] = $key . '=' . $value; } $args[] = $url; return $this->parseHttpResponse($this->run($args, '')); } /** @param array $rule */ private function replyOnceTime(array $rule): string { if (!empty($rule['reply_every_message'])) { return '0'; } return match ((int)($rule['repeat_minutes'] ?? 1440)) { 30 => '30m', 60 => '1h', 120 => '2h', 360 => '6h', 720 => '12h', 1440 => '1d', 2880 => '2d', 10080 => '1w', default => '1d', }; } private function parseHttpResponse(string $output): string { if (!preg_match("/\n([0-9]{3})$/", $output, $m, PREG_OFFSET_CAPTURE)) { return $output; } $code = (int)$m[1][0]; $body = substr($output, 0, (int)$m[0][1]); if ($code < 200 || $code >= 400) { throw new RuntimeException('DirectAdmin API HTTP ' . $code . ': ' . $this->compactBody($body)); } $this->throwIfApiError($body); return $body; } private function throwIfApiError(string $body): void { $trimmed = trim($body); if ($trimmed === '') { return; } $json = json_decode($trimmed, true); if (is_array($json) && isset($json['error']) && $this->truthyError($json['error'])) { throw new RuntimeException('DirectAdmin API error: ' . $this->compactBody(implode(' ', array_filter([ (string)($json['text'] ?? ''), (string)($json['details'] ?? ''), (string)($json['result'] ?? ''), (string)($json['error'] ?? ''), ])))); } $fields = []; parse_str($trimmed, $fields); if (isset($fields['error']) && $this->truthyError($fields['error'])) { throw new RuntimeException('DirectAdmin API error: ' . $this->compactBody(implode(' ', array_filter([ is_scalar($fields['text'] ?? null) ? (string)$fields['text'] : '', is_scalar($fields['details'] ?? null) ? (string)$fields['details'] : '', is_scalar($fields['result'] ?? null) ? (string)$fields['result'] : '', is_scalar($fields['error'] ?? null) ? (string)$fields['error'] : '', ])))); } } private function truthyError(mixed $value): bool { return in_array(strtolower(trim((string)$value)), ['1', 'yes', 'true', 'error'], true); } private function compactBody(string $body): string { $body = trim(preg_replace('/[[:cntrl:]]+/', ' ', $body) ?? $body); if ($body === '') { return 'empty response body'; } if (strlen($body) > 1200) { return substr($body, 0, 1200) . '...'; } return $body; } private function apiUrl(string $owner): string { $output = $this->run([$this->resolveDirectAdminBinary(), '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; } private function resolveDirectAdminBinary(): string { $candidates = array_values(array_unique([ $this->daBinary, '/usr/local/directadmin/directadmin', '/usr/bin/da', ])); foreach ($candidates as $candidate) { if ($candidate !== '' && is_executable($candidate)) { return $candidate; } } throw new RuntimeException('DirectAdmin binary is not executable. Checked: ' . implode(', ', $candidates)); } /** @param string[] $args */ private function run(array $args, string $stdin): string { if ($this->runner !== null) { $result = call_user_func($this->runner, $args, $stdin); return (string)$result; } if (($args[0] ?? '') !== '' && str_contains($args[0], '/') && !is_executable($args[0])) { throw new RuntimeException('DirectAdmin API command is not executable: ' . $args[0]); } $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; } }