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) . '
.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'] ?? []);