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
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
if (!defined('IN_DA_PLUGIN') || IN_DA_PLUGIN !== true) {
http_response_code(403);
echo 'Forbidden';
exit;
}
define('PLUGIN_ROOT', dirname(__DIR__));
define('PLUGIN_EXEC_DIR', __DIR__);
foreach ([
'Settings.php',
'Http.php',
'DirectAdminUser.php',
'Lang.php',
'LocalizedException.php',
'CsrfGuard.php',
'RuleRepository.php',
'DirectAdminSyncRepository.php',
'RuleValidator.php',
'MailboxDirectory.php',
'AuditLog.php',
'AppContext.php',
] as $file) {
require_once PLUGIN_EXEC_DIR . '/lib/' . $file;
}
require_once PLUGIN_EXEC_DIR . '/handlers/_form_helpers.php';
try {
$settings = Settings::load(Settings::EXTERNAL_SETTINGS);
$user = DirectAdminUser::fromEnvironment($settings);
$lang = Lang::load($user->language(), $settings);
if (!$user->hasPluginAccess($settings)) {
http_response_code(403);
echo 'Plugin is not enabled for this account.';
exit;
}
$secret = $settings->loadOrCreateSecret();
$csrf = new CsrfGuard($secret, $user->username(), (string)($_SERVER['SESSION_ID'] ?? getenv('SESSION_ID') ?: ''));
$ctx = new AppContext($user, $settings, new RuleRepository(), $csrf, $lang, new AuditLog());
$action = basename((string)PLUGIN_ACTION);
$handlerPath = PLUGIN_EXEC_DIR . '/handlers/' . $action . '.php';
if (!is_file($handlerPath)) {
throw new RuntimeException('Missing handler: ' . $action);
}
$handler = require $handlerPath;
if (!is_callable($handler)) {
throw new RuntimeException('Invalid handler: ' . $action);
}
$handler($ctx);
} catch (Throwable $e) {
http_response_code(500);
header('Content-Type: text/html; charset=UTF-8');
echo '<!doctype html><html><head><meta charset="utf-8"><title>Błąd</title></head><body>';
echo '<h1>Błąd pluginu</h1><p>' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</p>';
echo '</body></html>';
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
function render_rule_form(AppContext $ctx, string $mode, ?array $rule): string
{
$isUpdate = $mode === 'update';
$id = $isUpdate ? (string)$rule['id'] : '';
$subject = $rule['subject'] ?? '';
$body = $rule['body'] ?? '';
$enabled = $rule === null || !empty($rule['enabled']);
$startValue = (string)($rule['start_value'] ?? '2026-06-10T09:00');
$endValue = (string)($rule['end_value'] ?? '2026-06-24T17:00');
$action = $ctx->url($isUpdate ? 'update.html' : 'create.html');
$intent = $isUpdate ? 'update:' . $id : 'create';
$csrf = $ctx->csrfField($intent);
$hiddenId = $isUpdate ? '<input type="hidden" name="id" value="' . AppContext::e($id) . '">' : '';
return '<form method="post" action="' . AppContext::e($action) . '">' . $csrf . $hiddenId .
'<div class="grid grid-2"><div class="panel"><h3>Treść odpowiedzi</h3><div class="row"><div><label>' . AppContext::e($ctx->t('Subject')) . '</label><input type="text" name="subject" value="' . AppContext::e((string)$subject) . '"></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>' .
'<div class="panel"><h3>Podsumowanie</h3><div class="summary-card"><div class="kv"><strong>Zakres</strong><span>' . AppContext::e($ctx->t('All valid POP/IMAP mailboxes in this account')) . '</span></div>' .
'<div class="kv"><strong>Start</strong><span>' . AppContext::e($startValue) . '</span></div><div class="kv"><strong>Koniec</strong><span>' . AppContext::e($endValue) . '</span></div></div>' .
'<div class="actions"><button class="primary" type="submit">' . AppContext::e($ctx->t($isUpdate ? 'Save changes' : 'Save autoresponder')) . '</button><a class="btn" href="' . AppContext::e($ctx->url('index.html')) . '">' . AppContext::e($ctx->t('Cancel')) . '</a></div></div></div>' .
render_calendar_picker('start_value', 'Data rozpoczęcia', $startValue) .
render_calendar_picker('end_value', 'Data zakończenia', $endValue) .
'</form>';
}
/** @param array<string,mixed> $post @return array<string,mixed> */
function normalize_rule_post(array $post): array
{
foreach (['start_value', 'end_value'] as $key) {
$date = trim((string)($post[$key . '_date'] ?? ''));
$hour = trim((string)($post[$key . '_hour'] ?? ''));
if ($date !== '' && preg_match('/^[0-9]{2}$/', $hour)) {
$post[$key] = $date . 'T' . $hour . ':00';
}
}
return $post;
}
function render_calendar_picker(string $name, string $title, string $value): string
{
$selectedDate = substr($value, 0, 10);
$selectedHour = substr($value, 11, 2) ?: '09';
$days = '';
for ($week = 0, $day = 1; $week < 4; $week++) {
$days .= '<tr>';
for ($i = 0; $i < 7; $i++, $day++) {
$date = sprintf('2026-06-%02d', $day);
$class = $date === $selectedDate ? ' is-selected' : '';
$days .= '<td><button type="button" class="br-cal-day' . $class . '" data-date-target="' . AppContext::e($name . '_date') . '" data-date-label="' . AppContext::e($name . '_label') . '" data-date-value="' . AppContext::e($date) . '">' . $day . '</button></td>';
}
$days .= '</tr>';
}
$hours = '';
foreach (['08', '09', '10', '11', '12', '13', '14', '15', '16', '17', '18'] as $hour) {
$hours .= '<label class="br-time-item"><input type="radio" name="' . AppContext::e($name . '_hour') . '" value="' . $hour . '" ' . ($hour === $selectedHour ? 'checked' : '') . '> ' . $hour . ':00</label>';
}
return '<div class="panel"><h3>' . AppContext::e($title) . '</h3><div class="br-restore-layout"><div class="br-restore-calendar"><h4>' . AppContext::e($title) . '</h4><div class="br-month-nav"><button type="button" class="br-month-btn">&lsaquo;</button><span class="br-month-label">Czerwiec 2026</span><button type="button" class="br-month-btn">&rsaquo;</button></div>' .
'<table class="br-calendar-grid"><thead><tr><th>Pn</th><th>Wt</th><th>Śr</th><th>Cz</th><th>Pt</th><th>Sb</th><th>Nd</th></tr></thead><tbody>' . $days . '</tbody></table>' .
'<div class="br-date-label" id="' . AppContext::e($name . '_label') . '">Wybrano: ' . AppContext::e($selectedDate) . '</div><input type="hidden" id="' . AppContext::e($name . '_date') . '" name="' . AppContext::e($name . '_date') . '" value="' . AppContext::e($selectedDate) . '"></div>' .
'<div class="br-restore-calendar"><h4>Godzina</h4><div class="br-time-picker"><div class="br-time-title">Wybierz godzinę</div><div class="br-time-list">' . $hours . '</div></div></div></div>' .
'<input type="hidden" name="' . AppContext::e($name) . '" value="' . AppContext::e($value) . '"></div>';
}
function render_preview_box(array $rule): string
{
return '<div class="preview-box"><div><strong>Temat:</strong> ' . AppContext::e((string)$rule['subject']) . '</div><br><div>' . nl2br(AppContext::e((string)$rule['body'])) . '</div></div>';
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
$errors = [];
if (Http::isPost()) {
try {
$ctx->requireCsrf('create');
$input = normalize_rule_post($_POST);
$rule = RuleValidator::validate($input, $ctx->settings);
$ctx->rules->create($ctx->daUser->username(), $rule);
Http::redirect($ctx->url('index.html', ['status' => 'created']));
} catch (Throwable $e) {
$errors[] = $e->getMessage();
}
}
$ctx->render('Add autoresponder', render_rule_form($ctx, 'create', null), [], $errors);
};
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
$id = Http::isPost() ? $ctx->post('id') : $ctx->query('id');
$rule = $ctx->rules->find($ctx->daUser->username(), $id);
if ($rule === null) {
$ctx->render('Confirm deletion', '<div class="alert alert-err">Rule not found</div>');
return;
}
if (Http::isPost()) {
try {
$ctx->requireCsrf('delete:' . $id);
$ctx->rules->delete($ctx->daUser->username(), $id);
$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) {
$ctx->render('Confirm deletion', '<div class="alert alert-err">' . AppContext::e($e->getMessage()) . '</div>');
return;
}
}
$body = '<div class="grid grid-2"><div class="panel"><h3>' . AppContext::e($ctx->t('Preview autoresponder')) . '</h3>' . render_preview_box($rule) . '</div>' .
'<div class="panel"><h3>' . AppContext::e($ctx->t('Confirm deletion')) . '</h3><div class="alert alert-err">Usunięcie reguły wyłączy odpowiedź automatyczną utworzoną przez tę regułę dla objętych skrzynek.</div>' .
'<form method="post" action="' . AppContext::e($ctx->url('delete.html')) . '">' . $ctx->csrfField('delete:' . $id) . '<input type="hidden" name="id" value="' . AppContext::e($id) . '">' .
'<div class="actions"><button class="danger-fill" type="submit">' . AppContext::e($ctx->t('Delete autoresponder')) . '</button><a class="btn" href="' . AppContext::e($ctx->url('index.html')) . '">' . AppContext::e($ctx->t('Do not delete')) . '</a></div></form></div></div>';
$ctx->render('Confirm deletion', $body);
};
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
$rules = $ctx->rules->all($ctx->daUser->username());
$ok = [];
$status = $ctx->query('status');
if ($status === 'created') { $ok[] = $ctx->t('Rule created.'); }
if ($status === 'updated') { $ok[] = $ctx->t('Rule updated.'); }
if ($status === 'deleted') { $ok[] = $ctx->t('Rule deleted.'); }
if ($status === 'toggled') { $ok[] = $ctx->t('Rule status changed.'); }
if ($rules === []) {
$body = '<div class="panel"><div class="empty-state"><div><h3>' . AppContext::e($ctx->t('No global autoresponders')) . '</h3>' .
'<p class="muted">' . AppContext::e($ctx->t('All valid POP/IMAP mailboxes in this account')) . '</p>' .
'<a class="btn amber" href="' . AppContext::e($ctx->url('create.html')) . '">' . AppContext::e($ctx->t('Add autoresponder')) . '</a></div></div></div>';
$ctx->render('Global autoresponders', $body, $ok);
return;
}
$rows = '';
foreach ($rules as $rule) {
$id = (string)$rule['id'];
$enabled = !empty($rule['enabled']);
$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(date('Y-m-d H:i', (int)$rule['start_ts'])) . '<br><span class="muted">do ' . AppContext::e(date('Y-m-d H:i', (int)$rule['end_ts'])) . '</span></td>';
$rows .= '<td>' . AppContext::e($rule['dynamic_scope'] ? 'Dynamiczny' : 'Snapshot') . '</td><td><span class="table-actions">';
$rows .= '<a class="btn" href="' . AppContext::e($ctx->url('update.html', ['id' => $id])) . '">' . AppContext::e($ctx->t('Edit')) . '</a>';
$rows .= '<a class="btn" href="' . AppContext::e($ctx->url('preview.html', ['id' => $id])) . '">' . AppContext::e($ctx->t('Preview')) . '</a>';
$rows .= '<form method="post" action="' . AppContext::e($ctx->url('toggle.html')) . '">' . $ctx->csrfField('toggle:' . $id) . '<input type="hidden" name="id" value="' . AppContext::e($id) . '"><button>' . AppContext::e($ctx->t($enabled ? 'Disable' : 'Enable')) . '</button></form>';
$rows .= '<a class="btn danger" href="' . AppContext::e($ctx->url('delete.html', ['id' => $id])) . '">' . AppContext::e($ctx->t('Delete')) . '</a>';
$rows .= '</span></td></tr>';
}
$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>Status</th><th>' . AppContext::e($ctx->t('Subject')) . '</th><th>Okres wysyłki</th><th>Zakres</th><th>Akcje</th></tr></thead><tbody>' . $rows . '</tbody></table></div>';
$ctx->render('Global autoresponders', $body, $ok);
};
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
$id = $ctx->query('id');
$rule = $ctx->rules->find($ctx->daUser->username(), $id);
if ($rule === null) {
$ctx->render('Preview autoresponder', '<div class="alert alert-err">Rule not found</div>');
return;
}
$body = '<div class="grid grid-2"><div class="panel"><h3>' . AppContext::e($ctx->t('Preview autoresponder')) . '</h3>' . render_preview_box($rule) . '</div>' .
'<div class="panel"><h3>Szczegóły reguły</h3><div class="summary-card"><div class="kv"><strong>Status</strong><span>' . AppContext::e($ctx->t(!empty($rule['enabled']) ? 'Enabled' : 'Disabled')) . '</span></div>' .
'<div class="kv"><strong>Okres</strong><span>' . AppContext::e(date('Y-m-d H:i', (int)$rule['start_ts']) . ' - ' . date('Y-m-d H:i', (int)$rule['end_ts'])) . '</span></div></div>' .
'<div class="actions"><a class="btn" href="' . AppContext::e($ctx->url('update.html', ['id' => $id])) . '">' . AppContext::e($ctx->t('Edit')) . '</a><a class="btn danger" href="' . AppContext::e($ctx->url('delete.html', ['id' => $id])) . '">' . AppContext::e($ctx->t('Delete')) . '</a><a class="btn" href="' . AppContext::e($ctx->url('index.html')) . '">Powrót</a></div></div></div>';
$ctx->render('Preview autoresponder', $body);
};
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
$ctx->requirePost();
$id = $ctx->post('id');
$ctx->requireCsrf('toggle:' . $id);
$rule = $ctx->rules->find($ctx->daUser->username(), $id);
if ($rule === null) {
throw new RuntimeException('Rule not found');
}
$ctx->rules->update($ctx->daUser->username(), $id, ['enabled' => empty($rule['enabled'])]);
Http::redirect($ctx->url('index.html', ['status' => 'toggled']));
};
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
$id = Http::isPost() ? $ctx->post('id') : $ctx->query('id');
$rule = $ctx->rules->find($ctx->daUser->username(), $id);
if ($rule === null) {
$ctx->render('Edit autoresponder', '<div class="alert alert-err">Rule not found</div>');
return;
}
$errors = [];
if (Http::isPost()) {
try {
$ctx->requireCsrf('update:' . $id);
$input = normalize_rule_post($_POST);
$patch = RuleValidator::validate($input, $ctx->settings);
$ctx->rules->update($ctx->daUser->username(), $id, $patch);
Http::redirect($ctx->url('index.html', ['status' => 'updated']));
} catch (Throwable $e) {
$errors[] = $e->getMessage();
}
}
$ctx->render('Edit autoresponder', render_rule_form($ctx, 'update', $rule), [], $errors);
};
+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');
}
}
}
+1
View File
@@ -0,0 +1 @@
images/user_icon.svg
+1
View File
@@ -0,0 +1 @@
Globalne autorespondery
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
return [
'Global autoresponders' => 'Global autoresponders',
'Manage automatic replies for account mailboxes' => 'Manage automatic replies for account mailboxes',
'Dashboard' => 'Dashboard',
'AUTORESPONDERS' => 'AUTORESPONDERS',
'ADD AUTORESPONDER' => 'ADD AUTORESPONDER',
'Add autoresponder' => 'Add autoresponder',
'Edit autoresponder' => 'Edit autoresponder',
'Preview autoresponder' => 'Preview autoresponder',
'Subject' => 'Subject',
'Message body' => 'Message body',
'Status' => 'Status',
'Enabled' => 'Enabled',
'Disabled' => 'Disabled',
'Start date' => 'Start date',
'End date' => 'End date',
'Start hour' => 'Start hour',
'End hour' => 'End hour',
'Save autoresponder' => 'Save autoresponder',
'Save changes' => 'Save changes',
'Cancel' => 'Cancel',
'Edit' => 'Edit',
'Preview' => 'Preview',
'Delete' => 'Delete',
'Enable' => 'Enable',
'Disable' => 'Disable',
'Confirm deletion' => 'Confirm deletion',
'Delete autoresponder' => 'Delete autoresponder',
'Do not delete' => 'Do not delete',
'No global autoresponders' => 'No global autoresponders',
'Rule created.' => 'Rule created.',
'Rule updated.' => 'Rule updated.',
'Rule deleted.' => 'Rule deleted.',
'Rule status changed.' => 'Rule status changed.',
'All valid POP/IMAP mailboxes in this account' => 'All valid POP/IMAP mailboxes in this account',
];
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
return [
'Global autoresponders' => 'Globalne autorespondery',
'Manage automatic replies for account mailboxes' => 'Zarządzanie odpowiedziami dla skrzynek pocztowych konta',
'Dashboard' => 'Pulpit',
'AUTORESPONDERS' => 'AUTORESPONDERY',
'ADD AUTORESPONDER' => 'DODAJ AUTORESPONDER',
'Add autoresponder' => 'Dodaj autoresponder',
'Edit autoresponder' => 'Edytuj autoresponder',
'Preview autoresponder' => 'Podgląd autorespondera',
'Subject' => 'Temat',
'Message body' => 'Treść wiadomości',
'Status' => 'Status',
'Enabled' => 'Włączony',
'Disabled' => 'Wyłączony',
'Start date' => 'Data rozpoczęcia',
'End date' => 'Data zakończenia',
'Start hour' => 'Godzina rozpoczęcia',
'End hour' => 'Godzina zakończenia',
'Save autoresponder' => 'Zapisz autoresponder',
'Save changes' => 'Zapisz zmiany',
'Cancel' => 'Anuluj',
'Edit' => 'Edytuj',
'Preview' => 'Podgląd',
'Delete' => 'Usuń',
'Enable' => 'Włącz',
'Disable' => 'Wyłącz',
'Confirm deletion' => 'Potwierdź usunięcie',
'Delete autoresponder' => 'Usuń autoresponder',
'Do not delete' => 'Nie usuwaj',
'No global autoresponders' => 'Brak globalnych autoresponderów',
'Rule created.' => 'Reguła została utworzona.',
'Rule updated.' => 'Reguła została zaktualizowana.',
'Rule deleted.' => 'Reguła została usunięta.',
'Rule status changed.' => 'Status reguły został zmieniony.',
'All valid POP/IMAP mailboxes in this account' => 'Wszystkie prawidłowe skrzynki POP/IMAP konta',
];
+7
View File
@@ -0,0 +1,7 @@
display_errors=Off
log_errors=On
html_errors=Off
default_charset=UTF-8
memory_limit=128M
max_execution_time=60
open_basedir=
+31
View File
@@ -0,0 +1,31 @@
# Domyślny język pluginu, gdy język użytkownika DirectAdmin nie jest obsługiwany.
DEFAULT_PLUGIN_LANGUAGE=en
# Czy plugin jest domyślnie dostępny dla wszystkich użytkowników.
# true = dostępny dla każdego użytkownika, false = wymaga indywidualnego włączenia.
DEFAULT_ENABLE=true
# Strefa czasu używana do interpretacji i wyświetlania dat rozpoczęcia oraz zakończenia.
GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw
# Tryb harmonogramu.
# exact_hour = wybór dnia i godziny.
# directadmin_period = wybór dnia oraz morning/afternoon/evening zgodnie z API DirectAdmin.
GLOBAL_AUTORESPONDER_SCHEDULE_MODE=exact_hour
# 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.
GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES=255
# Maksymalna długość treści wiadomości po normalizacji.
GLOBAL_AUTORESPONDER_MAX_BODY_BYTES=20000
+7
View File
@@ -0,0 +1,7 @@
name=global-autoresponder
id=global-autoresponder
type=user
author=HITME.PL
version=1.0.1
active=no
installed=no
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
set -eu
BASE_DIR="/usr/local/hitme_plugins/global-autoresponders"
STATE_FILE="$BASE_DIR/config/backend-state.json"
usage() {
echo "Usage: scripts/backend.sh da|exim" >&2
}
if [ "${1:-}" != "da" ] && [ "${1:-}" != "exim" ]; then
usage
exit 2
fi
mkdir -p "$BASE_DIR/config" "$BASE_DIR/data" "$BASE_DIR/state" "$BASE_DIR/logs"
chmod 700 "$BASE_DIR/config" "$BASE_DIR/logs"
if [ "$1" = "da" ]; then
cat > "$STATE_FILE" <<'EOF'
{"active_backend":"da","migration_status":"complete"}
EOF
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
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."
@@ -0,0 +1,2 @@
# global-autoresponder router placeholder
# This file must only be installed through DirectAdmin/CustomBuild-safe Exim customization paths.
@@ -0,0 +1,2 @@
# global-autoresponder transport placeholder
# This file must only be installed through DirectAdmin/CustomBuild-safe Exim customization paths.
+10
View File
@@ -0,0 +1,10 @@
#!/usr/local/bin/php
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/exec/lib/Settings.php';
require_once dirname(__DIR__) . '/exec/lib/MessageClassifier.php';
require_once dirname(__DIR__) . '/exec/lib/RepeatState.php';
require_once dirname(__DIR__) . '/exec/lib/MailSender.php';
echo "global-autoresponder worker placeholder: enable only after safe Exim integration review.\n";
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
set -eu
BASE_DIR="/usr/local/hitme_plugins/global-autoresponders"
PLUGIN_DIR="/usr/local/directadmin/plugins/global-autoresponder"
mkdir -p "$BASE_DIR/config/users" "$BASE_DIR/data/users" "$BASE_DIR/state/replies" "$BASE_DIR/logs"
chmod 700 "$BASE_DIR/config" "$BASE_DIR/logs"
chmod 711 "$BASE_DIR/data" "$BASE_DIR/state"
if [ ! -f "$BASE_DIR/config/plugin-settings.conf" ]; then
cp "$PLUGIN_DIR/plugin-settings.conf" "$BASE_DIR/config/plugin-settings.conf"
chmod 600 "$BASE_DIR/config/plugin-settings.conf"
fi
if [ ! -f "$BASE_DIR/config/backend-state.json" ]; then
printf '%s\n' '{"active_backend":"da","migration_status":"complete"}' > "$BASE_DIR/config/backend-state.json"
chmod 600 "$BASE_DIR/config/backend-state.json"
fi
touch "$BASE_DIR/logs/audit.log"
chmod 600 "$BASE_DIR/logs/audit.log"
echo "global-autoresponder installed"
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
set -eu
VERSION="$(awk -F= '$1=="version"{print $2}' plugin.conf)"
if [ -z "$VERSION" ]; then
echo "Cannot determine version from plugin.conf" >&2
exit 1
fi
if [ ! -f images/user_icon.svg ]; then
echo "Missing user-supplied icon: src/images/user_icon.svg" >&2
exit 1
fi
ROOT_DIR="$(cd .. && pwd)"
mkdir -p "$ROOT_DIR/archives/$VERSION"
tar -czf "$ROOT_DIR/archives/$VERSION/global-autoresponder.tar.gz" \
--exclude='./.git' \
--exclude='./tests' \
--exclude='./data' \
--exclude='./state' \
--exclude='./logs' \
--exclude='./*.log' \
--exclude='./.DS_Store' \
.
echo "$ROOT_DIR/archives/$VERSION/global-autoresponder.tar.gz"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/local/bin/php
<?php
declare(strict_types=1);
echo "DirectAdmin vacation synchronization is executed by plugin handlers when DA backend is active.\n";
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -eu
BASE_DIR="/usr/local/hitme_plugins/global-autoresponders"
if [ "${PURGE_GLOBAL_AUTORESPONDER_DATA:-0}" = "1" ]; then
rm -rf "$BASE_DIR"
echo "global-autoresponder data purged"
else
echo "global-autoresponder uninstalled; external data preserved at $BASE_DIR"
fi
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -eu
BASE_DIR="/usr/local/hitme_plugins/global-autoresponders"
mkdir -p "$BASE_DIR/config/users" "$BASE_DIR/data/users" "$BASE_DIR/state/replies" "$BASE_DIR/logs"
echo "global-autoresponder updated; external configuration preserved"
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
require_once PLUGIN_ROOT . '/exec/lib/Settings.php';
require_once PLUGIN_ROOT . '/exec/lib/DirectAdminUser.php';
test('settings validate defaults and external paths', function (): void {
$path = TEST_TMP . '/settings.conf';
test_write($path, implode("\n", [
'DEFAULT_PLUGIN_LANGUAGE=pl',
'DEFAULT_ENABLE=false',
'GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw',
'GLOBAL_AUTORESPONDER_SCHEDULE_MODE=exact_hour',
'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',
]) . "\n");
$settings = Settings::load($path);
assert_same('pl', $settings->defaultLanguage());
assert_false($settings->defaultEnable());
assert_same('Europe/Warsaw', $settings->timezone());
assert_same('exact_hour', $settings->scheduleMode());
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 reject invalid timezone and schedule mode', function (): void {
$path = TEST_TMP . '/bad-settings.conf';
test_write($path, "GLOBAL_AUTORESPONDER_TIMEZONE=Not/AZone\nGLOBAL_AUTORESPONDER_SCHEDULE_MODE=bad\n");
try {
Settings::load($path);
} catch (InvalidArgumentException $e) {
assert_contains('timezone', $e->getMessage());
return;
}
throw new RuntimeException('Expected invalid settings to fail');
});
test('directadmin user access follows DEFAULT_ENABLE and per-user overrides', function (): void {
$settingsPath = TEST_TMP . '/access.conf';
test_write($settingsPath, "DEFAULT_ENABLE=false\n");
$settings = Settings::load($settingsPath);
$user = new DirectAdminUser('marek', 'enhanced', 'en', TEST_TMP . '/config');
assert_false($user->hasPluginAccess($settings));
test_write(TEST_TMP . '/config/users/marek.conf', "enabled=true\n");
assert_true($user->hasPluginAccess($settings));
});
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
require_once PLUGIN_ROOT . '/exec/lib/CsrfGuard.php';
test('csrf token is bound to user session action and time', function (): void {
$guard = new CsrfGuard('secret', 'alice', 'session-1');
$issued = $guard->issue('create');
assert_true($guard->validate('create', (string)$issued['ts'], $issued['token'], 3600, 'session-1'));
assert_false((new CsrfGuard('secret', 'bob', 'session-1'))->validate('create', (string)$issued['ts'], $issued['token'], 3600, 'session-1'));
assert_false($guard->validate('delete', (string)$issued['ts'], $issued['token'], 3600, 'session-1'));
assert_false($guard->validate('create', (string)($issued['ts'] - 7200), $issued['token'], 3600, 'session-1'));
assert_false($guard->validate('create', 'bad', $issued['token'], 3600, 'session-1'));
});
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
require_once PLUGIN_ROOT . '/exec/lib/DirectAdminSyncRepository.php';
test('directadmin sync repository only targets plugin tracked entries', function (): void {
$repo = new DirectAdminSyncRepository(TEST_TMP . '/data');
$repo->replaceRuleEntries('alice', 'rule-1', [
['domain' => 'example.com', 'mailbox' => 'info', 'api_key' => 'info@example.com'],
['domain' => 'example.com', 'mailbox' => 'sales', 'api_key' => 'sales@example.com'],
]);
assert_same(2, count($repo->entriesForRule('alice', 'rule-1')));
assert_same([], $repo->entriesForRule('bob', 'rule-1'));
assert_same(['info@example.com', 'sales@example.com'], array_column($repo->deleteRuleEntries('alice', 'rule-1'), 'api_key'));
assert_same([], $repo->entriesForRule('alice', 'rule-1'));
});
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
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',
'body' => 'Body',
'start_value' => '2026-06-10|morning',
'end_value' => '2026-06-24|evening',
]);
assert_same('info', $payload['user']);
assert_same('example.com', $payload['domain']);
assert_same('Body', $payload['text']);
assert_same('morning', $payload['starttime']);
assert_same('evening', $payload['endtime']);
});
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
test('mockup and planned UI do not expose backend terms', function (): void {
foreach (glob(dirname(__DIR__, 2) . '/docs/mockups/*.html') ?: [] as $file) {
$html = file_get_contents($file) ?: '';
assert_false(stripos($html, 'Exim') !== false, basename($file) . ' exposes Exim');
assert_false(stripos($html, 'DirectAdmin API') !== false, basename($file) . ' exposes API');
assert_false(stripos($html, 'backend.sh') !== false, basename($file) . ' exposes backend script');
}
});
test('mockup forms include calendar classes and delete confirmation view', function (): void {
$create = file_get_contents(dirname(__DIR__, 2) . '/docs/mockups/02-dodaj.html') ?: '';
$delete = file_get_contents(dirname(__DIR__, 2) . '/docs/mockups/04-podglad-usun.html') ?: '';
assert_contains('br-restore-calendar', $create);
assert_contains('br-calendar-grid', $create);
assert_contains('Potwierdź usunięcie', $delete);
assert_false(stripos($delete, 'checkbox') !== false);
});
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
require_once PLUGIN_ROOT . '/exec/lib/Lang.php';
require_once PLUGIN_ROOT . '/exec/lib/Settings.php';
test('language falls back to default language then English', function (): void {
$settingsPath = TEST_TMP . '/lang-settings.conf';
test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\n");
$settings = Settings::load($settingsPath);
$lang = Lang::load('de', $settings);
assert_same('pl', $lang->code());
assert_same('Globalne autorespondery', $lang->t('Global autoresponders'));
test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=de\n");
$settings = Settings::load($settingsPath);
$lang = Lang::load('fr', $settings);
assert_same('en', $lang->code());
});
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
require_once PLUGIN_ROOT . '/exec/lib/MailSender.php';
test('mail sender rejects subject header injection and builds utf8 message', function (): void {
try {
MailSender::buildMessage('from@example.com', 'to@example.com', "Bad\nSubject", 'Body');
} catch (InvalidArgumentException $e) {
assert_contains('subject', strtolower($e->getMessage()));
}
$message = MailSender::buildMessage('from@example.com', 'to@example.com', 'Temat', "Treść\nDruga linia");
assert_contains('Auto-Submitted: auto-replied', $message);
assert_contains('Content-Type: text/plain; charset=UTF-8', $message);
assert_contains("Subject: =?UTF-8?B?", $message);
});
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
require_once PLUGIN_ROOT . '/exec/lib/MailboxDirectory.php';
test('mailbox directory returns only pop imap mailboxes owned by user', function (): void {
$root = TEST_TMP . '/etc_virtual';
test_write($root . '/domainowners', "example.com: alice\nother.com: bob\n");
test_write($root . '/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");
test_write($root . '/example.com/aliases', "alias: info\n");
test_write($root . '/other.com/passwd', "info:x:1001:12::/home/bob/imap/other.com/info:/bin/false\n");
$dir = new MailboxDirectory($root);
$mailboxes = $dir->mailboxesForUser('alice');
assert_same(['info@example.com', 'sales@example.com'], array_column($mailboxes, 'email'));
});
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
require_once PLUGIN_ROOT . '/exec/lib/MessageClassifier.php';
test('message classifier suppresses automated and list messages', function (): void {
assert_true(MessageClassifier::shouldSuppress(['auto-submitted' => 'auto-replied'], 'sender@example.com'));
assert_true(MessageClassifier::shouldSuppress(['precedence' => 'bulk'], 'sender@example.com'));
assert_true(MessageClassifier::shouldSuppress(['list-id' => '<list.example.com>'], 'sender@example.com'));
assert_true(MessageClassifier::shouldSuppress([], ''));
assert_true(MessageClassifier::shouldSuppress([], 'MAILER-DAEMON@example.com'));
assert_false(MessageClassifier::shouldSuppress(['auto-submitted' => 'no'], 'person@example.com'));
});
+9
View File
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
test('packing guidelines exclude docs and require manual icon', function (): void {
$packing = file_get_contents(dirname(__DIR__, 2) . '/PACKING.md') ?: '';
assert_contains('docs/dokumentacja.html', $packing);
assert_contains('must not contain `docs/`', $packing);
assert_contains('src/images/user_icon.svg', $packing);
});
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
require_once PLUGIN_ROOT . '/exec/lib/RepeatState.php';
test('repeat state allows first reply and blocks repeated reply until ttl', function (): void {
$state = new RepeatState(TEST_TMP . '/state', 1000);
assert_true($state->shouldSend('alice', 'rule', 'info@example.com', 'sender@example.com', 60));
$state->record('alice', 'rule', 'info@example.com', 'sender@example.com');
assert_false($state->shouldSend('alice', 'rule', 'info@example.com', 'sender@example.com', 60));
$later = new RepeatState(TEST_TMP . '/state', 5000);
assert_true($later->shouldSend('alice', 'rule', 'info@example.com', 'sender@example.com', 60));
});
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
require_once PLUGIN_ROOT . '/exec/lib/RuleRepository.php';
test('rule repository isolates users and stores rules atomically', function (): void {
$repo = new RuleRepository(TEST_TMP . '/data');
$rule = [
'subject' => 'Urlop',
'body' => 'Body',
'start_ts' => 1,
'end_ts' => 2,
'enabled' => true,
];
$created = $repo->create('alice', $rule);
assert_true(isset($created['id']));
assert_same(1, count($repo->all('alice')));
assert_same(0, count($repo->all('bob')));
try {
$repo->update('bob', $created['id'], ['subject' => 'Other']);
} catch (RuntimeException $e) {
assert_contains('not found', $e->getMessage());
return;
}
throw new RuntimeException('Expected cross-user update to fail');
});
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
require_once PLUGIN_ROOT . '/exec/lib/Settings.php';
require_once PLUGIN_ROOT . '/exec/lib/RuleValidator.php';
test('rule validator normalizes exact hour schedule', function (): void {
$settingsPath = TEST_TMP . '/validator.conf';
test_write($settingsPath, "GLOBAL_AUTORESPONDER_TIMEZONE=Europe/Warsaw\nGLOBAL_AUTORESPONDER_SCHEDULE_MODE=exact_hour\n");
$settings = Settings::load($settingsPath);
$rule = RuleValidator::validate([
'subject' => 'Urlop',
'body' => "Dzień dobry\r\nWracam jutro",
'start_value' => '2026-06-10T09:00',
'end_value' => '2026-06-24T17:00',
'enabled' => '1',
], $settings);
assert_same('Urlop', $rule['subject']);
assert_same("Dzień dobry\nWracam jutro", $rule['body']);
assert_same('exact_hour', $rule['schedule_mode']);
assert_true($rule['start_ts'] < $rule['end_ts']);
});
test('rule validator rejects header injection and reversed dates', function (): void {
$settings = Settings::load(TEST_TMP . '/missing.conf');
try {
RuleValidator::validate([
'subject' => "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());
return;
}
throw new RuntimeException('Expected validation failure');
});
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
$root = dirname(__DIR__);
define('PLUGIN_ROOT', $root);
define('TEST_TMP', sys_get_temp_dir() . '/global-autoresponder-tests-' . getmypid());
@mkdir(TEST_TMP, 0700, true);
$tests = [];
function test(string $name, callable $fn): void
{
global $tests;
$tests[] = [$name, $fn];
}
function assert_true(bool $condition, string $message = 'Expected true'): void
{
if (!$condition) {
throw new RuntimeException($message);
}
}
function assert_false(bool $condition, string $message = 'Expected false'): void
{
if ($condition) {
throw new RuntimeException($message);
}
}
function assert_same(mixed $expected, mixed $actual, string $message = ''): void
{
if ($expected !== $actual) {
throw new RuntimeException($message !== '' ? $message : 'Expected ' . var_export($expected, true) . ', got ' . var_export($actual, true));
}
}
function assert_contains(string $needle, string $haystack, string $message = ''): void
{
if (strpos($haystack, $needle) === false) {
throw new RuntimeException($message !== '' ? $message : 'Missing text: ' . $needle);
}
}
function test_write(string $path, string $content): void
{
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0700, true);
}
file_put_contents($path, $content);
}
function test_rm_rf(string $path): void
{
if (!is_dir($path) && !is_file($path)) {
return;
}
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
if (is_dir($path)) {
rmdir($path);
}
}
foreach (glob(__DIR__ . '/*_test.php') ?: [] as $file) {
require $file;
}
$failures = 0;
foreach ($tests as [$name, $fn]) {
try {
$fn();
echo "PASS {$name}\n";
} catch (Throwable $e) {
$failures++;
echo "FAIL {$name}: " . $e->getMessage() . "\n";
}
}
test_rm_rf(TEST_TMP);
if ($failures > 0) {
exit(1);
}
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/global-autoresponder/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', 'create');
require dirname(__DIR__) . '/exec/bootstrap.php';
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/global-autoresponder/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', 'delete');
require dirname(__DIR__) . '/exec/bootstrap.php';
+74
View File
@@ -0,0 +1,74 @@
:root {
--bg: #f5f8ff;
--panel: #fff;
--text: #1f2a37;
--muted: #5f6a7b;
--border: #d8dde6;
--amber: #b77200;
--amber-strong: #9a6200;
--danger: #d4491f;
--ok: #127b3b;
--ok-bg: #e8f7ec;
--shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
}
* { box-sizing: border-box; }
html { overflow-y: scroll; scrollbar-gutter: stable; }
body { margin: 0; font-family: "IBM Plex Sans", "Segoe UI", Arial, sans-serif; background: var(--bg); color: var(--text); font-size: 16px; }
.wrap { width: 100%; margin: 0 auto; padding: 16px 20px; }
.breadcrumbs { color: #7f8896; font-size: 13px; margin-bottom: 8px; }
.breadcrumbs span { margin: 0 6px; }
header { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px; margin-bottom: 10px; }
header h1 { margin: 0; font-size: 38px; font-weight: 600; letter-spacing: 0; }
header p { margin: 0; color: var(--amber-strong); font-size: 15px; }
.top-actions { display: flex; gap: 8px; justify-content: flex-end; margin-bottom: 12px; flex-wrap: wrap; }
.main-section { display: block; }
.panel { border: 1px solid var(--border); border-radius: 10px; padding: 14px; background: var(--panel); box-shadow: var(--shadow); margin-bottom: 14px; }
.panel h3 { margin: 0 0 10px; font-size: 22px; font-weight: 700; letter-spacing: 0; }
.panel-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 10px; }
.muted { color: var(--muted); }
.alert { padding: 12px 14px; border-radius: 10px; margin: 8px 0 12px; border: 1px solid; font-size: 15px; }
.alert-ok { background: var(--ok-bg); border-color: #a8ddb8; color: #12562b; }
.alert-err { background: #fff0ed; border-color: #f3c0b4; color: #8b2b18; }
.grid { display: grid; gap: 12px; }
.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; }
textarea { min-height: 170px; resize: vertical; line-height: 1.45; }
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); }
.btn.danger, button.danger { border-color: var(--danger); color: var(--danger); background: #fff; }
button.danger-fill { background: var(--danger); border-color: var(--danger); color: #fff; }
button.success { background: var(--ok); border-color: var(--ok); color: #fff; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; align-items: center; }
.table-actions { display: inline-flex; flex-wrap: wrap; gap: 6px; }
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 15px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: #fff; }
th, td { border-bottom: 1px solid var(--border); padding: 10px 12px; text-align: left; vertical-align: top; }
tbody tr:last-child td { border-bottom: 0; }
th { background: #f6f8fb; color: #4a5566; font-weight: 600; }
.badge { display: inline-block; background: #fce2b7; color: #7b4b00; border: 1px solid #efc57f; border-radius: 5px; padding: 2px 8px; font-size: 13px; white-space: nowrap; }
.badge.ok { background: #def5e5; color: #12562b; border-color: #a8ddb8; }
.badge.off { background: #eef1f5; color: #5f6a7b; border-color: #d7dde8; }
.br-restore-layout { display: grid; grid-template-columns: 280px 1fr; gap: 16px; align-items: start; }
.br-restore-calendar { border: 1px solid var(--border); border-radius: 12px; padding: 12px; background: #f8fafc; }
.br-restore-calendar h4 { margin: 0 0 10px; font-size: 16px; font-weight: 700; }
.br-month-nav { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
.br-month-btn { width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border); background: #fff; color: #3a4655; cursor: pointer; }
.br-month-label { font-weight: 600; color: #1f2a37; }
.br-calendar-grid { width: 100%; border-collapse: collapse; font-size: 13px; border: 0; background: transparent; border-radius: 0; overflow: visible; }
.br-calendar-grid th { padding: 4px; text-align: center; border-bottom: 1px solid var(--border); background: transparent; color: var(--muted); }
.br-calendar-grid td { padding: 4px; text-align: center; border-bottom: 0; }
.br-cal-day, .br-cal-day-disabled { width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--border); background: #fff; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
.br-cal-day.is-selected { background: var(--amber); color: #fff; border-color: var(--amber); }
.br-cal-day-disabled { color: #a0a7b4; border-color: transparent; background: transparent; }
.br-date-label { font-weight: 600; color: #1f2a37; margin: 6px 0 10px; }
.br-time-picker { margin: 8px 0 14px; }
.br-time-title { font-weight: 600; margin-bottom: 6px; color: var(--muted); }
.br-time-list { display: flex; flex-wrap: wrap; gap: 8px; }
.br-time-item { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 999px; background: #fff; cursor: pointer; font-size: 13px; }
.br-time-item input { margin: 0; accent-color: var(--amber); }
.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; } }
+1
View File
@@ -0,0 +1 @@
@import url("enhanced.css");
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/global-autoresponder/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', basename(__FILE__, '.html'));
require dirname(__DIR__) . '/exec/bootstrap.php';
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/global-autoresponder/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', 'preview');
require dirname(__DIR__) . '/exec/bootstrap.php';
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/global-autoresponder/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', 'toggle');
require dirname(__DIR__) . '/exec/bootstrap.php';
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/global-autoresponder/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', 'update');
require dirname(__DIR__) . '/exec/bootstrap.php';