From 38646692bf7b7318a07d1953fcf5c8bd97d6e444 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Wed, 28 Oct 2026 07:54:38 +0000 Subject: [PATCH] Upload via GUI (346 Dateien) --- DeskHub-Webseite/includes/bootstrap.php | 1 + DeskHub-Webseite/includes/email_addresses.php | 2 +- DeskHub-Webseite/includes/health.php | 6 + DeskHub-Webseite/includes/i18n.php | 82 +++++++++++- DeskHub-Webseite/includes/mail_queue.php | 120 ++++++++++++++++++ DeskHub-Webseite/includes/mailer.php | 25 +++- DeskHub-Webseite/includes/pseudo_cron.php | 1 + DeskHub-Webseite/includes/smtp_mailer.php | 14 +- DeskHub-Webseite/includes/status_page.php | 12 +- 9 files changed, 247 insertions(+), 16 deletions(-) create mode 100644 DeskHub-Webseite/includes/mail_queue.php diff --git a/DeskHub-Webseite/includes/bootstrap.php b/DeskHub-Webseite/includes/bootstrap.php index fc1eb26d..abe3a1ab 100644 --- a/DeskHub-Webseite/includes/bootstrap.php +++ b/DeskHub-Webseite/includes/bootstrap.php @@ -12,6 +12,7 @@ require_once __DIR__ . '/time_tracking.php'; require_once __DIR__ . '/pseudo_cron.php'; require_once __DIR__ . '/auth.php'; require_once __DIR__ . '/smtp_mailer.php'; +require_once __DIR__ . '/mail_queue.php'; require_once __DIR__ . '/mailer.php'; require_once __DIR__ . '/email_addresses.php'; require_once __DIR__ . '/health.php'; diff --git a/DeskHub-Webseite/includes/email_addresses.php b/DeskHub-Webseite/includes/email_addresses.php index 37799d3b..266bef63 100644 --- a/DeskHub-Webseite/includes/email_addresses.php +++ b/DeskHub-Webseite/includes/email_addresses.php @@ -188,7 +188,7 @@ function email_address_send_test(PDO $pdo, int $id, string $recipient): string $from = $addr['label'] ? "{$addr['label']} <{$addr['email']}>" : $addr['email']; $via = $addr['smtp_enabled'] ? 'eigenen SMTP-Server' : 'globalen SMTP-Server'; $html = '

Dies ist eine Test-Mail von DeskHub.

Absender: ' . e($addr['email']) . '
Versand über: ' . e($via) . '

'; - if (!send_mail($recipient, 'DeskHub – Test-Mail von ' . $addr['email'], $html, $from)) { + if (!send_mail($recipient, 'DeskHub – Test-Mail von ' . $addr['email'], $html, $from, false)) { throw new EmailAddressException('Versand fehlgeschlagen. Bitte SMTP-Daten prüfen. ' . smtp_last_error(), 'MAIL_FAILED', 502); } return "Test-Mail von {$addr['email']} an {$recipient} gesendet (über {$via})."; diff --git a/DeskHub-Webseite/includes/health.php b/DeskHub-Webseite/includes/health.php index 41ee42eb..96a43ac3 100644 --- a/DeskHub-Webseite/includes/health.php +++ b/DeskHub-Webseite/includes/health.php @@ -109,6 +109,12 @@ function system_health(PDO $pdo): array $issues[] = ['level' => 'warning', 'code' => 'send_failed', 'message' => 'Der letzte E-Mail-Versand ist fehlgeschlagen (' . date('d.m.Y H:i', strtotime($at)) . '): ' . $msg]; } } + // Mails, die nicht rausgingen: warten in der Warteschlange oder wurden aufgegeben + if (function_exists('mail_queue_counts')) { + $q = mail_queue_counts($pdo); + if ($q['failed'] > 0) $issues[] = ['level' => 'error', 'code' => 'mail_queue_failed', 'message' => $q['failed'] . ' Mail(s) konnten auch nach mehreren Versuchen nicht gesendet werden. Unter Verwalten → Mail-Warteschlange nachsenden oder löschen.']; + elseif ($q['pending'] > 0) $issues[] = ['level' => 'warning', 'code' => 'mail_queue_pending', 'message' => $q['pending'] . ' Mail(s) warten in der Warteschlange und werden erneut versucht (Verwalten → Mail-Warteschlange).']; + } return $issues; } diff --git a/DeskHub-Webseite/includes/i18n.php b/DeskHub-Webseite/includes/i18n.php index e7875593..db749da4 100644 --- a/DeskHub-Webseite/includes/i18n.php +++ b/DeskHub-Webseite/includes/i18n.php @@ -95,15 +95,80 @@ function i18n_current(): string * Wörterbuch einer Sprache: exakte Texte und Muster mit "{}". * @return array{exact:array,patterns:list,count:int} */ -function i18n_dictionary(string $code): array +function i18n_dictionary(string $code, bool $reload = false): array { static $cache = []; + if ($reload) unset($cache[$code]); if (isset($cache[$code])) return $cache[$code]; $dict = ['exact' => [], 'patterns' => [], 'count' => 0]; - $file = i18n_languages()[$code]['strings'] ?? ''; - if ($file === '' || !is_file($file) || filesize($file) > I18N_MAX_STRINGS_BYTES) return $cache[$code] = $dict; + $files = []; + // Zuerst die eigenen Übersetzungen der aktiven Plugins (lang/.json), danach das Sprachpaket: Es gewinnt bei gleichem Text, + // damit sich ein Plugin-Text im Sprachpaket verbessern lässt. + foreach (i18n_plugin_string_files($code) as $f) $files[] = $f; + $packFile = i18n_languages()[$code]['strings'] ?? ''; + if ($packFile !== '') $files[] = $packFile; + foreach ($files as $file) i18n_load_strings_file($file, $dict); + return $cache[$code] = $dict; +} + +/** + * Übersetzungsdateien der aktiven Plugins für eine Sprache: /lang/.json (gleiches Format wie strings.json eines Sprachpakets). + * So bringt jedes Plugin seine Texte selbst mit; eine neue Sprache braucht dann nur die Datei lang/.json im Plugin. + * @return array Plugin-Kennung => Dateipfad + */ +function i18n_plugin_string_files(string $code): array +{ + if (!preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', $code) || $code === 'de') return []; + $slugs = []; + try { + if (function_exists('plugins_globally_disabled') && plugins_globally_disabled()) return []; + foreach (get_db()->query('SELECT slug FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll(PDO::FETCH_COLUMN) as $slug) { + if (plugin_valid_slug((string) $slug)) $slugs[] = (string) $slug; + } + } catch (Throwable $e) { + return []; + } + $out = []; + foreach ($slugs as $slug) { + $file = plugins_dir() . '/' . $slug . '/lang/' . $code . '.json'; + if (is_file($file)) $out[$slug] = $file; + } + return $out; +} + +/** + * Welche Plugins bringen für welche Sprachen eigene Übersetzungen mit? (Anzeige unter Sprachen) + * @return array> Plugin-Kennung => [Sprachcode => Anzahl Texte] + */ +function i18n_plugin_language_overview(): array +{ + $out = []; + try { + $slugs = get_db()->query('SELECT slug FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll(PDO::FETCH_COLUMN); + } catch (Throwable $e) { + return []; + } + foreach ($slugs as $slug) { + $slug = (string) $slug; + if (!plugin_valid_slug($slug)) continue; + foreach (glob(plugins_dir() . '/' . $slug . '/lang/*.json') ?: [] as $f) { + $code = strtolower(basename($f, '.json')); + if (!preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', $code) || filesize($f) > I18N_MAX_STRINGS_BYTES) continue; + $data = json_decode((string) file_get_contents($f), true); + $n = 0; + if (is_array($data)) foreach ($data as $k => $v) if (is_string($v) && $v !== '' && (string) $k !== '' && ((string) $k)[0] !== '_') $n++; + $out[$slug][$code] = $n; + } + } + return $out; +} + +/** Liest eine strings.json (Schlüssel = deutscher Text) in ein Wörterbuch ein; spätere Dateien überschreiben frühere. */ +function i18n_load_strings_file(string $file, array &$dict): void +{ + if ($file === '' || !is_file($file) || filesize($file) > I18N_MAX_STRINGS_BYTES) return; $data = json_decode((string) file_get_contents($file), true); - if (!is_array($data)) return $cache[$code] = $dict; + if (!is_array($data)) return; foreach ($data as $key => $value) { $key = (string) $key; if (!is_string($value) || $value === '' || $key === '' || $key[0] === '_') continue; @@ -114,13 +179,18 @@ function i18n_dictionary(string $code): array $regex = '/^'; foreach ($pieces as $k => $piece) $regex .= $k % 2 === 0 ? preg_quote($piece, '/') : ($piece === '{#}' ? '(\d+)' : '(.+?)'); $regex .= '$/su'; + // gleicher Schlüssel in einer späteren Datei ersetzt das frühere Muster + foreach ($dict['patterns'] as $i => $p) if (($dict['pkeys'][$i] ?? null) === $norm) { unset($dict['patterns'][$i], $dict['pkeys'][$i]); $dict['count']--; } + $dict['patterns'] = array_values($dict['patterns']); + $dict['pkeys'] = array_values($dict['pkeys'] ?? []); $dict['patterns'][] = [$regex, $value]; + $dict['pkeys'][] = $norm; + $dict['count']++; } else { + if (!isset($dict['exact'][$norm])) $dict['count']++; $dict['exact'][$norm] = $value; } - $dict['count']++; } - return $cache[$code] = $dict; } /** Entities auflösen, Leerraum zusammenfassen: so werden Schlüssel und Seitentexte verglichen. */ diff --git a/DeskHub-Webseite/includes/mail_queue.php b/DeskHub-Webseite/includes/mail_queue.php new file mode 100644 index 00000000..1d0942d4 --- /dev/null +++ b/DeskHub-Webseite/includes/mail_queue.php @@ -0,0 +1,120 @@ +query("SELECT COUNT(*) FROM mail_queue WHERE status = 'pending'")->fetchColumn(); + if ($pending >= MAIL_QUEUE_MAX_PENDING) return null; + $now = db_now(); + $pdo->prepare("INSERT INTO mail_queue (to_email, from_header, subject, body, status, attempts, next_attempt_at, last_error, created_at) VALUES (?,?,?,?, 'pending', 1, ?, ?, ?)") + ->execute([mb_substr($to, 0, 255), $fromHeader !== null && $fromHeader !== '' ? mb_substr($fromHeader, 0, 255) : null, mb_substr($subject, 0, 500), $htmlBody, + date('Y-m-d H:i:s', time() + MAIL_QUEUE_BACKOFF_MINUTES[0] * 60), mb_substr($error, 0, 400), $now]); + return (int) $pdo->lastInsertId(); + } catch (Throwable $e) { + error_log('mail_queue_add: ' . $e->getMessage()); + return null; + } +} + +/** + * Versucht fällige Mails erneut zu senden (höchstens $limit pro Lauf). Jede Mail wird vorher atomar "reserviert", damit zwei + * gleichzeitige Läufe sie nicht doppelt senden. + * @return array{sent:int, failed:int, retry:int, gave_up:int} + */ +function mail_queue_run(PDO $pdo, int $limit = 20): array +{ + $res = ['sent' => 0, 'failed' => 0, 'retry' => 0, 'gave_up' => 0]; + try { + $now = db_now(); + $due = $pdo->prepare("SELECT id FROM mail_queue WHERE status = 'pending' AND next_attempt_at <= ? ORDER BY id LIMIT " . max(1, $limit)); + $due->execute([$now]); + $ids = $due->fetchAll(PDO::FETCH_COLUMN); + } catch (Throwable $e) { + return $res; + } + foreach ($ids as $id) { + // Reservieren: nur wer die Zeile verschiebt, darf senden + $claim = $pdo->prepare("UPDATE mail_queue SET next_attempt_at = ? WHERE id = ? AND status = 'pending' AND next_attempt_at <= ?"); + $claim->execute([date('Y-m-d H:i:s', time() + 600), $id, db_now()]); + if ($claim->rowCount() !== 1) continue; + $s = $pdo->prepare('SELECT * FROM mail_queue WHERE id = ?'); + $s->execute([$id]); + $m = $s->fetch(); + if (!$m) continue; + $ok = send_mail($m['to_email'], $m['subject'], $m['body'], $m['from_header'] ?: null, false); + $attempts = (int) $m['attempts'] + 1; + if ($ok) { + $pdo->prepare("UPDATE mail_queue SET status = 'sent', attempts = ?, sent_at = ?, last_error = NULL WHERE id = ?")->execute([$attempts, db_now(), $id]); + $res['sent']++; + continue; + } + $err = mb_substr(smtp_last_error() !== '' ? smtp_last_error() : 'Versand fehlgeschlagen', 0, 400); + $tooOld = time() - (int) strtotime((string) $m['created_at']) > MAIL_QUEUE_MAX_AGE_HOURS * 3600; + if ($attempts >= MAIL_QUEUE_MAX_ATTEMPTS || $tooOld) { + $pdo->prepare("UPDATE mail_queue SET status = 'failed', attempts = ?, last_error = ? WHERE id = ?")->execute([$attempts, $err, $id]); + $res['gave_up']++; + } else { + $wait = MAIL_QUEUE_BACKOFF_MINUTES[min($attempts - 1, count(MAIL_QUEUE_BACKOFF_MINUTES) - 1)]; + $pdo->prepare("UPDATE mail_queue SET attempts = ?, last_error = ?, next_attempt_at = ? WHERE id = ?")->execute([$attempts, $err, date('Y-m-d H:i:s', time() + $wait * 60), $id]); + $res['retry']++; + } + $res['failed']++; + } + // Aufräumen: Erledigtes und Aufgegebenes nach 30 Tagen löschen + try { + $pdo->prepare("DELETE FROM mail_queue WHERE status IN ('sent','failed') AND created_at < ?")->execute([date('Y-m-d H:i:s', time() - 30 * 86400)]); + } catch (Throwable $e) { + } + return $res; +} + +/** @return array{pending:int, failed:int, sent:int} Anzahl nach Status (für Anzeige und Gesundheitsprüfung) */ +function mail_queue_counts(PDO $pdo): array +{ + $out = ['pending' => 0, 'failed' => 0, 'sent' => 0]; + try { + foreach ($pdo->query('SELECT status, COUNT(*) AS n FROM mail_queue GROUP BY status')->fetchAll() as $r) { + if (isset($out[$r['status']])) $out[$r['status']] = (int) $r['n']; + } + } catch (Throwable $e) { + } + return $out; +} diff --git a/DeskHub-Webseite/includes/mailer.php b/DeskHub-Webseite/includes/mailer.php index 68150d5e..2c914e2b 100644 --- a/DeskHub-Webseite/includes/mailer.php +++ b/DeskHub-Webseite/includes/mailer.php @@ -15,7 +15,27 @@ function smtp_last_error(): string return func_num_args() > 0 ? ($lastError = func_get_arg(0)) : $lastError; } -function send_mail(string $to, string $subject, string $htmlBody, ?string $fromOverride = null): bool +/** + * Sendet eine Mail. Scheitert der Versand, obwohl ein Versandweg eingerichtet ist, kommt die Mail in die Warteschlange + * (includes/mail_queue.php) und wird später erneut versucht; der Rückgabewert bleibt dann false, mail_last_queued() ist true. + * $queue = false für Test-Mails und für die Wiederholungsläufe der Warteschlange selbst. + */ +function send_mail(string $to, string $subject, string $htmlBody, ?string $fromOverride = null, bool $queue = true): bool +{ + mail_attempted(false); + mail_last_queued(false); + $ok = send_mail_now($to, $subject, $htmlBody, $fromOverride); + if (!$ok && $queue && mail_attempted() && mail_queue_enabled()) { + try { + if (mail_queue_add(get_db(), $to, $subject, $htmlBody, $fromOverride, smtp_last_error()) !== null) mail_last_queued(true); + } catch (Throwable $e) { + error_log('send_mail queue: ' . $e->getMessage()); + } + } + return $ok; +} + +function send_mail_now(string $to, string $subject, string $htmlBody, ?string $fromOverride = null): bool { $cfg = [ 'enabled' => get_setting('mail_enabled', config()['mail']['enabled'] ? '1' : '0') === '1', @@ -45,6 +65,7 @@ function send_mail(string $to, string $subject, string $htmlBody, ?string $fromO (string) $own['smtp_username'], $own['smtp_password_enc'] ? decrypt_secret($own['smtp_password_enc']) : '', ); + mail_attempted(true); $ok = $mailer->send($to, $fromHeader, $subject, $htmlBody); if (!$ok) { smtp_last_error($mailer->getLastError()); @@ -62,6 +83,7 @@ function send_mail(string $to, string $subject, string $htmlBody, ?string $fromO get_setting('smtp_username', ''), decrypt_secret(get_setting('smtp_password', '')), ); + mail_attempted(true); $ok = $mailer->send($to, $fromHeader, $subject, $htmlBody); if (!$ok) { smtp_last_error($mailer->getLastError()); @@ -75,6 +97,7 @@ function send_mail(string $to, string $subject, string $htmlBody, ?string $fromO $headers .= "Content-type: text/html; charset=UTF-8\r\n"; $headers .= "From: {$fromHeader}\r\n"; + mail_attempted(true); return @mail($to, $subject, $htmlBody, $headers); } diff --git a/DeskHub-Webseite/includes/pseudo_cron.php b/DeskHub-Webseite/includes/pseudo_cron.php index 210d6de8..3c69bf81 100644 --- a/DeskHub-Webseite/includes/pseudo_cron.php +++ b/DeskHub-Webseite/includes/pseudo_cron.php @@ -39,6 +39,7 @@ function pseudo_cron_job_definitions(): array 'anonymize_data' => ['setting' => 'cron_anonymize_data_last_run', 'interval' => 86400, 'file' => 'anonymize_data.php', 'fn' => 'run_anonymization'], 'auto_close_tickets'=> ['setting' => 'cron_auto_close_tickets_last_run','interval' => 86400, 'file' => 'auto_close_tickets.php','fn' => 'run_auto_close_tickets'], 'process_snoozes' => ['setting' => 'cron_process_snoozes_last_run', 'interval' => 120, 'file' => 'process_snoozes.php', 'fn' => 'run_process_snoozes'], + 'process_mail_queue' => ['setting' => 'cron_process_mail_queue_last_run', 'interval' => 120, 'file' => 'process_mail_queue.php', 'fn' => 'run_process_mail_queue'], 'remind_customers' => ['setting' => 'cron_remind_customers_last_run', 'interval' => 3600, 'file' => 'remind_customers.php', 'fn' => 'run_remind_customers'], 'send_csat' => ['setting' => 'cron_send_csat_last_run', 'interval' => 300, 'file' => 'send_csat.php', 'fn' => 'run_send_csat'], 'status_checks' => ['setting' => 'cron_status_checks_last_run', 'interval' => 60, 'file' => 'status_checks.php', 'fn' => 'run_status_checks'], diff --git a/DeskHub-Webseite/includes/smtp_mailer.php b/DeskHub-Webseite/includes/smtp_mailer.php index 93cb0ec0..5934cdc0 100644 --- a/DeskHub-Webseite/includes/smtp_mailer.php +++ b/DeskHub-Webseite/includes/smtp_mailer.php @@ -86,7 +86,19 @@ class SmtpMailer STREAM_CLIENT_CONNECT, stream_context_create(['ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]) ); if (!$this->socket) { - throw new \RuntimeException("Verbindung zu {$this->host}:{$this->port} fehlgeschlagen: {$errstr} ({$errno})"); + // Bei SSL-Fehlern bleibt $errstr oft leer; der eigentliche Grund steht dann in der letzten PHP-Warnung + $reason = trim((string) $errstr); + if ($reason === '') { + $last = (string) (error_get_last()['message'] ?? ''); + $reason = trim(preg_replace('/^stream_socket_client\(\):\s*/', '', $last)); + } + if ($reason === '') $reason = $this->encryption === 'ssl' + ? 'keine Antwort. Prüfen, ob Port und Verschlüsselung zusammenpassen (SSL = 465, STARTTLS = 587) und ob der Server ausgehend verbinden darf' + : 'keine Antwort (Zeitüberschreitung oder Sperre durch eine Firewall)'; + if ($this->encryption === 'ssl' && $this->port === 587) $reason .= ' – Port 587 ist für STARTTLS gedacht, bei „SSL/TLS“ gehört Port 465 dazu'; + elseif ($this->encryption === 'tls' && $this->port === 465) $reason .= ' – Port 465 ist für SSL/TLS gedacht, bei „STARTTLS“ gehört Port 587 dazu'; + elseif (stripos($reason, 'certificate') !== false || stripos($reason, 'verify') !== false) $reason .= ' – möglicherweise fehlt auf diesem Server die Liste vertrauenswürdiger Zertifizierungsstellen (CA-Bundle: curl.cainfo/openssl.cafile in der PHP-Konfiguration)'; + throw new \RuntimeException("Verbindung zu {$this->host}:{$this->port} fehlgeschlagen: {$reason}" . ($errno ? " ({$errno})" : '')); } stream_set_timeout($this->socket, $this->timeout); $this->readResponse([220]); diff --git a/DeskHub-Webseite/includes/status_page.php b/DeskHub-Webseite/includes/status_page.php index a02104a3..9fa1d508 100644 --- a/DeskHub-Webseite/includes/status_page.php +++ b/DeskHub-Webseite/includes/status_page.php @@ -87,11 +87,9 @@ function status_execute_component_check(PDO $pdo, array $check): array try { $pdo->query('SELECT 1'); $ok = true; } catch (Throwable $e) { $error = 'Datenbankabfrage fehlgeschlagen'; } } elseif (in_array($check['type'], ['android_app', 'windows_app'], true)) { $platform = $check['type'] === 'windows_app' ? 'windows' : 'android'; - $connection = status_app_connection($pdo, $platform); - $ok = $connection['online']; - if (!$ok) $error = $connection['last_seen'] === null - ? 'Noch keine Verbindung von ' . ($platform === 'windows' ? 'Windows' : 'Android') . ' gemeldet' - : 'Keine aktuelle Verbindung von ' . ($platform === 'windows' ? 'Windows' : 'Android'); + // Erreichbar heißt: Der Server nimmt Anfragen der App an (Datenbank und Schnittstelle laufen). Ob gerade ein PC oder Handy + // eingeschaltet ist, sagt darüber nichts: Ohne laufendes Programm ist der Dienst trotzdem erreichbar. + try { $pdo->query('SELECT 1'); $ok = true; } catch (Throwable $e) { $error = 'Schnittstelle für die App nicht erreichbar (Datenbankabfrage fehlgeschlagen)'; } } elseif ($check['type'] === 'mail') { $endpoints = status_configured_mail_endpoints($pdo); $ok = count($endpoints) > 0; @@ -334,9 +332,9 @@ function status_component_detected_targets(PDO $pdo, array $check): array $platform = $check['type'] === 'windows_app' ? 'windows' : 'android'; $label = $platform === 'windows' ? 'Windows' : 'Android'; $connection = status_app_connection($pdo, $platform); - if ($connection['last_seen'] === null) return ['Noch keine Verbindung von ' . $label . ' gemeldet']; + if ($connection['last_seen'] === null) return ['Server erreichbar · noch kein Gerät mit ' . $label . ' angemeldet gewesen']; $when = format_dt($connection['last_seen']); - return [$connection['online'] ? 'Verbunden · letzter Kontakt ' . $when : 'Letzter Kontakt ' . $when . ' · älter als ' . (int) $connection['timeout_minutes'] . ' Minuten']; + return ['Server erreichbar · ' . ($connection['online'] ? 'Gerät gerade verbunden, letzter Kontakt ' . $when : 'kein Gerät gerade aktiv, letzter Kontakt ' . $when)]; } if ($check['type'] === 'database') { $db = (array) (config()['db'] ?? []);