Upload via GUI (346 Dateien)

This commit is contained in:
2026-10-28 07:54:38 +00:00
parent 4045ab2b95
commit 38646692bf
9 changed files with 247 additions and 16 deletions
+1
View File
@@ -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';
@@ -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 = '<p>Dies ist eine Test-Mail von DeskHub.</p><p>Absender: ' . e($addr['email']) . '<br>Versand über: ' . e($via) . '</p>';
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}).";
+6
View File
@@ -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;
}
+76 -6
View File
@@ -95,15 +95,80 @@ function i18n_current(): string
* Wörterbuch einer Sprache: exakte Texte und Muster mit "{}".
* @return array{exact:array<string,string>,patterns:list<array{0:string,1:string}>,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/<code>.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: <Plugin>/lang/<code>.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/<code>.json im Plugin.
* @return array<string,string> 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<string,array<string,int>> 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. */
+120
View File
@@ -0,0 +1,120 @@
<?php
/**
* includes/mail_queue.php
*
* Warteschlange für ausgehende Mails. Scheitert der Versand (SMTP-Server nicht erreichbar, falsche Zugangsdaten, Port gesperrt …),
* wird die Mail hier gespeichert und vom Cron-Job "process_mail_queue" mit wachsenden Abständen erneut versucht. So gehen
* Antworten, Eingangsbestätigungen und Passwort-Mails bei einer Störung nicht verloren.
*
* Tabelle mail_queue (Migration php_0064): status = pending (wartet), sent (nachträglich gesendet), failed (aufgegeben).
*/
const MAIL_QUEUE_MAX_ATTEMPTS = 10;
const MAIL_QUEUE_MAX_AGE_HOURS = 48;
const MAIL_QUEUE_MAX_PENDING = 2000;
/** Minuten bis zum nächsten Versuch nach dem 1., 2., 3. … Fehlversuch (der letzte Wert gilt für alle weiteren). */
const MAIL_QUEUE_BACKOFF_MINUTES = [5, 10, 20, 40, 60, 120, 240, 360];
/** Ist die Warteschlange eingeschaltet? (Standard: ja; Einstellung mail_queue_enabled = 0 schaltet sie ab) */
function mail_queue_enabled(): bool
{
return get_setting('mail_queue_enabled', '1') === '1';
}
/** Merkt sich pro Anfrage, ob der letzte send_mail()-Aufruf wirklich versucht hat zu senden (nicht nur "Mailversand aus"). */
function mail_attempted(?bool $set = null): bool
{
static $v = false;
if ($set !== null) $v = $set;
return $v;
}
/** Wurde die zuletzt fehlgeschlagene Mail in die Warteschlange gelegt? */
function mail_last_queued(?bool $set = null): bool
{
static $v = false;
if ($set !== null) $v = $set;
return $v;
}
/** Legt eine Mail in die Warteschlange. @return int|null ID, null wenn nicht möglich (Tabelle fehlt, Warteschlange voll) */
function mail_queue_add(PDO $pdo, string $to, string $subject, string $htmlBody, ?string $fromHeader, string $error): ?int
{
try {
$pending = (int) $pdo->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;
}
+24 -1
View File
@@ -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);
}
@@ -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'],
+13 -1
View File
@@ -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]);
+5 -7
View File
@@ -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'] ?? []);