feat: implement global autoresponder plugin

This commit is contained in:
Marek Miklewicz
2026-06-02 19:19:00 +02:00
commit 6f989af278
62 changed files with 2018 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
final class AppContext
{
public function __construct(
public DirectAdminUser $daUser,
public Settings $settings,
public RuleRepository $rules,
public CsrfGuard $csrf,
public Lang $lang,
public AuditLog $audit,
) {
}
public function action(): string
{
return (string)PLUGIN_ACTION;
}
public function baseUrl(): string
{
return '/CMD_PLUGINS/global-autoresponder';
}
/** @param array<string,string|int> $query */
public function url(string $page, array $query = []): string
{
$url = $this->baseUrl() . '/' . ltrim($page, '/');
if ($query !== []) {
$url .= '?' . http_build_query($query);
}
return $url;
}
public function t(string $key): string
{
return $this->lang->t($key);
}
public function post(string $key, string $default = ''): string
{
return Http::getString($_POST, $key, $default);
}
public function query(string $key, string $default = ''): string
{
return Http::getString($_GET, $key, $default);
}
public function requirePost(): void
{
if (!Http::isPost()) {
throw new RuntimeException('Method not allowed');
}
}
public function csrfField(string $intent): string
{
$issued = $this->csrf->issue($intent);
return '<input type="hidden" name="csrf_ts" value="' . self::e((string)$issued['ts']) . '">' .
'<input type="hidden" name="csrf_sid" value="' . self::e($issued['sid']) . '">' .
'<input type="hidden" name="csrf_token" value="' . self::e($issued['token']) . '">';
}
public function requireCsrf(string $intent): void
{
if (!$this->csrf->validate($intent, $this->post('csrf_ts'), $this->post('csrf_token'), 3600, $this->post('csrf_sid'))) {
throw new RuntimeException('Nieprawidłowy lub wygasły token CSRF.');
}
}
public function render(string $title, string $body, array $ok = [], array $errors = []): void
{
header('Content-Type: text/html; charset=UTF-8');
$alerts = '';
foreach ($ok as $msg) {
$alerts .= '<div class="alert alert-ok">' . self::e((string)$msg) . '</div>';
}
foreach ($errors as $msg) {
$alerts .= '<div class="alert alert-err">' . self::e((string)$msg) . '</div>';
}
echo '<!doctype html><html lang="' . self::e($this->lang->code()) . '"><head><meta charset="utf-8">';
echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
echo '<title>' . self::e($this->t($title)) . '</title><style>' . $this->styles() . '</style></head><body>';
echo '<div class="wrap"><div class="breadcrumbs">' . self::e($this->t('Dashboard')) . ' <span>&gt;</span> ' . self::e($this->t('Global autoresponders')) . '</div>';
echo '<header><h1>' . self::e($this->t($title)) . '</h1><p>' . self::e($this->t('Manage automatic replies for account mailboxes')) . '</p></header>';
echo '<div class="top-actions"><a class="btn amber' . ($this->action() === 'index' ? ' active' : '') . '" href="' . self::e($this->url('index.html')) . '">' . self::e($this->t('AUTORESPONDERS')) . '</a>';
echo '<a class="btn amber' . ($this->action() === 'create' ? ' active' : '') . '" href="' . self::e($this->url('create.html')) . '">' . self::e($this->t('ADD AUTORESPONDER')) . '</a></div>';
echo '<section class="main-section">' . $alerts . $this->lang->translateHtml($body) . '</section></div>';
echo '<script>' . $this->scripts() . '</script></body></html>';
}
public static function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
private function styles(): string
{
$skin = $this->daUser->skin();
$path = PLUGIN_ROOT . '/user/' . $skin . '.css';
if (!is_file($path)) {
$path = PLUGIN_ROOT . '/user/enhanced.css';
}
return is_file($path) ? (string)file_get_contents($path) : '';
}
private function scripts(): string
{
return <<<'JS'
(function () {
document.querySelectorAll('[data-date-value]').forEach(function (btn) {
btn.addEventListener('click', function () {
var target = document.getElementById(btn.getAttribute('data-date-target'));
var label = document.getElementById(btn.getAttribute('data-date-label'));
if (target) { target.value = btn.getAttribute('data-date-value') || ''; }
if (label) { label.textContent = 'Wybrano: ' + (btn.getAttribute('data-date-value') || ''); }
btn.closest('.br-restore-calendar').querySelectorAll('.br-cal-day').forEach(function (d) { d.classList.remove('is-selected'); });
btn.classList.add('is-selected');
});
});
})();
JS;
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
final class AuditLog
{
public function __construct(private string $path = Settings::AUDIT_LOG)
{
}
/** @param array<string,string|int|bool> $fields */
public function record(string $action, string $user, array $fields = []): void
{
$dir = dirname($this->path);
if (!is_dir($dir)) {
@mkdir($dir, 0700, true);
}
unset($fields['body']);
$line = json_encode([
'ts' => date('c'),
'action' => $action,
'user' => $user,
'fields' => $fields,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($line !== false) {
@file_put_contents($this->path, $line . "\n", FILE_APPEND | LOCK_EX);
}
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
final class CsrfGuard
{
public function __construct(private string $secret, private string $username, private string $sessionId)
{
$this->sessionId = $this->sessionId !== '' ? $this->sessionId : 'no_session';
}
/**
* @return array{ts:int,sid:string,token:string}
*/
public function issue(string $intent): array
{
$ts = time();
return ['ts' => $ts, 'sid' => $this->sessionId, 'token' => $this->buildToken($intent, $ts, $this->sessionId)];
}
public function validate(string $intent, string $timestamp, string $token, int $ttl = 3600, string $sessionHint = ''): bool
{
if (!preg_match('/^[0-9]{1,20}$/', $timestamp)) {
return false;
}
$ts = (int)$timestamp;
$now = time();
if ($ts <= 0 || $ts > $now + 30 || $now - $ts > $ttl) {
return false;
}
$sids = array_values(array_unique(array_filter([$sessionHint, $this->sessionId, 'no_session'], static fn ($v): bool => $v !== '')));
foreach ($sids as $sid) {
if (hash_equals($this->buildToken($intent, $ts, $sid), trim($token))) {
return true;
}
}
return false;
}
private function buildToken(string $intent, int $timestamp, string $sid): string
{
return hash_hmac('sha256', implode('|', [$this->username, $sid, $intent, (string)$timestamp]), $this->secret);
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
final class DirectAdminSyncRepository
{
public function __construct(private string $baseDir = Settings::DATA_DIR)
{
}
/** @param array<int,array<string,mixed>> $entries */
public function replaceRuleEntries(string $username, string $ruleId, array $entries): void
{
$data = $this->read($username);
$data['rules'][$ruleId] = array_values($entries);
$this->write($username, $data);
}
/** @return array<int,array<string,mixed>> */
public function entriesForRule(string $username, string $ruleId): array
{
$data = $this->read($username);
return array_values($data['rules'][$ruleId] ?? []);
}
/** @return array<int,array<string,mixed>> */
public function deleteRuleEntries(string $username, string $ruleId): array
{
$data = $this->read($username);
$entries = array_values($data['rules'][$ruleId] ?? []);
unset($data['rules'][$ruleId]);
$this->write($username, $data);
return $entries;
}
/** @return array{rules:array<string,array<int,array<string,mixed>>>} */
private function read(string $username): array
{
$path = $this->path($username);
if (!is_file($path)) {
return ['rules' => []];
}
$data = json_decode((string)file_get_contents($path), true);
return is_array($data) && isset($data['rules']) && is_array($data['rules']) ? $data : ['rules' => []];
}
/** @param array{rules:array<string,array<int,array<string,mixed>>>} $data */
private function write(string $username, array $data): void
{
$path = $this->path($username);
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0700, true);
}
$tmp = $path . '.tmp.' . getmypid();
file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
@chmod($tmp, 0600);
rename($tmp, $path);
}
private function path(string $username): string
{
if (!preg_match('/^[A-Za-z0-9._-]+$/', $username)) {
throw new InvalidArgumentException('Invalid username');
}
return rtrim($this->baseDir, '/') . '/users/' . $username . '/da-sync.json';
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
final class DirectAdminUser
{
private string $username;
private string $skin;
private string $language;
private string $configDir;
public function __construct(string $username, string $skin = 'enhanced', string $language = 'en', string $configDir = Settings::CONFIG_DIR)
{
if (!preg_match('/^[A-Za-z0-9._-]+$/', $username)) {
throw new InvalidArgumentException('Invalid username');
}
$this->username = $username;
$this->skin = $skin !== '' ? $skin : 'enhanced';
$this->language = $language !== '' ? strtolower($language) : 'en';
$this->configDir = $configDir;
}
public static function fromEnvironment(Settings $settings): self
{
$username = getenv('USERNAME') ?: getenv('USER') ?: getenv('DA_USER') ?: '';
if ($username === '' && isset($_SERVER['USERNAME'])) {
$username = (string)$_SERVER['USERNAME'];
}
if ($username === '') {
throw new RuntimeException('Cannot determine DirectAdmin user');
}
$skin = getenv('SKIN') ?: (string)($_SERVER['SKIN'] ?? 'enhanced');
$lang = self::readLanguage($username) ?: $settings->defaultLanguage();
return new self($username, $skin, $lang);
}
public function username(): string
{
return $this->username;
}
public function skin(): string
{
return $this->skin;
}
public function language(): string
{
return $this->language;
}
public function hasPluginAccess(Settings $settings): bool
{
$override = $this->configDir . '/users/' . $this->username . '.conf';
if (is_file($override)) {
$data = Settings::load($override);
return $data->getBool('enabled', $settings->defaultEnable());
}
return $settings->defaultEnable();
}
private static function readLanguage(string $username): string
{
$path = '/usr/local/directadmin/data/users/' . $username . '/user.conf';
if (!is_file($path)) {
return '';
}
$lines = file($path, FILE_IGNORE_NEW_LINES);
if ($lines === false) {
return '';
}
foreach ($lines as $line) {
if (str_starts_with($line, 'language=')) {
return strtolower(trim(substr($line, 9)));
}
}
return '';
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
final class DirectAdminVacationApi
{
public function __construct(private string $directadminBinary = '/usr/local/directadmin/directadmin')
{
}
/** @param array<string,mixed> $rule @return array<string,string> */
public function buildSavePayload(string $email, array $rule): 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'] ?? ''));
return [
'action' => 'modify',
'domain' => $domain,
'user' => $local,
'email' => $email,
'text' => (string)($rule['body'] ?? ''),
'subject' => (string)($rule['subject'] ?? ''),
'startdate' => $startDate,
'starttime' => $startTime,
'enddate' => $endDate,
'endtime' => $endTime,
];
}
/** @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]];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
final class Http
{
public static function method(): string
{
return strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
}
public static function isPost(): bool
{
return self::method() === 'POST';
}
/**
* @param array<string,mixed> $source
*/
public static function getString(array $source, string $key, string $default = ''): string
{
$value = $source[$key] ?? $default;
return is_scalar($value) ? trim((string)$value) : $default;
}
public static function redirect(string $url): void
{
header('Location: ' . $url, true, 302);
exit;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
final class Lang
{
/** @var array<string,string> */
private array $map;
private string $code;
/**
* @param array<string,string> $map
*/
private function __construct(string $code, array $map)
{
$this->code = $code;
$this->map = $map;
}
public static function load(string $preferred, Settings $settings): self
{
$candidates = [strtolower(trim($preferred)), $settings->defaultLanguage(), 'en'];
foreach ($candidates as $code) {
if (!preg_match('/^[a-z]{2}$/', $code)) {
continue;
}
$path = PLUGIN_ROOT . '/lang/' . $code . '.php';
if (is_file($path)) {
$data = require $path;
return new self($code, is_array($data) ? $data : []);
}
}
return new self('en', []);
}
public function code(): string
{
return $this->code;
}
/**
* @param array<string,string|int|float> $vars
*/
public function t(string $key, array $vars = []): string
{
$text = $this->map[$key] ?? $key;
foreach ($vars as $var => $value) {
$text = str_replace('{' . $var . '}', (string)$value, $text);
}
return $text;
}
public function translateHtml(string $html): string
{
if ($html === '' || empty($this->map)) {
return $html;
}
$keys = array_keys($this->map);
usort($keys, static fn (string $a, string $b): int => strlen($b) <=> strlen($a));
return str_replace($keys, array_map(fn (string $k): string => $this->map[$k], $keys), $html);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
final class LocalizedException extends RuntimeException
{
/** @param array<string,string|int|float> $vars */
public function __construct(private string $key, private array $vars = [])
{
parent::__construct($key);
}
public function key(): string
{
return $this->key;
}
/** @return array<string,string|int|float> */
public function vars(): array
{
return $this->vars;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
final class MailSender
{
public static function buildMessage(string $from, string $to, string $subject, string $body): string
{
foreach ([$from, $to, $subject] as $value) {
if (str_contains($value, "\r") || str_contains($value, "\n")) {
throw new InvalidArgumentException('Invalid subject or address header');
}
}
$encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
$headers = [
'From: ' . $from,
'To: ' . $to,
'Subject: ' . $encodedSubject,
'Auto-Submitted: auto-replied',
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: 8bit',
];
return implode("\n", $headers) . "\n\n" . str_replace(["\r\n", "\r"], "\n", $body) . "\n";
}
public function send(string $sendmail, string $from, string $to, string $subject, string $body): bool
{
$message = self::buildMessage($from, $to, $subject, $body);
if (!is_file($sendmail) || !is_executable($sendmail)) {
return false;
}
$process = proc_open([$sendmail, '-t', '-i'], [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $pipes);
if (!is_resource($process)) {
return false;
}
fwrite($pipes[0], $message);
fclose($pipes[0]);
stream_get_contents($pipes[1]);
stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return proc_close($process) === 0;
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
final class MailboxDirectory
{
public function __construct(private string $virtualRoot = '/etc/virtual')
{
}
/** @return array<int,array{domain:string,mailbox:string,email:string}> */
public function mailboxesForUser(string $username): array
{
$domains = $this->domainsForUser($username);
$out = [];
foreach ($domains as $domain) {
$passwd = $this->virtualRoot . '/' . $domain . '/passwd';
if (!is_file($passwd)) {
continue;
}
$lines = file($passwd, FILE_IGNORE_NEW_LINES) ?: [];
foreach ($lines as $line) {
$local = trim(strtok($line, ':') ?: '');
if ($local === '' || !preg_match('/^[A-Za-z0-9._+-]+$/', $local)) {
continue;
}
$out[] = ['domain' => $domain, 'mailbox' => $local, 'email' => $local . '@' . $domain];
}
}
usort($out, static fn (array $a, array $b): int => strcmp($a['email'], $b['email']));
return $out;
}
/** @return string[] */
private function domainsForUser(string $username): array
{
$path = $this->virtualRoot . '/domainowners';
if (!is_file($path)) {
return [];
}
$domains = [];
foreach (file($path, FILE_IGNORE_NEW_LINES) ?: [] as $line) {
if (!str_contains($line, ':')) {
continue;
}
[$domain, $owner] = array_map('trim', explode(':', $line, 2));
if ($owner !== $username || !preg_match('/^[A-Za-z0-9.-]+$/', $domain)) {
continue;
}
$domains[] = strtolower($domain);
}
sort($domains);
return $domains;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
final class MessageClassifier
{
/** @param array<string,string> $headers */
public static function shouldSuppress(array $headers, string $sender): bool
{
$sender = strtolower(trim($sender));
if ($sender === '' || str_starts_with($sender, 'mailer-daemon') || str_starts_with($sender, 'postmaster')) {
return true;
}
$normalized = [];
foreach ($headers as $key => $value) {
$normalized[strtolower(trim($key))] = strtolower(trim($value));
}
if (($normalized['auto-submitted'] ?? 'no') !== 'no') {
return true;
}
if (in_array($normalized['precedence'] ?? '', ['bulk', 'list', 'junk'], true)) {
return true;
}
if (($normalized['list-id'] ?? '') !== '') {
return true;
}
if (str_contains($normalized['content-type'] ?? '', 'delivery-status')) {
return true;
}
return false;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
final class RepeatState
{
public function __construct(private string $baseDir = Settings::STATE_DIR, private ?int $now = null)
{
}
public function shouldSend(string $owner, string $ruleId, string $recipient, string $sender, int $repeatMinutes): bool
{
if ($repeatMinutes <= 0) {
return true;
}
$path = $this->path($owner, $ruleId, $recipient, $sender);
if (!is_file($path)) {
return true;
}
$data = json_decode((string)file_get_contents($path), true);
$last = is_array($data) ? (int)($data['last_sent_at'] ?? 0) : 0;
return ($this->time() - $last) >= ($repeatMinutes * 60);
}
public function record(string $owner, string $ruleId, string $recipient, string $sender): void
{
$path = $this->path($owner, $ruleId, $recipient, $sender);
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0700, true);
}
file_put_contents($path, json_encode(['last_sent_at' => $this->time()], JSON_PRETTY_PRINT), LOCK_EX);
@chmod($path, 0600);
}
private function path(string $owner, string $ruleId, string $recipient, string $sender): string
{
if (!preg_match('/^[A-Za-z0-9._-]+$/', $owner)) {
throw new InvalidArgumentException('Invalid owner');
}
$hash = hash('sha256', implode('|', [$owner, $ruleId, strtolower($recipient), strtolower($sender)]));
return rtrim($this->baseDir, '/') . '/replies/' . $owner . '/' . $hash . '.json';
}
private function time(): int
{
return $this->now ?? time();
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
final class RuleRepository
{
public function __construct(private string $baseDir = Settings::DATA_DIR)
{
}
/** @return array<int,array<string,mixed>> */
public function all(string $username): array
{
$data = $this->read($username);
return array_values($data['rules'] ?? []);
}
/** @return array<string,mixed>|null */
public function find(string $username, string $id): ?array
{
foreach ($this->all($username) as $rule) {
if (($rule['id'] ?? '') === $id) {
return $rule;
}
}
return null;
}
/** @param array<string,mixed> $rule @return array<string,mixed> */
public function create(string $username, array $rule): array
{
$data = $this->read($username);
$now = time();
$rule['id'] = bin2hex(random_bytes(12));
$rule['created_at'] = $now;
$rule['updated_at'] = $now;
$data['rules'][] = $rule;
$this->write($username, $data);
return $rule;
}
/** @param array<string,mixed> $patch @return array<string,mixed> */
public function update(string $username, string $id, array $patch): array
{
$data = $this->read($username);
foreach ($data['rules'] as $i => $rule) {
if (($rule['id'] ?? '') === $id) {
$updated = array_merge($rule, $patch, ['id' => $id, 'updated_at' => time()]);
$data['rules'][$i] = $updated;
$this->write($username, $data);
return $updated;
}
}
throw new RuntimeException('Rule not found');
}
public function delete(string $username, string $id): void
{
$data = $this->read($username);
$before = count($data['rules']);
$data['rules'] = array_values(array_filter($data['rules'], static fn (array $rule): bool => ($rule['id'] ?? '') !== $id));
if (count($data['rules']) === $before) {
throw new RuntimeException('Rule not found');
}
$this->write($username, $data);
}
/** @return array{rules:array<int,array<string,mixed>>} */
private function read(string $username): array
{
$path = $this->path($username);
if (!is_file($path)) {
return ['rules' => []];
}
$data = json_decode((string)file_get_contents($path), true);
return is_array($data) && isset($data['rules']) && is_array($data['rules']) ? $data : ['rules' => []];
}
/** @param array{rules:array<int,array<string,mixed>>} $data */
private function write(string $username, array $data): void
{
$path = $this->path($username);
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0700, true);
}
$tmp = $path . '.tmp.' . getmypid();
file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
@chmod($tmp, 0600);
rename($tmp, $path);
}
private function path(string $username): string
{
$this->assertUsername($username);
return rtrim($this->baseDir, '/') . '/users/' . $username . '/rules.json';
}
private function assertUsername(string $username): void
{
if (!preg_match('/^[A-Za-z0-9._-]+$/', $username)) {
throw new InvalidArgumentException('Invalid username');
}
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
final class RuleValidator
{
/** @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.');
}
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.');
}
$body = str_replace(["\r\n", "\r", "\0"], ["\n", "\n", ''], (string)($input['body'] ?? ''));
$body = trim($body);
if ($body === '') {
throw new InvalidArgumentException('Treść wiadomości jest wymagana.');
}
if (strlen($body) > $settings->maxBodyBytes()) {
throw new InvalidArgumentException('Treść wiadomości jest za długa.');
}
$mode = $settings->scheduleMode();
$tz = new DateTimeZone($settings->timezone());
[$startTs, $startValue] = self::parseScheduleValue((string)($input['start_value'] ?? ''), $mode, $tz, true);
[$endTs, $endValue] = self::parseScheduleValue((string)($input['end_value'] ?? ''), $mode, $tz, false);
if ($endTs <= $startTs) {
throw new InvalidArgumentException('Data zakończenia musi być późniejsza niż data rozpoczęcia.');
}
return [
'subject' => $subject,
'body' => $body,
'schedule_mode' => $mode,
'timezone' => $settings->timezone(),
'start_value' => $startValue,
'end_value' => $endValue,
'start_ts' => $startTs,
'end_ts' => $endTs,
'enabled' => self::truthy($input['enabled'] ?? false),
'dynamic_scope' => $settings->dynamicMailboxScope(),
];
}
/** @return array{0:int,1:string} */
private static function parseScheduleValue(string $value, string $mode, DateTimeZone $tz, bool $start): array
{
$value = trim($value);
if ($mode === 'exact_hour') {
if (!preg_match('/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:00$/', $value)) {
throw new InvalidArgumentException('Nieprawidłowa data lub godzina.');
}
$dt = DateTimeImmutable::createFromFormat('!Y-m-d\TH:i', $value, $tz);
if (!$dt) {
throw new InvalidArgumentException('Nieprawidłowa data lub godzina.');
}
return [$dt->getTimestamp(), $value];
}
if (!preg_match('/^([0-9]{4}-[0-9]{2}-[0-9]{2})\|(morning|afternoon|evening)$/', $value, $m)) {
throw new InvalidArgumentException('Nieprawidłowa data lub pora dnia.');
}
$hour = ['morning' => 8, 'afternoon' => 13, 'evening' => 18][$m[2]];
if (!$start && $m[2] === 'evening') {
$hour = 23;
}
$dt = DateTimeImmutable::createFromFormat('!Y-m-d H:i', $m[1] . ' ' . sprintf('%02d:00', $hour), $tz);
if (!$dt) {
throw new InvalidArgumentException('Nieprawidłowa data lub pora dnia.');
}
return [$dt->getTimestamp(), $value];
}
private static function truthy(mixed $value): bool
{
return in_array(strtolower((string)$value), ['1', 'true', 'yes', 'on'], true);
}
}
+161
View File
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
final class Settings
{
public const BASE_DIR = '/usr/local/hitme_plugins/global-autoresponders';
public const CONFIG_DIR = self::BASE_DIR . '/config';
public const DATA_DIR = self::BASE_DIR . '/data';
public const STATE_DIR = self::BASE_DIR . '/state';
public const LOG_DIR = self::BASE_DIR . '/logs';
public const AUDIT_LOG = self::LOG_DIR . '/audit.log';
public const EXTERNAL_SETTINGS = self::CONFIG_DIR . '/plugin-settings.conf';
/** @var array<string,string> */
private array $values;
/**
* @param array<string,string> $values
*/
private function __construct(array $values)
{
$this->values = $values;
$this->validate();
}
public static function load(string $path): self
{
$values = self::defaults();
if (is_file($path)) {
$lines = file($path, FILE_IGNORE_NEW_LINES);
if ($lines !== false) {
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
continue;
}
[$key, $value] = explode('=', $line, 2);
$values[trim($key)] = trim($value);
}
}
}
return new self($values);
}
/**
* @return array<string,string>
*/
public static function defaults(): array
{
return [
'DEFAULT_PLUGIN_LANGUAGE' => 'en',
'DEFAULT_ENABLE' => 'true',
'GLOBAL_AUTORESPONDER_TIMEZONE' => 'Europe/Warsaw',
'GLOBAL_AUTORESPONDER_SCHEDULE_MODE' => 'exact_hour',
'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',
];
}
public function getString(string $key, string $default = ''): string
{
return $this->values[$key] ?? $default;
}
public function getBool(string $key, bool $default = false): bool
{
$raw = strtolower($this->getString($key, $default ? 'true' : 'false'));
return in_array($raw, ['1', 'true', 'yes', 'on'], true);
}
public function getInt(string $key, int $default): int
{
$raw = $this->getString($key, (string)$default);
return preg_match('/^-?[0-9]+$/', $raw) ? (int)$raw : $default;
}
public function defaultLanguage(): string
{
$lang = strtolower($this->getString('DEFAULT_PLUGIN_LANGUAGE', 'en'));
return preg_match('/^[a-z]{2}$/', $lang) ? $lang : 'en';
}
public function defaultEnable(): bool
{
return $this->getBool('DEFAULT_ENABLE', true);
}
public function timezone(): string
{
return $this->getString('GLOBAL_AUTORESPONDER_TIMEZONE', 'Europe/Warsaw');
}
public function scheduleMode(): string
{
return $this->getString('GLOBAL_AUTORESPONDER_SCHEDULE_MODE', '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);
}
public function maxSubjectBytes(): int
{
return $this->getInt('GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES', 255);
}
public function maxBodyBytes(): int
{
return $this->getInt('GLOBAL_AUTORESPONDER_MAX_BODY_BYTES', 20000);
}
public function loadOrCreateSecret(string $path = self::CONFIG_DIR . '/secret.key'): string
{
if (is_file($path)) {
$secret = trim((string)file_get_contents($path));
if ($secret !== '') {
return $secret;
}
}
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0700, true);
}
$secret = bin2hex(random_bytes(32));
file_put_contents($path, $secret . "\n", LOCK_EX);
@chmod($path, 0600);
return $secret;
}
private function validate(): void
{
try {
new DateTimeZone($this->timezone());
} catch (Throwable) {
throw new InvalidArgumentException('Invalid timezone: ' . $this->timezone());
}
if (!in_array($this->scheduleMode(), ['exact_hour', 'directadmin_period'], true)) {
throw new InvalidArgumentException('Invalid schedule mode: ' . $this->scheduleMode());
}
if ($this->repeatMinutes() < 0) {
throw new InvalidArgumentException('Invalid repeat minutes');
}
if ($this->maxSubjectBytes() < 1 || $this->maxBodyBytes() < 1) {
throw new InvalidArgumentException('Invalid size limits');
}
}
}