From 27b3e4dbafaabe898ec8410140f058d9a297e473 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Wed, 4 Nov 2026 07:55:19 +0000 Subject: [PATCH] Upload via GUI (396 Dateien) --- DeskHub-Webseite/includes/bootstrap.php | 8 + DeskHub-Webseite/includes/db.php | 4 + DeskHub-Webseite/includes/fcm.php | 6 +- DeskHub-Webseite/includes/functions.php | 97 ++++++- DeskHub-Webseite/includes/gdpr.php | 169 +++++++++++ DeskHub-Webseite/includes/i18n.php | 7 + DeskHub-Webseite/includes/integrations.php | 61 ++++ .../includes/mail_fetch_manual.php | 82 ++++++ DeskHub-Webseite/includes/mail_reload.php | 126 ++++++++ DeskHub-Webseite/includes/mail_text.php | 271 ++++++++++++++++++ DeskHub-Webseite/includes/mail_threading.php | 104 +++++++ DeskHub-Webseite/includes/mailer.php | 4 +- DeskHub-Webseite/includes/my_week.php | 168 +++++++++++ DeskHub-Webseite/includes/pseudo_cron.php | 2 + .../includes/report_schedules.php | 218 ++++++++++++++ .../includes/scheduled_replies.php | 135 +++++++++ DeskHub-Webseite/includes/smtp_mailer.php | 4 + DeskHub-Webseite/includes/status_page.php | 49 ++++ .../includes/ticket_attention.php | 53 ++++ 19 files changed, 1559 insertions(+), 9 deletions(-) create mode 100644 DeskHub-Webseite/includes/gdpr.php create mode 100644 DeskHub-Webseite/includes/mail_fetch_manual.php create mode 100644 DeskHub-Webseite/includes/mail_reload.php create mode 100644 DeskHub-Webseite/includes/mail_threading.php create mode 100644 DeskHub-Webseite/includes/my_week.php create mode 100644 DeskHub-Webseite/includes/report_schedules.php create mode 100644 DeskHub-Webseite/includes/scheduled_replies.php create mode 100644 DeskHub-Webseite/includes/ticket_attention.php diff --git a/DeskHub-Webseite/includes/bootstrap.php b/DeskHub-Webseite/includes/bootstrap.php index c00690ae..59c4f085 100644 --- a/DeskHub-Webseite/includes/bootstrap.php +++ b/DeskHub-Webseite/includes/bootstrap.php @@ -16,6 +16,7 @@ require_once __DIR__ . '/mail_queue.php'; require_once __DIR__ . '/login_guard.php'; require_once __DIR__ . '/vacation.php'; require_once __DIR__ . '/mailer.php'; +require_once __DIR__ . '/mail_threading.php'; require_once __DIR__ . '/email_addresses.php'; require_once __DIR__ . '/health.php'; require_once __DIR__ . '/admin_models.php'; @@ -31,6 +32,13 @@ require_once __DIR__ . '/csat.php'; require_once __DIR__ . '/status_page.php'; require_once __DIR__ . '/ticket_templates.php'; require_once __DIR__ . '/problems.php'; +require_once __DIR__ . '/ticket_links.php'; +require_once __DIR__ . '/ticket_attention.php'; +require_once __DIR__ . '/wait_reminders.php'; +require_once __DIR__ . '/my_week.php'; +require_once __DIR__ . '/scheduled_replies.php'; +require_once __DIR__ . '/report_schedules.php'; +require_once __DIR__ . '/gdpr.php'; require_once __DIR__ . '/plugins.php'; require_once __DIR__ . '/app_downloads.php'; require_once __DIR__ . '/theme_catalog.php'; diff --git a/DeskHub-Webseite/includes/db.php b/DeskHub-Webseite/includes/db.php index 8bc45e62..909cbe19 100644 --- a/DeskHub-Webseite/includes/db.php +++ b/DeskHub-Webseite/includes/db.php @@ -15,6 +15,10 @@ function config(): array if (getenv('DESKHUB_TESTING') === '1' && ($extra = json_decode((string) getenv('DESKHUB_CONFIG_JSON'), true)) && is_array($extra)) { $config = array_replace_recursive($config, $extra); } + // Nur für Tests: Datenbankangaben (zum Beispiel eine MySQL-Testdatenbank) überschreiben alles andere, auch wenn der Test selbst DESKHUB_CONFIG_JSON setzt + if (getenv('DESKHUB_TESTING') === '1' && ($testDbJson = json_decode((string) getenv('DESKHUB_TEST_DB_JSON'), true)) && is_array($testDbJson)) { + $config['db'] = array_replace($config['db'] ?? [], $testDbJson); + } // Nur für die Testläufe (tests/run_all.php setzt DESKHUB_TESTS_FORCE_SQLITE): nie die in config/config.php eingetragene Datenbank // (zum Beispiel die Produktiv-MySQL) benutzen, sondern SQLite. In Test-Webservern nur mit einer vom Test gesetzten Datenbankdatei. if (getenv('DESKHUB_TESTS_FORCE_SQLITE') === '1') { diff --git a/DeskHub-Webseite/includes/fcm.php b/DeskHub-Webseite/includes/fcm.php index 755bb583..d6723a5e 100644 --- a/DeskHub-Webseite/includes/fcm.php +++ b/DeskHub-Webseite/includes/fcm.php @@ -374,7 +374,7 @@ function fcm_recipients_for_department(PDO $pdo, ?int $deptId): array /** Text der Benachrichtigung: standardmäßig nur mit Ticketnummer (privat), mit "Details" auch Betreff und Kunde. */ function fcm_message(string $type, array $ticket): array { - $titles = ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Antwort', 'ticket_assigned' => 'Ticket zugewiesen', 'chat_new' => 'Neuer Live-Chat', 'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Erwähnung', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket']; + $titles = ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Antwort', 'ticket_assigned' => 'Ticket zugewiesen', 'chat_new' => 'Neuer Live-Chat', 'ticket_sla' => 'SLA überschritten', 'ticket_wait' => 'Ticket wartet auf Antwort', 'ticket_mention' => 'Erwähnung', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket']; $title = $titles[$type] ?? 'DeskHub'; $number = (string) ($ticket['ticket_number'] ?? ''); if (get_setting('fcm_show_details', '0') === '1') { @@ -434,7 +434,7 @@ function fcm_notify_assignment(array $ticket, int $staffId, ?int $actorId = null function fcm_event_types(): array { return ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Kundenantwort', 'ticket_assigned' => 'Ticket mir zugewiesen', 'chat_new' => 'Neuer Live-Chat', - 'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Ich wurde erwähnt', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket aktualisiert']; + 'ticket_sla' => 'SLA überschritten', 'ticket_wait' => 'Ticket wartet auf meine Antwort', 'ticket_mention' => 'Ich wurde erwähnt', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket aktualisiert']; } /** @@ -446,7 +446,7 @@ function fcm_event_types(): array function fcm_event_types_for(array $staff): array { $types = fcm_event_types(); - if (!has_permission($staff, 'reply_ticket')) unset($types['ticket_new'], $types['ticket_reply'], $types['chat_new']); + if (!has_permission($staff, 'reply_ticket')) unset($types['ticket_new'], $types['ticket_reply'], $types['ticket_wait'], $types['chat_new']); if (get_setting('live_chat_enabled', '0') !== '1') unset($types['chat_new']); return $types; } diff --git a/DeskHub-Webseite/includes/functions.php b/DeskHub-Webseite/includes/functions.php index 7706981e..6b35b635 100644 --- a/DeskHub-Webseite/includes/functions.php +++ b/DeskHub-Webseite/includes/functions.php @@ -784,6 +784,7 @@ function delete_ticket(PDO $pdo, array $ticket, ?int $deletedByStaffId): void $deletedByStaffId, db_now(), ]); + if (function_exists('ticket_links_purge')) ticket_links_purge($pdo, (int) $ticket['id']); $pdo->prepare('DELETE FROM tickets WHERE id = ?')->execute([$ticket['id']]); } @@ -1332,6 +1333,16 @@ function get_allowed_extensions(): array return config()['uploads']['allowed_extensions']; } +/** Zusätzliche Dateiendungen, die nur für Anhänge eingehender E-Mails erlaubt sind (Einstellungen → Uploads); Standard: gängige Büro- und Bildformate. */ +const MAIL_ATTACHMENT_EXTRA_DEFAULT = 'doc, xls, ppt, pptx, odt, ods, odp, csv, rtf, msg, heic, webp'; + +/** @return string[] */ +function mail_attachment_extra_extensions(): array +{ + $list = array_filter(array_map('trim', explode(',', strtolower(get_setting('mail_attachment_extra_extensions', MAIL_ATTACHMENT_EXTRA_DEFAULT))))); + return array_values(array_unique(array_map(fn($e) => ltrim($e, '.'), $list))); +} + /** Gibt die maximale Upload-Größe in Bytes zurück (aus DB-Setting oder config-Fallback). */ function get_max_upload_size(): int { @@ -1342,10 +1353,16 @@ function get_max_upload_size(): int return config()['uploads']['max_size_bytes']; } -function handle_ticket_upload(array $file): array +/** + * @param bool $dryRun nur prüfen (Name, Endung, Größe, Inhalt), nichts speichern + * @param array $extraAllowed zusätzlich erlaubte Endungen (nur für Dateien, die DeskHub selbst aus einer Mail übernimmt: Bilder, .eml) + * @param bool $httpUpload true bei einem Formular-Upload (move_uploaded_file); false für eine Datei, die DeskHub selbst angelegt hat + * (zum Beispiel ein Anhang aus einer abgerufenen E-Mail). Die Prüfungen sind in beiden Fällen dieselben. + */ +function handle_ticket_upload(array $file, bool $httpUpload = true, array $extraAllowed = [], bool $dryRun = false): array { $cfg = config()['uploads']; - $allowed = get_allowed_extensions(); + $allowed = array_values(array_unique(array_merge(get_allowed_extensions(), $extraAllowed))); $maxSize = get_max_upload_size(); if ($file['error'] !== UPLOAD_ERR_OK) { @@ -1392,6 +1409,7 @@ function handle_ticket_upload(array $file): array return ['error' => 'Der Inhalt der Datei „' . $file['name'] . '“ sieht nach einem Skript aus und ist aus Sicherheitsgründen nicht erlaubt.']; } } + if ($dryRun) return ['original_name' => $file['name'], 'stored_name' => '', 'filesize' => $file['size']]; // Sicherer, zufälliger Dateiname ohne ursprüngliche Extension-Inferenz $storedName = bin2hex(random_bytes(16)) . '.' . $ext; $uploadsDir = realpath(rtrim($cfg['path'], '/')); @@ -1399,12 +1417,55 @@ function handle_ticket_upload(array $file): array if (!$uploadsDir || strpos($dest, $uploadsDir) !== 0) { return ['error' => 'Ungültiger Upload-Pfad.']; } - if (!move_uploaded_file($file['tmp_name'], $dest)) { + $moved = $httpUpload ? move_uploaded_file($file['tmp_name'], $dest) : @copy($file['tmp_name'], $dest); + if (!$moved) { return ['error' => 'Die Datei konnte nicht gespeichert werden.']; } return ['original_name' => $file['name'], 'stored_name' => $storedName, 'filesize' => $file['size']]; } +/** + * Speichert einen Anhang aus einer E-Mail (Rohdaten) als Anhang einer Nachricht. Es gelten dieselben Regeln wie beim Upload + * (erlaubte Endungen, Größe, gesperrte Typen). + * + * @return array{ok:bool,error?:string,name:string} + */ +function store_ticket_attachment_data(PDO $pdo, int $threadId, string $name, string $data, array $opts = []): array +{ + $name = trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', '', basename(str_replace(chr(92), '/', $name)))); + if ($name === '') $name = 'Anhang'; + $name = mb_substr($name, 0, 255); + if ($data === '') return ['ok' => false, 'error' => 'Der Anhang ist leer.', 'name' => $name]; + $tmp = tempnam(sys_get_temp_dir(), 'dhmail'); + if ($tmp === false || file_put_contents($tmp, $data) === false) return ['ok' => false, 'error' => 'Der Anhang konnte nicht zwischengespeichert werden.', 'name' => $name]; + $res = handle_ticket_upload(['name' => $name, 'type' => '', 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => strlen($data)], false, (array) ($opts['extra_allowed'] ?? [])); + @unlink($tmp); + if (isset($res['error'])) return ['ok' => false, 'error' => (string) $res['error'], 'name' => $name]; + if (!empty($opts['content_id']) && ticket_mail_html_ready($pdo)) { + $pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at, content_id, is_inline) VALUES (?,?,?,?,?,?,?)') + ->execute([$threadId, $res['original_name'], $res['stored_name'], $res['filesize'], db_now(), mb_substr((string) $opts['content_id'], 0, 255), empty($opts['inline']) ? 0 : 1]); + } else { + $pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)') + ->execute([$threadId, $res['original_name'], $res['stored_name'], $res['filesize'], db_now()]); + } + return ['ok' => true, 'name' => $name]; +} + +/** Gibt es die Spalten für die HTML-Fassung von Mails (Datenbank-Update php_0070 eingespielt)? Bis dahin verhält sich alles wie bisher. */ +function ticket_mail_html_ready(PDO $pdo): bool +{ + static $cache = []; + $key = spl_object_id($pdo); + if (isset($cache[$key])) return $cache[$key]; + try { + $rows = $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'PRAGMA table_info(ticket_threads)' : 'SHOW COLUMNS FROM ticket_threads')->fetchAll(); + foreach ($rows as $r) if (($r['name'] ?? $r['Field'] ?? '') === 'message_html') return $cache[$key] = true; + } catch (Throwable $e) { + // Tabelle fehlt: nichts zu tun + } + return $cache[$key] = false; +} + /** Verarbeitet mehrere Datei-Uploads auf einmal (multi-file input). * Gibt ein Array von Ergebnissen zurück – jedes entweder mit 'error' oder mit den Upload-Daten. */ function handle_ticket_uploads_multi(array $filesInput): array @@ -1518,8 +1579,26 @@ function build_report_table(PDO $pdo, string $dimColumn, string $from, string $t * der Seite zu einem Tag verbindet. Attributwerte werden vor der Prüfung entschlüsselt (Entities), * weil der Browser sie ebenfalls entschlüsselt, und beim Ausgeben neu maskiert. */ -function sanitize_staff_html(string $html): string +/** + * Ob eine Nachricht HTML enthält (echte HTML-Tags wie

,

,
, …) und deshalb als HTML dargestellt werden muss. + * Eine Adresse in spitzen Klammern ("Olaf ") ist kein HTML: Wird sie dafür gehalten, verschwinden die Zeilenumbrüche + * des Textes (HTML kennt keine). + */ +function message_is_html(string $text): bool { + return (bool) preg_match('/<\/?(?:a|abbr|b|big|blockquote|body|br|caption|center|code|col|colgroup|div|em|figcaption|figure|font|h[1-6]|head|hr|html|i|img|li|meta|ol|p|pre|s|small|span|strike|strong|style|sub|sup|table|tbody|td|tfoot|th|thead|title|tr|u|ul|o:p)(?=[\s\/>])[^>]*>/i', $text); +} + +/** + * @param array{remote_images?:string,cid_map?:array} $opts + * remote_images: 'placeholder' = Bilder von fremden Adressen (https) werden nicht geladen, sondern durch einen Platzhalter ersetzt + * (Datenschutz: ein Bild von außen verrät dem Absender, wann und von wo die Mail geöffnet wurde). + * cid_map: Content-ID (klein, ohne <>) => Adresse des Bildes, das aus der Mail übernommen wurde (cid:-Bilder im Text) + */ +function sanitize_staff_html(string $html, array $opts = []): string +{ + $placeholderRemote = ($opts['remote_images'] ?? '') === 'placeholder'; + $cidMap = (array) ($opts['cid_map'] ?? []); if (trim($html) === '') return ''; // Gefährliche Elemente komplett inkl. Inhalt entfernen @@ -1603,7 +1682,15 @@ function sanitize_staff_html(string $html): string $src = $attr($raw, 'src'); if ($src !== null) { // Nur https, data:image (Rasterbilder) und cid: erlauben, alles andere durch ein leeres Bild ersetzen - if (preg_match('#^(https://|data:image/(?:png|jpe?g|gif|webp);|cid:)#i', $src)) { + $blank = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI='; + if (preg_match('#^cid:#i', $src)) { + $cid = strtolower(trim(substr($src, 4), " \t\r\n<>")); + if (isset($cidMap[$cid])) $out .= ' src="' . $e((string) $cidMap[$cid]) . '"'; + elseif ($placeholderRemote) $out .= ' src="' . $blank . '" title="Bild aus der E-Mail nicht verfügbar"'; + else $out .= ' src="' . $e($src) . '"'; + } elseif ($placeholderRemote && preg_match('#^https://#i', $src)) { + $out .= ' src="' . $blank . '" title="Externes Bild aus Datenschutzgründen nicht geladen" data-blocked-remote="1"'; + } elseif (preg_match('#^(https://|data:image/(?:png|jpe?g|gif|webp);)#i', $src)) { $out .= ' src="' . $e($src) . '"'; } else { $out .= ' src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI=" title="Bild aus Datenschutzgründen blockiert"'; diff --git a/DeskHub-Webseite/includes/gdpr.php b/DeskHub-Webseite/includes/gdpr.php new file mode 100644 index 00000000..33b5d592 --- /dev/null +++ b/DeskHub-Webseite/includes/gdpr.php @@ -0,0 +1,169 @@ +}|null + */ +function gdpr_resolve_customer(PDO $pdo, array $ref): ?array +{ + $user = null; + $email = trim((string) ($ref['email'] ?? '')); + if (!empty($ref['user_id'])) { + $stmt = $pdo->prepare('SELECT u.*, o.name AS org_name FROM users u LEFT JOIN organizations o ON o.id = u.organization_id WHERE u.id = ?'); + $stmt->execute([(int) $ref['user_id']]); + $user = $stmt->fetch() ?: null; + if (!$user) return null; + $email = (string) $user['email']; + } + if ($email === '') return null; + $stmt = $pdo->prepare('SELECT * FROM tickets WHERE requester_email = ? OR (user_id IS NOT NULL AND user_id = ?) ORDER BY created_at'); + $stmt->execute([$email, $user ? (int) $user['id'] : 0]); + $tickets = $stmt->fetchAll(); + if (!$user && !$tickets) return null; + return ['email' => $email, 'user' => $user, 'tickets' => $tickets]; +} + +function gdpr_plain(string $html): string +{ + $t = preg_replace('~<(br|/p|/div|/li|/tr)\b[^>]*>~i', "\n", $html) ?? $html; + return trim(html_entity_decode(strip_tags($t), ENT_QUOTES | ENT_HTML5, 'UTF-8')); +} + +/** Alle gespeicherten Daten zu einem Kunden. @return array|null */ +function gdpr_customer_export(PDO $pdo, array $ref): ?array +{ + $c = gdpr_resolve_customer($pdo, $ref); + if ($c === null) return null; + $user = $c['user']; + $out = [ + 'erstellt_am' => date('c'), + 'hinweis' => 'Auskunft nach Art. 15 DSGVO. Interne Notizen der Mitarbeiter sind als intern gekennzeichnet und vor der Weitergabe zu prüfen. Dateiinhalte der Anhänge sind nicht enthalten, nur deren Namen.', + 'kunde' => [ + 'name' => $user['name'] ?? ($c['tickets'][0]['requester_name'] ?? ''), + 'email' => $c['email'], + 'telefon' => $user['phone'] ?? null, + 'organisation' => $user['org_name'] ?? null, + 'konto_angelegt_am' => $user['created_at'] ?? null, + 'hat_konto' => $user !== null, + ], + 'tickets' => [], + 'notizen_zum_kunden' => [], + ]; + $dept = $pdo->prepare('SELECT name FROM departments WHERE id = ?'); + $threads = $pdo->prepare('SELECT t.*, s.name AS staff_name FROM ticket_threads t LEFT JOIN staff s ON s.id = t.staff_id WHERE t.ticket_id = ? ORDER BY t.created_at, t.id'); + $atts = $pdo->prepare('SELECT a.original_name, a.filesize, a.created_at FROM ticket_attachments a JOIN ticket_threads t ON t.id = a.thread_id WHERE t.ticket_id = ? ORDER BY a.id'); + $collab = $pdo->prepare('SELECT email, name FROM ticket_collaborators WHERE ticket_id = ?'); + $rating = $pdo->prepare('SELECT rating, comment, created_at FROM ticket_ratings WHERE ticket_id = ?'); + $csat = $pdo->prepare('SELECT rating, comment, answered_at FROM ticket_csat WHERE ticket_id = ? AND answered_at IS NOT NULL'); + foreach ($c['tickets'] as $t) { + $dept->execute([(int) $t['department_id']]); + $threads->execute([(int) $t['id']]); + $atts->execute([(int) $t['id']]); + $collab->execute([(int) $t['id']]); + $rating->execute([(int) $t['id']]); + $csat->execute([(int) $t['id']]); + $messages = []; + foreach ($threads->fetchAll() as $m) { + $messages[] = [ + 'zeitpunkt' => $m['created_at'], + 'von' => $m['author_type'] === 'customer' ? 'Kunde' : ($m['author_type'] === 'staff' ? 'Mitarbeiter' : 'System'), + 'mitarbeiter' => $m['author_type'] === 'staff' ? ($m['staff_name'] ?? null) : null, + 'intern' => (bool) $m['is_internal_note'], + 'text' => gdpr_plain((string) $m['message']), + ]; + } + $out['tickets'][] = [ + 'nummer' => $t['ticket_number'], 'betreff' => $t['subject'], 'status' => $t['status'], 'quelle' => $t['source'], + 'abteilung' => (string) ($dept->fetchColumn() ?: ''), 'angelegt_am' => $t['created_at'], 'geschlossen_am' => $t['closed_at'] ?: null, + 'name_im_ticket' => $t['requester_name'], 'email_im_ticket' => $t['requester_email'], 'ip_adresse' => $t['requester_ip'] ?: null, + 'nachrichten' => $messages, + 'anhaenge' => array_map(fn($a) => ['name' => $a['original_name'], 'groesse_bytes' => (int) $a['filesize'], 'hinzugefuegt_am' => $a['created_at']], $atts->fetchAll()), + 'mitleser' => $collab->fetchAll(PDO::FETCH_ASSOC), + 'bewertung' => $rating->fetch(PDO::FETCH_ASSOC) ?: ($csat->fetch(PDO::FETCH_ASSOC) ?: null), + ]; + } + if ($user) { + $n = $pdo->prepare('SELECT note, created_at FROM customer_notes WHERE user_id = ? ORDER BY created_at'); + $n->execute([(int) $user['id']]); + $out['notizen_zum_kunden'] = array_map(fn($r) => ['zeitpunkt' => $r['created_at'], 'text' => $r['note']], $n->fetchAll()); + } + return $out; +} + +/** + * Löscht die personenbezogenen Daten eines Kunden. Ticketnummer, Betreff, Status und Zeiten bleiben für die Statistik erhalten. + * Offene Tickets verhindern die Löschung, solange $force nicht gesetzt ist. + * @return array{error:?string,tickets:int,messages:int,files:int} + */ +function gdpr_customer_erase(PDO $pdo, array $ref, bool $force = false): array +{ + $res = ['error' => null, 'tickets' => 0, 'messages' => 0, 'files' => 0]; + $c = gdpr_resolve_customer($pdo, $ref); + if ($c === null) { $res['error'] = 'Kunde nicht gefunden.'; return $res; } + $open = array_filter($c['tickets'], fn($t) => !in_array($t['status'], ['resolved', 'closed'], true)); + if ($open && !$force) { + $res['error'] = count($open) . ' Ticket(s) sind noch offen. Bitte zuerst schließen oder die Löschung ausdrücklich erzwingen.'; + return $res; + } + $user = $c['user']; + $email = $c['email']; + $anon = 'anon_' . substr(hash('sha256', $email . 'deskhub-anon'), 0, 16) . '@deleted.local'; + $uploads = realpath((string) (config()['uploads']['path'] ?? '')) ?: ''; + $files = []; + + $pdo->beginTransaction(); + try { + foreach ($c['tickets'] as $t) { + $tid = (int) $t['id']; + // Dateien der Kunden-Nachrichten merken und Zeilen entfernen + $stmt = $pdo->prepare("SELECT a.id, a.stored_name FROM ticket_attachments a JOIN ticket_threads th ON th.id = a.thread_id WHERE th.ticket_id = ? AND th.author_type = 'customer'"); + $stmt->execute([$tid]); + foreach ($stmt->fetchAll() as $a) { + $files[] = (string) $a['stored_name']; + $pdo->prepare('DELETE FROM ticket_attachments WHERE id = ?')->execute([(int) $a['id']]); + } + $hasHtml = function_exists('ticket_mail_html_ready') && ticket_mail_html_ready($pdo); + $upd = $pdo->prepare("UPDATE ticket_threads SET message = ?, " . ($hasHtml ? 'message_html = NULL, ' : '') . "user_id = NULL WHERE ticket_id = ? AND author_type = 'customer'"); + $upd->execute(['[Auf Anfrage des Betroffenen gelöscht]', $tid]); + $res['messages'] += $upd->rowCount(); + $pdo->prepare('DELETE FROM ticket_collaborators WHERE ticket_id = ? AND LOWER(email) = LOWER(?)')->execute([$tid, $email]); + $pdo->prepare('UPDATE ticket_ratings SET comment = NULL, user_id = NULL WHERE ticket_id = ?')->execute([$tid]); + $pdo->prepare('UPDATE ticket_csat SET comment = NULL WHERE ticket_id = ?')->execute([$tid]); + $pdo->prepare("UPDATE tickets SET requester_name = 'Gelöscht', requester_email = ?, requester_ip = '0.0.0.0', user_id = NULL WHERE id = ?")->execute([$anon, $tid]); + $res['tickets']++; + } + if ($user) { + $uid = (int) $user['id']; + $pdo->prepare('DELETE FROM customer_notes WHERE user_id = ?')->execute([$uid]); + $pdo->prepare('UPDATE tickets SET user_id = NULL WHERE user_id = ?')->execute([$uid]); + $pdo->prepare('UPDATE ticket_threads SET user_id = NULL WHERE user_id = ?')->execute([$uid]); + $pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$uid]); + } + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) $pdo->rollBack(); + $res['error'] = 'Löschen fehlgeschlagen: ' . $e->getMessage(); + return $res; + } + // Dateien erst nach erfolgreichem Abschluss löschen (nur innerhalb des Upload-Ordners) + foreach (array_unique($files) as $name) { + if ($name === '' || $uploads === '' || $name !== basename($name)) continue; + $path = $uploads . DIRECTORY_SEPARATOR . $name; + if (is_file($path) && @unlink($path)) $res['files']++; + } + return $res; +} diff --git a/DeskHub-Webseite/includes/i18n.php b/DeskHub-Webseite/includes/i18n.php index db749da4..de488029 100644 --- a/DeskHub-Webseite/includes/i18n.php +++ b/DeskHub-Webseite/includes/i18n.php @@ -108,6 +108,13 @@ function i18n_dictionary(string $code, bool $reload = false): array $packFile = i18n_languages()[$code]['strings'] ?? ''; if ($packFile !== '') $files[] = $packFile; foreach ($files as $file) i18n_load_strings_file($file, $dict); + // Genauere (längere) Muster zuerst: Sonst würde ein allgemeines wie „Ticket #{}“ ein spezielleres wie „Ticket #{} wurde nicht gefunden.“ überdecken + if (count($dict['patterns']) > 1) { + $order = array_keys($dict['patterns']); + usort($order, fn($x, $y) => strlen((string) ($dict['pkeys'][$y] ?? '')) <=> strlen((string) ($dict['pkeys'][$x] ?? ''))); + $dict['patterns'] = array_map(fn($i) => $dict['patterns'][$i], $order); + $dict['pkeys'] = array_map(fn($i) => $dict['pkeys'][$i] ?? '', $order); + } return $cache[$code] = $dict; } diff --git a/DeskHub-Webseite/includes/integrations.php b/DeskHub-Webseite/includes/integrations.php index 3a79fcb4..05641346 100644 --- a/DeskHub-Webseite/includes/integrations.php +++ b/DeskHub-Webseite/includes/integrations.php @@ -111,6 +111,59 @@ function public_form_fields_payload(array $fields): array } /** Das ganze Formular für die API-Antwort (Texte und Felder). */ +/** Grenzen für Dateianhänge über die öffentliche Schnittstelle (die Datei kommt Base64-kodiert im JSON). */ +const PUBLIC_ATTACHMENT_MAX_FILES = 5; +const PUBLIC_ATTACHMENT_FILE_BYTES = 5 * 1048576; +const PUBLIC_ATTACHMENT_TOTAL_BYTES = 10 * 1048576; + +/** Dürfen Besucher bei diesem Formular Dateien anhängen, und mit welchen Grenzen? (Ein eigenes Formular kann Anhänge abschalten.) */ +function public_attachment_policy(?array $own): array +{ + $enabled = $own === null || !array_key_exists('attachment_show', $own) || !empty($own['attachment_show']); + return [ + 'enabled' => $enabled, + 'maxFiles' => PUBLIC_ATTACHMENT_MAX_FILES, + 'maxFileBytes' => min(PUBLIC_ATTACHMENT_FILE_BYTES, get_max_upload_size()), + 'maxTotalBytes' => PUBLIC_ATTACHMENT_TOTAL_BYTES, + 'allowedExtensions' => get_allowed_extensions(), + ]; +} + +/** + * Prüft die Dateianhänge einer Anfrage und liefert sie dekodiert: [['name' => …, 'data' => Rohdaten], …]. Geprüft wird alles vor dem + * Anlegen des Tickets (Anzahl, Größe, Endung, Inhalt wie beim Upload im Formular). + * + * @throws AdminModelException mit Code INVALID_ATTACHMENT (die Meldung ist für den Besucher gedacht) + */ +function public_attachments_prepare($raw, ?array $own): array +{ + if ($raw === null || $raw === [] || $raw === '') return []; + $policy = public_attachment_policy($own); + if (!is_array($raw) || (count($raw) > 0 && array_keys($raw) !== range(0, count($raw) - 1))) throw new AdminModelException('Die Dateianhänge haben ein ungültiges Format.', 'INVALID_ATTACHMENT'); + if (!$policy['enabled']) throw new AdminModelException('Bei diesem Formular können keine Dateien angehängt werden.', 'INVALID_ATTACHMENT'); + if (count($raw) > $policy['maxFiles']) throw new AdminModelException('Es können höchstens ' . $policy['maxFiles'] . ' Dateien angehängt werden.', 'INVALID_ATTACHMENT'); + $out = []; + $total = 0; + foreach ($raw as $a) { + $name = is_array($a) ? trim((string) ($a['name'] ?? '')) : ''; + $b64 = is_array($a) ? (string) ($a['data'] ?? '') : ''; + if ($name === '' || mb_strlen($name) > 200 || $b64 === '') throw new AdminModelException('Jeder Anhang braucht einen Namen und Inhalt (Base64).', 'INVALID_ATTACHMENT'); + $data = base64_decode(preg_replace('/\s+/', '', $b64) ?? '', true); + if ($data === false || $data === '') throw new AdminModelException('Der Anhang „' . mb_substr($name, 0, 80) . '“ ist nicht lesbar (Base64).', 'INVALID_ATTACHMENT'); + if (strlen($data) > $policy['maxFileBytes']) throw new AdminModelException('Der Anhang „' . mb_substr($name, 0, 80) . '“ ist zu groß (höchstens ' . round($policy['maxFileBytes'] / 1048576, 1) . ' MB).', 'INVALID_ATTACHMENT'); + $total += strlen($data); + if ($total > $policy['maxTotalBytes']) throw new AdminModelException('Die Anhänge sind zusammen zu groß (höchstens ' . round($policy['maxTotalBytes'] / 1048576) . ' MB).', 'INVALID_ATTACHMENT'); + // Name, Endung und Inhalt wie beim Upload im Formular prüfen, ohne etwas zu speichern + $tmp = tempnam(sys_get_temp_dir(), 'dhpub'); + if ($tmp === false || file_put_contents($tmp, $data) === false) throw new AdminModelException('Der Anhang konnte nicht verarbeitet werden.', 'INVALID_ATTACHMENT'); + $check = handle_ticket_upload(['name' => basename(str_replace(chr(92), '/', $name)), 'type' => '', 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => strlen($data)], false, [], true); + @unlink($tmp); + if (isset($check['error'])) throw new AdminModelException(strip_tags((string) $check['error']), 'INVALID_ATTACHMENT'); + $out[] = ['name' => $name, 'data' => $data]; + } + return $out; +} + function public_form_payload(array $def): array { $o = $def['own']; @@ -125,6 +178,7 @@ function public_form_payload(array $def): array 'hint' => (string) ($o['message_hint'] ?? 'Beschreiben Sie Ihr Anliegen so detailliert wie möglich…')], 'submitLabel' => (string) ($o['submit_label'] ?? 'Ticket erstellen'), 'fields' => public_form_fields_payload($def['fields']), + 'attachments' => public_attachment_policy($o), ]; } @@ -221,6 +275,8 @@ function public_ticket_create(PDO $pdo, array $in, string $clientIp, array $opts if ($message === '' || mb_strlen($message) > 50000) throw new AdminModelException('Bitte eine Nachricht angeben (höchstens 50.000 Zeichen).', 'INVALID_MESSAGE'); if (is_banned($email)) throw new AdminModelException('Diese Absenderadresse ist gesperrt.', 'BANNED', 403); $fieldValues = $formDef ? public_form_validate_fields($formDef['fields'], $fieldInput) : []; + // Dateianhänge (Base64 im JSON): ohne "fields" gilt das Standardformular, das Anhänge erlaubt + $attachmentList = public_attachments_prepare($in['attachments'] ?? null, $formDef['own'] ?? null); $topicId = am_int_or_null($in, 'helpTopicId'); $deptId = am_int_or_null($in, 'departmentId'); @@ -262,6 +318,11 @@ function public_ticket_create(PDO $pdo, array $in, string $clientIp, array $opts $ticketId = (int) $pdo->lastInsertId(); $pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)') ->execute([$ticketId, 'customer', $message, $now]); + $threadId = (int) $pdo->lastInsertId(); + foreach ($attachmentList as $att) { + $saved = store_ticket_attachment_data($pdo, $threadId, $att['name'], $att['data']); + if (!$saved['ok']) log_activity($pdo, $ticketId, null, 'Anhang nicht übernommen: ' . mb_substr($att['name'], 0, 80) . ' (' . mb_substr((string) ($saved['error'] ?? ''), 0, 100) . ')'); + } if ($fieldValues) { $cfStmt = $pdo->prepare('INSERT INTO ticket_field_values (ticket_id, custom_field_id, value) VALUES (?,?,?)'); foreach ($fieldValues as $fieldId => $val) { diff --git a/DeskHub-Webseite/includes/mail_fetch_manual.php b/DeskHub-Webseite/includes/mail_fetch_manual.php new file mode 100644 index 00000000..ada12bc0 --- /dev/null +++ b/DeskHub-Webseite/includes/mail_fetch_manual.php @@ -0,0 +1,82 @@ +query('SELECT COUNT(*) FROM imap_accounts WHERE is_active = 1')->fetchColumn() > 0; + } catch (Throwable $e) { + return false; + } +} + +/** + * Startet den Abruf. $runner ist nur für Tests austauschbar (liefert wie run_fetch_emails ['processed' => int, 'log' => string[]]). + * + * @return array{ok:bool,level:string,message:string,processed:int,new_tickets:int,replies:int,errors:string[]} + */ +function mail_fetch_manual(PDO $pdo, ?callable $runner = null): array +{ + $res = ['ok' => false, 'level' => 'error', 'message' => '', 'processed' => 0, 'new_tickets' => 0, 'replies' => 0, 'errors' => []]; + if (!function_exists('imap_open') && $runner === null) { + $res['message'] = 'Der Abruf ist nicht möglich: Die PHP-Erweiterung IMAP ist auf diesem Server nicht aktiv.'; + return $res; + } + if (!mail_fetch_available($pdo)) { + $res['message'] = 'Es ist kein aktives Postfach eingerichtet (Adminportal → Postfächer (Abruf)).'; + return $res; + } + if (!pseudo_cron_try_claim($pdo, 'cron_fetch_emails_last_run', MAIL_FETCH_MANUAL_COOLDOWN)) { + $res['level'] = 'info'; + $res['message'] = 'Der E-Mail-Abruf lief gerade eben schon (oder läuft noch). Bitte einige Sekunden warten und dann erneut versuchen.'; + return $res; + } + if ($runner === null) { + if (!defined('DESKHUB_CRON_RUNNER')) define('DESKHUB_CRON_RUNNER', true); + require_once __DIR__ . '/../cron/fetch_emails.php'; + $runner = fn() => run_fetch_emails($pdo, true); + } + @set_time_limit(120); + try { + $out = $runner(); + } catch (Throwable $e) { + error_log('Manueller E-Mail-Abruf: ' . $e->getMessage()); + $res['message'] = 'Der E-Mail-Abruf ist mit einem Fehler abgebrochen: ' . mb_substr($e->getMessage(), 0, 200); + return $res; + } + $res['processed'] = (int) ($out['processed'] ?? 0); + foreach ((array) ($out['log'] ?? []) as $line) { + $line = (string) $line; + if (preg_match('/Neues Ticket #/', $line)) $res['new_tickets']++; + elseif (preg_match('/Antwort zu Ticket #/', $line)) $res['replies']++; + if (preg_match('/fehlgeschlagen|Abbruch|nicht installiert|Kein aktives Postfach/i', $line) && !str_contains($line, 'Anhang nicht übernommen')) { + $res['errors'][] = mb_substr(preg_replace('/^\[[^\]]*\]\s*/', '', $line) ?? $line, 0, 220); + } + } + $res['ok'] = $res['errors'] === []; + if ($res['errors']) { + $res['message'] = 'Der Abruf hatte Probleme: ' . implode(' | ', array_slice($res['errors'], 0, 3)); + } elseif ($res['processed'] === 0) { + $res['level'] = 'info'; + $res['message'] = 'Abruf fertig: Keine neuen E-Mails.'; + } else { + $res['level'] = 'success'; + $parts = []; + if ($res['new_tickets'] > 0) $parts[] = $res['new_tickets'] . ' neue(s) Ticket(s)'; + if ($res['replies'] > 0) $parts[] = $res['replies'] . ' Antwort(en)'; + $other = $res['processed'] - $res['new_tickets'] - $res['replies']; + if ($other > 0) $parts[] = $other . ' weitere (zum Beispiel Spam oder Systemmails, ohne Ticket)'; + $res['message'] = 'Abruf fertig: ' . $res['processed'] . ' E-Mail(s) verarbeitet' . ($parts ? ' (' . implode(', ', $parts) . ')' : '') . '.'; + } + return $res; +} diff --git a/DeskHub-Webseite/includes/mail_reload.php b/DeskHub-Webseite/includes/mail_reload.php new file mode 100644 index 00000000..85f0df75 --- /dev/null +++ b/DeskHub-Webseite/includes/mail_reload.php @@ -0,0 +1,126 @@ + $threads Kundennachrichten des Tickets + */ +function mail_reload_pick_thread(array $threads, string $sourceCreatedAt): ?int +{ + $at = strtotime($sourceCreatedAt) ?: 0; + $best = null; + $bestDiff = PHP_INT_MAX; + foreach ($threads as $t) { + $diff = abs((strtotime((string) $t['created_at']) ?: 0) - $at); + if ($diff < $bestDiff) { + $bestDiff = $diff; + $best = (int) $t['id']; + } + } + return $best; +} + +/** + * Findet die Mail im geöffneten Ordner: über die UID (und passende Message-ID, falls beide bekannt), sonst nur über die Message-ID. + */ +function mail_reload_find_message(mixed $mbox, array $source): ?int +{ + $wanted = imap_normalize_message_id((string) ($source['message_id'] ?? '')); + $uid = (int) ($source['message_uid'] ?? 0); + if ($uid > 0) { + $num = (int) @imap_msgno($mbox, $uid); + if ($num > 0) { + if ($wanted === '') return $num; + $h = @imap_headerinfo($mbox, $num); + if ($h && imap_normalize_message_id($h->message_id ?? '') === $wanted) return $num; + } + } + if ($wanted !== '') { + $hits = @imap_search($mbox, 'ALL') ?: []; + foreach ($hits as $num) { + $h = @imap_headerinfo($mbox, (int) $num); + if ($h && imap_normalize_message_id($h->message_id ?? '') === $wanted) return (int) $num; + } + } + return null; +} + +/** + * Lädt die Anhänge aller Original-Mails eines Tickets nach. Bereits vorhandene Anhänge werden nicht doppelt gespeichert. + * + * @return array{error:?string,added:int,inline:int,html:int,skipped:int,existing:int,mails:int,failed:string[]} + */ +function reload_ticket_mail_attachments(PDO $pdo, array $ticket, ?int $staffId = null): array +{ + $res = ['error' => null, 'added' => 0, 'inline' => 0, 'html' => 0, 'skipped' => 0, 'existing' => 0, 'mails' => 0, 'failed' => []]; + if (!function_exists('imap_open')) { + $res['error'] = 'PHP-IMAP ist nicht installiert.'; + return $res; + } + $ticketId = (int) $ticket['id']; + $srcStmt = $pdo->prepare( + 'SELECT tes.*, ia.label, ia.host, ia.port, ia.encryption, ia.username, ia.password_enc + FROM ticket_email_sources tes JOIN imap_accounts ia ON ia.id = tes.imap_account_id + WHERE tes.ticket_id = ? ORDER BY tes.id' + ); + $srcStmt->execute([$ticketId]); + $sources = $srcStmt->fetchAll(); + if (!$sources) { + $res['error'] = 'Zu diesem Ticket ist keine Original-E-Mail gespeichert (nur per E-Mail abgerufene Tickets).'; + return $res; + } + $thStmt = $pdo->prepare("SELECT id, created_at FROM ticket_threads WHERE ticket_id = ? AND author_type = 'customer' AND is_internal_note = 0 ORDER BY id"); + $thStmt->execute([$ticketId]); + $threads = $thStmt->fetchAll(); + if (!$threads) { + $res['error'] = 'Das Ticket hat keine Kundennachricht, an die Anhänge gehängt werden könnten.'; + return $res; + } + + foreach ($sources as $source) { + $folder = trim((string) ($source['current_folder'] ?? '')) ?: 'INBOX'; + $label = (string) ($source['label'] ?: $source['username']); + $mbox = imap_connect_account($source, $folder); + if (!$mbox) { + $res['failed'][] = "[$label] Postfach nicht erreichbar: " . (imap_last_error() ?: 'unbekannter Fehler'); + continue; + } + $num = mail_reload_find_message($mbox, $source); + if ($num === null) { + imap_close($mbox); + $res['failed'][] = "[$label] Original-E-Mail wurde im Ordner „{$folder}“ nicht gefunden (verschoben oder gelöscht?)."; + continue; + } + $threadId = mail_reload_pick_thread($threads, (string) $source['created_at']); + $structure = @imap_fetchstructure($mbox, $num) ?: null; + $log = []; + $fetchPart = fn(string $no) => (string) imap_fetchbody($mbox, $num, $no); + // Formatierte Fassung der Nachricht (HTML) nachtragen, wenn es noch keine gibt + $htmlBody = null; + if (ticket_mail_html_ready($pdo) && $structure) { + $htmlBody = mail_html_for_display($structure, $fetchPart); + $has = $pdo->prepare('SELECT message_html FROM ticket_threads WHERE id = ?'); + $has->execute([(int) $threadId]); + if ($htmlBody !== null && trim((string) $has->fetchColumn()) === '') { + $pdo->prepare('UPDATE ticket_threads SET message_html = ? WHERE id = ?')->execute([$htmlBody, (int) $threadId]); + $res['html']++; + } + } + $c = mail_store_attachments($pdo, $structure, $fetchPart, (int) $threadId, $ticketId, $label, $log, true, $htmlBody !== null); + imap_close($mbox); + $res['mails']++; + foreach (['added', 'inline', 'skipped', 'existing'] as $k) $res[$k] += $c[$k]; + } + if ($res['added'] + $res['inline'] + $res['html'] > 0) log_activity($pdo, $ticketId, $staffId, 'Anhänge und Bilder aus der E-Mail nachgeladen: ' . $res['added'] . ' Anhang/Anhänge, ' . $res['inline'] . ' Bild(er)' . ($res['html'] ? ', Formatierung' : '')); + return $res; +} diff --git a/DeskHub-Webseite/includes/mail_text.php b/DeskHub-Webseite/includes/mail_text.php index 0ccd1d41..042f4980 100644 --- a/DeskHub-Webseite/includes/mail_text.php +++ b/DeskHub-Webseite/includes/mail_text.php @@ -144,6 +144,277 @@ function mail_collect_text_parts(object $part, string $no = ''): array return $found; } +/** Dateiname eines Anhangs: MIME-Kodierung (=?UTF-8?B?…?=) und RFC 2231 (UTF-8''…) werden aufgelöst. */ +function mail_decode_filename(string $value): string +{ + $value = trim($value); + if (preg_match("/^([A-Za-z0-9_-]+)'[^']*'(.*)$/", $value, $m)) { + $decoded = rawurldecode($m[2]); + $cs = strtolower($m[1]); + if ($cs !== 'utf-8' && $cs !== 'utf8') { + try { $decoded = (string) mb_convert_encoding($decoded, 'UTF-8', $cs); } catch (Throwable) { /* bleibt */ } + } + return $decoded; + } + if (str_contains($value, '=?')) { + $decoded = function_exists('iconv_mime_decode') ? @iconv_mime_decode($value, 0, 'UTF-8') : false; + if ($decoded === false || $decoded === '') $decoded = mb_decode_mimeheader($value); + return (string) $decoded; + } + return mb_check_encoding($value, 'UTF-8') ? $value : (string) mb_convert_encoding($value, 'UTF-8', 'ISO-8859-1'); +} + +/** + * Sammelt die Anhänge einer IMAP-Struktur: Teile mit Dateinamen oder der Anordnung "attachment" sowie weitergeleitete Mails + * (message/rfc822). Bilder mit Content-ID, die im Text der Mail verwendet werden (Logos, Signaturbilder, eingefügte Fotos), sind als + * "inline" gekennzeichnet: Sie gehören zur Darstellung der Mail und werden nur dann als Anhang gezeigt, wenn es keine HTML-Fassung gibt. + * + * @return array + */ +function mail_collect_attachment_parts(object $part, string $no = ''): array +{ + $type = (int) ($part->type ?? -1); + if (!empty($part->parts) && is_array($part->parts) && $type !== 2) { + $found = []; + foreach ($part->parts as $i => $sub) { + $found = array_merge($found, mail_collect_attachment_parts($sub, $no === '' ? (string) ($i + 1) : $no . '.' . ($i + 1))); + } + return $found; + } + $disposition = strtolower((string) ($part->disposition ?? '')); + $name = ''; + foreach (array_merge((array) ($part->dparameters ?? []), (array) ($part->parameters ?? [])) as $p) { + $attr = strtolower((string) ($p->attribute ?? '')); + if ($name === '' && ($attr === 'filename' || $attr === 'filename*' || $attr === 'name' || $attr === 'name*')) $name = mail_decode_filename((string) $p->value); + } + $cid = strtolower(trim((string) ($part->id ?? ''), " \t\r\n<>")); + $isEml = $type === 2; + $inline = $type === 5 && $disposition !== 'attachment' && !empty($part->ifid) && $cid !== ''; + if (!$isEml && !$inline && $name === '' && $disposition !== 'attachment') return []; + if ($name === '') { + if ($isEml) $name = 'Weitergeleitete E-Mail.eml'; + else $name = 'Anhang' . (!empty($part->subtype) ? '.' . strtolower((string) $part->subtype) : ''); + } + return [['no' => $no === '' ? '1' : $no, 'name' => $name, 'encoding' => (int) ($part->encoding ?? 0), 'bytes' => (int) ($part->bytes ?? 0), 'inline' => $inline, 'cid' => $cid, 'eml' => $isEml]]; +} + +/** Dateiendung für ein Rasterbild anhand seines Inhalts (nur gif, jpg, png, webp), sonst null: HTML oder Skripte im Bild-Gewand fallen so durch. */ +function mail_image_extension(string $data): ?string +{ + $info = @getimagesizefromstring($data); + if (!$info) return null; + return match ($info[2] ?? 0) { + IMAGETYPE_GIF => 'gif', + IMAGETYPE_JPEG => 'jpg', + IMAGETYPE_PNG => 'png', + IMAGETYPE_WEBP => 'webp', + default => null, + }; +} + +/** Name einer weitergeleiteten Mail (.eml) aus ihrem Betreff. */ +function mail_eml_name(string $raw, string $fallback): string +{ + if (preg_match('/^Subject:[ \t]*(.*(?:\r?\n[ \t]+.*)*)/mi', substr($raw, 0, 8192), $m)) { + $subject = trim(mail_decode_filename(preg_replace('/\r?\n[ \t]+/', ' ', $m[1]) ?? '')); + $subject = trim((string) preg_replace('~[\\\\/:*?"<>|\x00-\x1F]+~u', ' ', $subject)); + if ($subject !== '') return mb_substr($subject, 0, 120) . '.eml'; + } + return $fallback; +} + +/** + * HTML-Fassung einer Mail für die Anzeige im Ticket (bereinigt: nur erlaubte Tags und Eigenschaften), null ohne HTML-Teil. + * + * @param callable(string):string $fetchPart liefert den rohen Inhalt eines Teils (imap_fetchbody) + */ +function mail_html_for_display(object $structure, callable $fetchPart): ?string +{ + foreach (mail_collect_text_parts($structure) as $p) { + if ($p['subtype'] !== 'HTML') continue; + $html = mail_decode_part($fetchPart($p['no']), $p['encoding'], $p['charset']); + if (trim($html) === '' || strlen($html) > 3000000) return null; + $clean = sanitize_staff_html($html); + if (trim(strip_tags($clean)) === '' && stripos($clean, ' 400000 ? null : $clean; + } + return null; +} + +/** Rohdaten eines Mail-Teils nach der Transferkodierung (Anhänge sind Binärdaten, kein Zeichensatz). */ +function mail_decode_binary(string $raw, int $encoding): string +{ + return match ($encoding) { + 3 => (string) base64_decode(preg_replace('/\s+/', '', $raw) ?? $raw, false), + 4 => quoted_printable_decode($raw), + default => $raw, + }; +} + +/** + * Übernimmt die Anhänge einer Mail an die Nachricht $threadId des Tickets. Nicht übernommene Anhänge (Typ nicht erlaubt, zu groß) + * werden im Verlauf des Tickets vermerkt, damit niemand glaubt, die Mail hätte keinen gehabt. + * + * @param callable(string):string $fetchPart liefert den rohen Inhalt eines Teils (imap_fetchbody) + * @param bool $skipExisting Anhänge, die an dieser Nachricht schon hängen (gleicher Name und gleiche Größe), nicht noch einmal speichern + * @param bool $inlineImages die Mail hat eine HTML-Fassung: Bilder mit Content-ID werden als eingebettete Bilder gespeichert (und nicht als + * Anhang aufgelistet); sonst zählen sie wie jeder andere Anhang + * @return array{added:int,inline:int,skipped:int,existing:int} + */ +function mail_store_attachments(PDO $pdo, ?object $structure, callable $fetchPart, int $threadId, int $ticketId, string $label, array &$log, bool $skipExisting = false, bool $inlineImages = false): array +{ + $count = ['added' => 0, 'inline' => 0, 'skipped' => 0, 'existing' => 0]; + if (!$structure || $threadId <= 0) return $count; + $max = get_max_upload_size(); + $parts = mail_collect_attachment_parts($structure); + $imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp']; + foreach (array_slice($parts, 0, 40) as $p) { + $reason = null; + $name = $p['name']; + $asInline = $inlineImages && $p['inline']; + // Vorab nach Größe prüfen, ohne den Teil zu laden (Base64 ist rund ein Drittel größer als die Datei) + $tooBig = $p['bytes'] > 0 && (int) ($p['encoding'] === 3 ? $p['bytes'] * 0.75 : $p['bytes']) > $max; + if ($tooBig) $reason = 'zu groß (max. ' . round($max / 1048576) . ' MB)'; + if ($reason === null) { + $raw = $fetchPart($p['no']); + $data = $p['eml'] ? $raw : mail_decode_binary($raw, $p['encoding']); + $opts = ['extra_allowed' => mail_attachment_extra_extensions()]; + if ($p['eml']) { + $name = mail_eml_name($data, $name); + if (!str_ends_with(strtolower($name), '.eml')) $name .= '.eml'; + $opts['extra_allowed'][] = 'eml'; + } elseif ($asInline) { + $ext = mail_image_extension($data); + if ($ext === null) { + $log[] = "[$label] → Eingebettetes Bild übersprungen (kein Bild): {$name}"; + continue; + } + if (!in_array(strtolower(pathinfo($name, PATHINFO_EXTENSION)), $imageExts, true)) $name = 'bild-' . substr(md5($p['cid']), 0, 8) . '.' . $ext; + $opts = ['extra_allowed' => $imageExts, 'content_id' => $p['cid'], 'inline' => true]; + } + if ($skipExisting) { + if ($asInline) { + $dup = $pdo->prepare('SELECT COUNT(*) FROM ticket_attachments WHERE thread_id = ? AND content_id = ?'); + $dup->execute([$threadId, mb_substr($p['cid'], 0, 255)]); + } else { + $dup = $pdo->prepare('SELECT COUNT(*) FROM ticket_attachments WHERE thread_id = ? AND original_name = ? AND filesize = ?'); + $dup->execute([$threadId, mb_substr(trim(basename(str_replace(chr(92), '/', $name))), 0, 255), strlen($data)]); + } + if ((int) $dup->fetchColumn() > 0) { + $count['existing']++; + continue; + } + } + $res = store_ticket_attachment_data($pdo, $threadId, $name, $data, $opts); + if ($res['ok']) { + $log[] = "[$label] → " . ($asInline ? 'Eingebettetes Bild' : 'Anhang') . " übernommen: {$res['name']}"; + $count[$asInline ? 'inline' : 'added']++; + continue; + } + $reason = (string) ($res['error'] ?? 'unbekannter Fehler'); + } + if ($asInline) { + $log[] = "[$label] → Eingebettetes Bild nicht übernommen: {$name} ({$reason})"; + continue; + } + $count['skipped']++; + $log[] = "[$label] → Anhang nicht übernommen: {$name} ({$reason})"; + log_activity($pdo, $ticketId, null, 'Anhang aus E-Mail nicht übernommen: ' . mb_substr($name, 0, 80) . ' (' . mb_substr($reason, 0, 100) . ')'); + } + if (count($parts) > 40) log_activity($pdo, $ticketId, null, 'Anhänge aus E-Mail nicht übernommen: mehr als 40 Teile, die übrigen fehlen'); + return $count; +} + +/** Kopfzeilen-Schlüssel eines zitierten Mailkopfs ("Von: …", "Gesendet: …") am Zeilenanfang. */ +const MAIL_QUOTE_HEADER_KEYS = '(?:Von|From|Gesendet|Sent|Datum|Date|An|To|Cc|Betreff|Subject)'; + +/** + * Trennt eine Nachricht (Text) in den neuen Teil und den zitierten Verlauf früherer Mails. Der Verlauf beginnt bei der ersten + * Zeile, die ein Zitat ankündigt: "Am … schrieb …:", "On … wrote:", "-----Ursprüngliche Nachricht-----", ein zitierter Mailkopf + * ("Von: … Gesendet: … An: … Betreff: …") oder eine mit ">" beginnende Zeile. Steht vor dieser Stelle kein Text (die ganze Mail ist + * zitiert oder weitergeleitet), wird nichts getrennt. + * + * @return array{0:string,1:string} [neuer Teil, zitierter Verlauf (leer, wenn es keinen gibt)] + */ +function mail_split_quoted_text(string $text): array +{ + $text = str_replace(["\r\n", "\r"], "\n", $text); + $lines = explode("\n", $text); + $n = count($lines); + for ($i = 0; $i < $n; $i++) { + $line = $lines[$i]; + $isMarker = false; + if (preg_match('/^\s*-{2,}\s*(Original Message|Ursprüngliche Nachricht|Urspr\. Nachricht|Weitergeleitete Nachricht|Forwarded message)\s*-{2,}\s*$/iu', $line)) { + $isMarker = true; + } elseif (preg_match('/^\s*(Am|On)\s.+/iu', $line)) { + $joined = $line . ' ' . ($lines[$i + 1] ?? ''); + if (preg_match('/^\s*(Am|On)\s.{5,200}\s(schrieb|wrote)\b.*:\s*$/iu', $joined) || preg_match('/^\s*(Am|On)\s.{5,200}\s(schrieb|wrote)\b.*:\s*$/iu', $line)) $isMarker = true; + } elseif (preg_match('/^\s*>/', $line)) { + $isMarker = true; + } elseif (preg_match('/^\s*' . MAIL_QUOTE_HEADER_KEYS . '\s*:/iu', $line)) { + // Zitierter Mailkopf: mehrere solche Zeilen hintereinander, darunter "Von" + $keys = []; + for ($j = $i; $j < min($n, $i + 7); $j++) { + if (preg_match('/^\s*(' . MAIL_QUOTE_HEADER_KEYS . ')\s*:/iu', $lines[$j], $m)) $keys[] = mb_strtolower($m[1]); + elseif (trim($lines[$j]) !== '') break; + } + if (count($keys) >= 3 && (in_array('von', $keys, true) || in_array('from', $keys, true))) $isMarker = true; + } + if (!$isMarker) continue; + $body = trim(implode("\n", array_slice($lines, 0, $i))); + if ($body === '') return [$text, '']; + return [$body, trim(implode("\n", array_slice($lines, $i)))]; + } + return [$text, '']; +} + +/** Bringt ein HTML-Bruchstück in Ordnung (offene Tags schließen, überzählige schließende Tags entfernen). */ +function mail_balance_html(string $html): string +{ + if (trim($html) === '' || !class_exists('DOMDocument')) return $html; + $prev = libxml_use_internal_errors(true); + $dom = new DOMDocument(); + $ok = $dom->loadHTML('
' . $html . '
', LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_NONET | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + libxml_clear_errors(); + libxml_use_internal_errors($prev); + if (!$ok) return $html; + $root = $dom->getElementById('dh-root'); + if (!$root) return $html; + $out = ''; + foreach ($root->childNodes as $child) $out .= $dom->saveHTML($child); + return $out; +} + +/** + * Trennt bereinigtes Mail-HTML in den neuen Teil und den zitierten Verlauf. Gesucht wird die Trennlinie (
) oder der Block mit + * oberem Rahmen, den Outlook vor dem zitierten Mailkopf setzt, und danach der Mailkopf mit mindestens drei Angaben (Von, Gesendet, An …). + * + * @return array{0:string,1:string} + */ +function mail_split_quoted_html(string $html): array +{ + if (!preg_match_all('/]*>|]*border-top[^>]*>/i', $html, $matches, PREG_OFFSET_CAPTURE)) return [$html, '']; + foreach ($matches[0] as [$tag, $offset]) { + $after = html_entity_decode(preg_replace('/<[^>]*>/', ' ', substr($html, $offset, 1500)) ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $after = preg_replace('/\s+/u', ' ', $after) ?? $after; + preg_match_all('/(?:^|\s)(' . MAIL_QUOTE_HEADER_KEYS . ')\s*:/iu', mb_substr($after, 0, 600), $m); + $keys = array_map('mb_strtolower', $m[1] ?? []); + if (count($keys) < 3 || !(in_array('von', $keys, true) || in_array('from', $keys, true))) continue; + $head = substr($html, 0, $offset); + if (trim(strip_tags($head)) === '' && stripos($head, '
'; +} + /** * Bester Text einer Mail: bevorzugt text/plain; ist der nur eine kurze Floskel ("Bitte HTML-Ansicht verwenden") und die HTML-Fassung * deutlich länger, wird die HTML-Fassung als Text genommen. diff --git a/DeskHub-Webseite/includes/mail_threading.php b/DeskHub-Webseite/includes/mail_threading.php new file mode 100644 index 00000000..0185503c --- /dev/null +++ b/DeskHub-Webseite/includes/mail_threading.php @@ -0,0 +1,104 @@ +), die Mailprogramme in der Antwort mitschicken. Außerdem zählen die Message-IDs früher + * abgerufener Mails des Tickets. + * Bei 2 und 3 (die nicht schon durch die DeskHub-Nummer im Betreff gesichert sind) muss der Absender zum Ticket gehören + * (Auftraggeber oder früherer Verfasser), damit niemand mit einer geratenen Nummer fremden Tickets Nachrichten unterschieben kann. + */ + +/** Message-IDs aus In-Reply-To und References, klein und ohne spitze Klammern. @return string[] */ +function mail_reference_ids(string $rawHeader): array +{ + $ids = []; + // Kopfzeilen können gefaltet sein (Fortsetzungszeilen beginnen mit Leerraum) + $unfolded = preg_replace('/\r?\n[ \t]+/', ' ', $rawHeader) ?? $rawHeader; + foreach (['In-Reply-To', 'References'] as $name) { + if (preg_match_all('/^' . $name . ':(.*)$/mi', $unfolded, $m)) { + foreach ($m[1] as $value) { + if (preg_match_all('/<([^<>\s]+)>/', $value, $mm)) foreach ($mm[1] as $id) $ids[] = strtolower(trim($id)); + } + } + } + return array_values(array_unique($ids)); +} + +/** Ticketnummer aus einer von DeskHub erzeugten Message-ID () oder null. */ +function mail_ticket_number_from_message_id(string $id): ?string +{ + return preg_match('/^deskhub\.([a-z0-9-]{4,24})\.[a-z0-9]+@/i', $id, $m) ? $m[1] : null; +} + +/** Message-ID für eine ausgehende Mail zu einem Ticket (Nummer steht im Betreff als [#NUMMER]); null, wenn der Betreff keine Nummer enthält. */ +function mail_ticket_message_id(string $subject, string $host): ?string +{ + if (!preg_match('/\[#([A-Za-z0-9-]{4,24})\]/', $subject, $m)) return null; + $host = preg_replace('/[^A-Za-z0-9.-]/', '', $host) ?: 'localhost'; + return ''; +} + +/** Gehört der Absender zum Ticket (Auftraggeber oder früherer Verfasser einer Kundennachricht)? */ +function mail_sender_belongs_to_ticket(PDO $pdo, array $ticket, string $fromEmail): bool +{ + $from = strtolower(trim($fromEmail)); + if ($from === '') return false; + if (strtolower(trim((string) ($ticket['requester_email'] ?? ''))) === $from) return true; + try { + $stmt = $pdo->prepare("SELECT 1 FROM ticket_threads tt JOIN users u ON u.id = tt.user_id WHERE tt.ticket_id = ? AND tt.author_type = 'customer' AND LOWER(u.email) = ? LIMIT 1"); + $stmt->execute([(int) $ticket['id'], $from]); + return (bool) $stmt->fetchColumn(); + } catch (Throwable $e) { + return false; + } +} + +/** + * Sucht das Ticket zu einer eingehenden Mail. + * + * @return array{ticket:array,via:string}|null via: subject (DeskHub-Nummer), legacy (andere Nummer im Betreff), header (In-Reply-To/References) + */ +function mail_find_ticket_for_reply(PDO $pdo, string $subject, string $rawHeader, string $fromEmail): ?array +{ + $byNumber = $pdo->prepare('SELECT * FROM tickets WHERE LOWER(ticket_number) = LOWER(?)'); + + // 1. DeskHub-Nummer im Betreff wie bisher + if (preg_match('/\[#([0-9]{6}-[0-9]{4})\]/', $subject, $m)) { + $byNumber->execute([$m[1]]); + if ($t = $byNumber->fetch()) return ['ticket' => $t, 'via' => 'subject']; + } + // 2. Andere Nummer im Betreff (genau diese Nummer muss es als Ticket geben) + if (preg_match_all('/\[#([A-Za-z0-9-]{4,24})\]/', $subject, $all)) { + foreach ($all[1] as $num) { + $byNumber->execute([$num]); + if (($t = $byNumber->fetch()) && mail_sender_belongs_to_ticket($pdo, $t, $fromEmail)) return ['ticket' => $t, 'via' => 'legacy']; + } + } + // 3. Kopfzeilen + $ids = mail_reference_ids($rawHeader); + foreach ($ids as $id) { + $num = mail_ticket_number_from_message_id($id); + if ($num !== null) { + $byNumber->execute([$num]); + if (($t = $byNumber->fetch()) && mail_sender_belongs_to_ticket($pdo, $t, $fromEmail)) return ['ticket' => $t, 'via' => 'header']; + } + } + if ($ids) { + // Message-IDs früher abgerufener Mails des Tickets (gespeichert mit oder ohne spitze Klammern): grob in SQL vorfiltern, genau in PHP prüfen + $ids = array_slice($ids, 0, 15); + $likes = implode(' OR ', array_fill(0, count($ids), 'LOWER(message_id) LIKE ?')); + $stmt = $pdo->prepare("SELECT ticket_id, message_id FROM ticket_email_sources WHERE message_id IS NOT NULL AND ({$likes}) ORDER BY id DESC LIMIT 50"); + $stmt->execute(array_map(fn($id) => '%' . str_replace(['%', '_'], ['\%', '\_'], $id) . '%', $ids)); + foreach ($stmt->fetchAll() as $row) { + if (!in_array(strtolower(trim((string) $row['message_id'], '<> ')), $ids, true)) continue; + $byId = $pdo->prepare('SELECT * FROM tickets WHERE id = ?'); + $byId->execute([(int) $row['ticket_id']]); + if (($t = $byId->fetch()) && mail_sender_belongs_to_ticket($pdo, $t, $fromEmail)) return ['ticket' => $t, 'via' => 'header']; + } + } + return null; +} diff --git a/DeskHub-Webseite/includes/mailer.php b/DeskHub-Webseite/includes/mailer.php index 93d81977..27ebc006 100644 --- a/DeskHub-Webseite/includes/mailer.php +++ b/DeskHub-Webseite/includes/mailer.php @@ -97,6 +97,8 @@ function send_mail_now(string $to, string $subject, string $htmlBody, ?string $f $headers .= "X-DeskHub-Mail: 1\r\n"; // erkennt der E-Mail-Abruf wieder und macht daraus kein Ticket $headers .= "Content-type: text/html; charset=UTF-8\r\n"; $headers .= "From: {$fromHeader}\r\n"; + $mid = mail_ticket_message_id($subject, preg_match('/@([^>\s]+)>?\s*$/', $fromHeader, $hm) ? $hm[1] : ($_SERVER['SERVER_NAME'] ?? 'localhost')); + if ($mid !== null) $headers .= "Message-ID: {$mid}\r\n"; mail_attempted(true); return @mail($to, $subject, $htmlBody, $headers); @@ -411,7 +413,7 @@ function notify_customer_chat_transcript(array $ticket, array $messages): void // rohes Plain-Text (siehe live_chat.php-Konvention) – für die E-Mail beides einheitlich // auf reinen Text reduzieren, sonst würden HTML-Tags als sichtbarer Text erscheinen. $rawMessage = (string) $m['message']; - $plainMessage = preg_match('/<[a-zA-Z][^>]*>/i', $rawMessage) + $plainMessage = message_is_html($rawMessage) ? trim(html_entity_decode(strip_tags(str_replace(['
', '
', '
', '

'], "\n", $rawMessage)), ENT_QUOTES | ENT_HTML5, 'UTF-8')) : $rawMessage; $rows .= email_chat_transcript_row(e($label), $plainMessage, e($time), $isStaff); diff --git a/DeskHub-Webseite/includes/my_week.php b/DeskHub-Webseite/includes/my_week.php new file mode 100644 index 00000000..d602e876 --- /dev/null +++ b/DeskHub-Webseite/includes/my_week.php @@ -0,0 +1,168 @@ + */ +function my_week_periods(): array +{ + return ['week' => 'Diese Woche', 'lastweek' => 'Letzte Woche', 'month' => 'Dieser Monat', 'lastmonth' => 'Letzter Monat']; +} + +/** + * Zeitraum und der gleich lange Zeitraum davor (zum Vergleichen). + * @return array{from:string,to:string,prev_from:string,prev_to:string,label:string} + */ +function my_week_range(string $key, ?int $now = null): array +{ + $now = $now ?? time(); + $labels = my_week_periods(); + if (!isset($labels[$key])) $key = 'week'; + $monday = strtotime('monday this week', $now); + switch ($key) { + case 'lastweek': + $from = strtotime('-7 days', $monday); + $to = $monday - 1; + break; + case 'month': + $from = strtotime(date('Y-m-01', $now)); + $to = $now; + break; + case 'lastmonth': + $first = strtotime(date('Y-m-01', $now)); + $from = strtotime('-1 month', $first); + $to = $first - 1; + break; + default: + $from = $monday; + $to = $now; + } + // Vergleichszeitraum nach dem Kalender (nicht in Sekunden): Sommer-/Winterzeit verschiebt sonst die Grenze um eine Stunde + if ($key === 'week' || $key === 'lastweek') { + $prevFrom = strtotime('-7 days', $from); + $prevTo = strtotime('-7 days', $to); + } elseif ($key === 'month') { + $prevFrom = strtotime('-1 month', $from); + $prevTo = min(strtotime('-1 month', $to), $from - 1); // gleich weit in den Vormonat, höchstens bis dessen Ende + } else { + $prevFrom = strtotime('-1 month', $from); + $prevTo = $from - 1; + } + $fmt = fn(int $ts) => date('Y-m-d H:i:s', $ts); + return ['from' => $fmt($from), 'to' => $fmt($to), 'prev_from' => $fmt($prevFrom), 'prev_to' => $fmt($prevTo), 'label' => $labels[$key]]; +} + +/** + * Kennzahlen eines Mitarbeiters in einem Zeitraum. + * @return array{resolved:int,replies:int,first_response_seconds:?int,first_response_count:int,csat_avg:?float,csat_count:int,csat_positive:?int,on_time:?int} + */ +function my_week_stats(PDO $pdo, int $staffId, string $from, string $to): array +{ + $one = function (string $sql, array $params) use ($pdo) { + $s = $pdo->prepare($sql); + $s->execute($params); + return $s->fetch(PDO::FETCH_NUM) ?: []; + }; + + // Gelöst/geschlossen und mir zugewiesen; „pünktlich“ = vor Ablauf der Lösungsfrist (oder ohne Frist) + [$resolved, $onTime] = $one( + "SELECT COUNT(*), SUM(CASE WHEN resolution_due_at IS NULL OR COALESCE(resolved_at, closed_at) <= resolution_due_at THEN 1 ELSE 0 END) + FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status IN ('resolved','closed') AND COALESCE(resolved_at, closed_at) BETWEEN ? AND ?", + [$staffId, $from, $to] + ) + [0, 0]; + + [$replies] = $one( + "SELECT COUNT(*) FROM ticket_threads WHERE staff_id = ? AND author_type = 'staff' AND is_internal_note = 0 AND created_at BETWEEN ? AND ?", + [$staffId, $from, $to] + ) + [0]; + + // Zeit bis zur ersten Antwort: nur Tickets, deren erste öffentliche Antwort von mir stammt (Arbeitszeit, wenn Geschäftszeiten eingerichtet sind) + $st = $pdo->prepare( + "SELECT t.created_at, t.first_responded_at FROM tickets t + WHERE t.is_spam = 0 AND t.first_responded_at BETWEEN ? AND ? + AND (SELECT th.staff_id FROM ticket_threads th WHERE th.ticket_id = t.id AND th.author_type = 'staff' AND th.is_internal_note = 0 ORDER BY th.created_at, th.id LIMIT 1) = ? + ORDER BY t.id DESC LIMIT 500" + ); + $st->execute([$from, $to, $staffId]); + $sum = 0; $n = 0; + foreach ($st->fetchAll() as $r) { + if (empty($r['created_at']) || empty($r['first_responded_at'])) continue; + $sum += ticket_sla_working_seconds_between((string) $r['created_at'], (string) $r['first_responded_at']); + $n++; + } + + [$csatN, $csatAvg, $csatGood] = $one( + "SELECT COUNT(*), AVG(rating), SUM(CASE WHEN rating >= 4 THEN 1 ELSE 0 END) FROM ticket_csat + WHERE staff_id = ? AND rating IS NOT NULL AND answered_at BETWEEN ? AND ?", + [$staffId, $from, $to] + ) + [0, null, 0]; + + return [ + 'resolved' => (int) $resolved, + 'replies' => (int) $replies, + 'first_response_seconds' => $n > 0 ? (int) round($sum / $n) : null, + 'first_response_count' => $n, + 'csat_avg' => (int) $csatN > 0 ? round((float) $csatAvg, 2) : null, + 'csat_count' => (int) $csatN, + 'csat_positive' => (int) $csatN > 0 ? (int) round(100 * (int) $csatGood / (int) $csatN) : null, + 'on_time' => (int) $resolved > 0 ? (int) round(100 * (int) $onTime / (int) $resolved) : null, + ]; +} + +/** Was jetzt offen ist (unabhängig vom Zeitraum). @return array{open:int,overdue:int,waiting:int} */ +function my_week_now(PDO $pdo, int $staffId, ?string $now = null): array +{ + $now = $now ?? db_now(); + $count = function (string $extra, array $params = []) use ($pdo, $staffId): int { + $s = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status NOT IN ('resolved','closed'){$extra}"); + $s->execute(array_merge([$staffId], $params)); + return (int) $s->fetchColumn(); + }; + return [ + 'open' => $count(''), + 'overdue' => $count(' AND resolution_due_at IS NOT NULL AND resolution_due_at < ?', [$now]), + 'waiting' => $count(" AND status = 'open'"), + ]; +} + +/** Gelöste Tickets je Tag im Zeitraum (für das Balkendiagramm). @return array Datum => Anzahl */ +function my_week_per_day(PDO $pdo, int $staffId, string $from, string $to): array +{ + $out = []; + for ($d = strtotime(date('Y-m-d', strtotime($from))); $d <= strtotime($to); $d = strtotime('+1 day', $d)) $out[date('Y-m-d', $d)] = 0; + $s = $pdo->prepare("SELECT COALESCE(resolved_at, closed_at) AS at FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status IN ('resolved','closed') AND COALESCE(resolved_at, closed_at) BETWEEN ? AND ?"); + $s->execute([$staffId, $from, $to]); + foreach ($s->fetchAll(PDO::FETCH_COLUMN) as $at) { + $day = date('Y-m-d', strtotime((string) $at)); + if (isset($out[$day])) $out[$day]++; + } + return count($out) > 62 ? array_slice($out, -62, null, true) : $out; +} + +/** Dauer kurz und lesbar: „2 Std 15 Min“, „40 Min“, „–“. */ +function my_week_fmt_duration(?int $seconds): string +{ + if ($seconds === null) return '–'; + if ($seconds < 60) return 'unter 1 Min'; + $m = (int) round($seconds / 60); + if ($m < 60) return $m . ' Min'; + $h = intdiv($m, 60); + $rest = $m % 60; + if ($h >= 48) return round($h / 24) . ' Tage'; + return $h . ' Std' . ($rest ? ' ' . $rest . ' Min' : ''); +} + +/** + * Veränderung zum Zeitraum davor als Text und Richtung (+1 besser, -1 schlechter, 0 gleich/unbekannt). + * @return array{text:string,trend:int} + */ +function my_week_delta(?float $now, ?float $before, bool $lowerIsBetter = false, string $unit = ''): array +{ + if ($now === null || $before === null) return ['text' => 'kein Vergleich', 'trend' => 0]; + $diff = $now - $before; + if (abs($diff) < 0.005) return ['text' => 'wie davor', 'trend' => 0]; + $good = $lowerIsBetter ? $diff < 0 : $diff > 0; + $num = abs($diff) >= 10 || floor(abs($diff)) === abs($diff) ? (string) round(abs($diff)) : number_format(abs($diff), 1, ',', ''); + return ['text' => ($diff > 0 ? '▲ ' : '▼ ') . $num . $unit . ' zum Zeitraum davor', 'trend' => $good ? 1 : -1]; +} diff --git a/DeskHub-Webseite/includes/pseudo_cron.php b/DeskHub-Webseite/includes/pseudo_cron.php index 3c69bf81..10aea89f 100644 --- a/DeskHub-Webseite/includes/pseudo_cron.php +++ b/DeskHub-Webseite/includes/pseudo_cron.php @@ -43,6 +43,8 @@ function pseudo_cron_job_definitions(): array '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'], + 'send_scheduled_replies' => ['setting' => 'cron_send_scheduled_replies_last_run', 'interval' => 60, 'file' => 'send_scheduled_replies.php', 'fn' => 'run_send_scheduled_replies'], + 'send_report_schedules' => ['setting' => 'cron_send_report_schedules_last_run', 'interval' => 300, 'file' => 'send_report_schedules.php', 'fn' => 'run_send_report_schedules'], 'run_plugins' => ['setting' => 'cron_run_plugins_last_run', 'interval' => 60, 'file' => 'run_plugins.php', 'fn' => 'run_plugins_cron'], ]; } diff --git a/DeskHub-Webseite/includes/report_schedules.php b/DeskHub-Webseite/includes/report_schedules.php new file mode 100644 index 00000000..d0e0328e --- /dev/null +++ b/DeskHub-Webseite/includes/report_schedules.php @@ -0,0 +1,218 @@ + */ +function report_schedule_periods(): array +{ + return [ + 'yesterday' => 'Gestern', + 'last_7' => 'Letzte 7 Tage', + 'last_week' => 'Letzte Kalenderwoche (Mo–So)', + 'last_30' => 'Letzte 30 Tage', + 'last_month' => 'Letzter Kalendermonat', + ]; +} + +/** @return array */ +function report_schedule_frequencies(): array +{ + return ['daily' => 'Täglich', 'weekly' => 'Wöchentlich', 'monthly' => 'Monatlich']; +} + +/** @return array */ +function report_schedule_weekdays(): array +{ + return [1 => 'Montag', 2 => 'Dienstag', 3 => 'Mittwoch', 4 => 'Donnerstag', 5 => 'Freitag', 6 => 'Samstag', 7 => 'Sonntag']; +} + +/** Zeitraum (von, bis; beide Y-m-d) für einen relativen Zeitraum, bezogen auf $now. */ +function report_period_range(string $period, ?int $now = null): array +{ + $now = $now ?? time(); + $day = fn(string $mod) => date('Y-m-d', strtotime($mod, $now)); + switch ($period) { + case 'yesterday': return [$day('-1 day'), $day('-1 day')]; + case 'last_week': + $monday = strtotime('monday this week', $now); + return [date('Y-m-d', strtotime('-7 days', $monday)), date('Y-m-d', strtotime('-1 day', $monday))]; + case 'last_30': return [$day('-30 days'), $day('-1 day')]; + case 'last_month': + $first = strtotime(date('Y-m-01', $now)); + return [date('Y-m-d', strtotime('-1 month', $first)), date('Y-m-d', strtotime('-1 day', $first))]; + case 'last_7': + default: return [$day('-7 days'), $day('-1 day')]; + } +} + +/** Nächster Sendezeitpunkt (Y-m-d H:i:s) nach $after für den Zeitplan. */ +function report_schedule_next(array $s, ?int $after = null): string +{ + $after = $after ?? time(); + $time = preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', (string) ($s['send_time'] ?? '')) ? (string) $s['send_time'] : '08:00'; + $freq = (string) ($s['frequency'] ?? 'weekly'); + $weekday = max(1, min(7, (int) ($s['weekday'] ?? 1))); + $monthday = max(1, min(28, (int) ($s['monthday'] ?? 1))); + for ($i = 0; $i <= 62; $i++) { + $dayTs = strtotime("+{$i} days", strtotime(date('Y-m-d', $after))); + if ($freq === 'weekly' && (int) date('N', $dayTs) !== $weekday) continue; + if ($freq === 'monthly' && (int) date('j', $dayTs) !== $monthday) continue; + if ($freq === 'daily' && !empty($s['weekdays_only']) && (int) date('N', $dayTs) > 5) continue; + $candidate = strtotime(date('Y-m-d', $dayTs) . ' ' . $time . ':00'); + if ($candidate !== false && $candidate > $after) return date('Y-m-d H:i:s', $candidate); + } + return date('Y-m-d H:i:s', $after + 86400); +} + +/** + * Prüft die Empfängerliste (Komma, Semikolon oder Zeilenumbruch getrennt). Nicht-Administratoren dürfen nur an aktive Mitarbeiter senden. + * @return array{0:list,1:?string} [Adressen, Fehlermeldung] + */ +function report_schedule_recipients(PDO $pdo, string $input, array $staff): array +{ + $list = []; + foreach (preg_split('/[\s,;]+/', trim($input), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $addr) { + $addr = strtolower(trim($addr)); + if (!filter_var($addr, FILTER_VALIDATE_EMAIL)) return [[], 'Ungültige E-Mail-Adresse: ' . $addr]; + $list[$addr] = $addr; + } + $list = array_values($list); + if (!$list) return [[], 'Bitte mindestens einen Empfänger angeben.']; + if (count($list) > REPORT_SCHEDULE_MAX_RECIPIENTS) return [[], 'Höchstens ' . REPORT_SCHEDULE_MAX_RECIPIENTS . ' Empfänger je Zeitplan.']; + if (($staff['role'] ?? '') !== 'admin') { + $known = array_map('strtolower', $pdo->query('SELECT email FROM staff WHERE is_active = 1')->fetchAll(PDO::FETCH_COLUMN)); + foreach ($list as $addr) { + if (!in_array($addr, $known, true)) return [[], 'Nur Administratoren dürfen Berichte an Adressen außerhalb der Mitarbeiter senden: ' . $addr]; + } + } + return [$list, null]; +} + +/** Legt einen Zeitplan an. Gibt eine Fehlermeldung zurück oder null. */ +function report_schedule_create(PDO $pdo, array $staff, array $in): ?string +{ + $stmt = $pdo->prepare('SELECT * FROM saved_reports WHERE id = ?'); + $stmt->execute([(int) ($in['report_id'] ?? 0)]); + $report = $stmt->fetch(); + if (!$report || ((int) $report['staff_id'] !== (int) $staff['id'] && !(int) $report['is_shared'] && ($staff['role'] ?? '') !== 'admin')) return 'Bericht nicht gefunden.'; + [$recipients, $err] = report_schedule_recipients($pdo, (string) ($in['recipients'] ?? ''), $staff); + if ($err) return $err; + $freq = isset(report_schedule_frequencies()[$in['frequency'] ?? '']) ? (string) $in['frequency'] : 'weekly'; + $period = isset(report_schedule_periods()[$in['period'] ?? '']) ? (string) $in['period'] : 'last_7'; + $time = preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', (string) ($in['send_time'] ?? '')) ? (string) $in['send_time'] : '08:00'; + $row = [ + 'frequency' => $freq, 'weekday' => max(1, min(7, (int) ($in['weekday'] ?? 1))), 'monthday' => max(1, min(28, (int) ($in['monthday'] ?? 1))), + 'send_time' => $time, 'weekdays_only' => !empty($in['weekdays_only']) ? 1 : 0, + ]; + // Weniger Mails: Standard ist „nur bei Daten“ und „nur bei Änderung“ (ein leeres Kontrollkästchen im Formular schaltet es ab, siehe „quiet_form“) + $onlyData = !empty($in['quiet_form']) ? (!empty($in['only_if_data']) ? 1 : 0) : 1; + $skipSame = !empty($in['quiet_form']) ? (!empty($in['skip_unchanged']) ? 1 : 0) : 1; + $pdo->prepare('INSERT INTO report_schedules (report_id, staff_id, recipients, frequency, weekday, monthday, send_time, period, enabled, next_run_at, created_at, only_if_data, skip_unchanged, weekdays_only) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)') + ->execute([(int) $report['id'], (int) $staff['id'], implode(', ', $recipients), $freq, $row['weekday'], $row['monthday'], $time, $period, 1, report_schedule_next($row), db_now(), $onlyData, $skipSame, $row['weekdays_only']]); + return null; +} + +/** Baut Betreff und HTML-Text für einen Zeitplan. @return array{0:string,1:string,2:int,3:string}|null [Betreff, HTML, Zeilen, Prüfsumme] oder null, wenn der Bericht fehlt. */ +function report_schedule_render(PDO $pdo, array $schedule, ?int $now = null): ?array +{ + $stmt = $pdo->prepare('SELECT * FROM saved_reports WHERE id = ?'); + $stmt->execute([(int) $schedule['report_id']]); + $saved = $stmt->fetch(); + if (!$saved) return null; + $owner = $pdo->prepare('SELECT * FROM staff WHERE id = ?'); + $owner->execute([(int) $schedule['staff_id']]); + $ownerRow = $owner->fetch(); + if (!$ownerRow || (isset($ownerRow['is_active']) && (int) $ownerRow['is_active'] === 0)) return null; + + [$from, $to] = report_period_range((string) $schedule['period'], $now); + $cfgIn = json_decode((string) $saved['config'], true) ?: []; + $cfgIn['from'] = $from; + $cfgIn['to'] = $to; + $cfg = report_clean_config($pdo, $cfgIn); + $report = report_run($pdo, $cfg, staff_department_ids($ownerRow)); + + $fmt = function ($v, string $unit): string { + if ($v === null || $v === '') return '–'; + if ($unit === '%' || $unit === 'Std') return number_format((float) $v, 1, ',', '.') . ($unit === '%' ? ' %' : ' Std'); + return (string) $v; + }; + $th = 'style="text-align:left;padding:6px 10px;border-bottom:2px solid #cbd5e1;background:#f1f5f9;font-size:13px"'; + $td = 'style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-size:13px"'; + $html = '

' . e($saved['name']) . '

' + . '

Zeitraum: ' . e(date('d.m.Y', strtotime($from))) . ' bis ' . e(date('d.m.Y', strtotime($to))) . '

' + . ''; + foreach ($report['columns'] as $c) $html .= ''; + $html .= ''; + foreach ($report['rows'] as $row) { + $html .= ''; + foreach (array_slice($report['columns'], 1) as $c) $html .= ''; + $html .= ''; + } + if (!$report['rows']) { + $html .= ''; + } else { + $html .= ''; + foreach (array_slice($report['columns'], 1) as $c) $html .= ''; + $html .= ''; + } + $html .= '
' . e($c['label']) . '
' . e((string) $row['label']) . '' . e($fmt($row[$c['key']] ?? null, (string) $c['unit'])) . '
Keine Tickets im Zeitraum.
Gesamt' . e($fmt($report['totals'][$c['key']] ?? null, (string) $c['unit'])) . '

Automatisch versendet von DeskHub (' . e(strtolower(report_schedule_frequencies()[$schedule['frequency']] ?? '')) . ').

'; + if (function_exists('email_wrap')) $html = email_wrap($html); + $subject = 'DeskHub-Bericht: ' . $saved['name'] . ' (' . date('d.m.', strtotime($from)) . '–' . date('d.m.Y', strtotime($to)) . ')'; + // Prüfsumme nur der Zahlen (ohne Zeitraum): gleiche Zahlen wie beim letzten Versand = „nichts Neues“ + $hash = sha1(json_encode([$report['columns'], $report['rows'], $report['totals']], JSON_UNESCAPED_UNICODE) ?: ''); + return [$subject, $html, count($report['rows']), $hash]; +} + +/** + * Sendet einen Zeitplan. Mit $respectQuiet = true (automatischer Lauf) wird nichts gesendet, wenn der Bericht leer ist („nur bei Daten“) + * oder dieselben Zahlen enthält wie beim letzten Versand („nur bei Änderung“); „Test senden“ sendet immer. + * @return array{sent:int,error:?string,skipped:?string,hash:?string} + */ +function report_schedule_send(PDO $pdo, array $schedule, bool $respectQuiet = false): array +{ + $rendered = report_schedule_render($pdo, $schedule); + if ($rendered === null) return ['sent' => 0, 'error' => 'Bericht oder Einrichter existiert nicht mehr oder ist deaktiviert.', 'skipped' => null, 'hash' => null]; + [$subject, $html, $rows, $hash] = $rendered; + if ($respectQuiet) { + if (!empty($schedule['only_if_data']) && $rows === 0) return ['sent' => 0, 'error' => null, 'skipped' => 'Übersprungen: keine Daten im Zeitraum', 'hash' => $hash]; + if (!empty($schedule['skip_unchanged']) && !empty($schedule['last_hash']) && hash_equals((string) $schedule['last_hash'], $hash)) { + return ['sent' => 0, 'error' => null, 'skipped' => 'Übersprungen: keine Änderung seit dem letzten Versand', 'hash' => $hash]; + } + } + $sent = 0; + foreach (preg_split('/[\s,;]+/', (string) $schedule['recipients'], -1, PREG_SPLIT_NO_EMPTY) ?: [] as $to) { + if (send_mail($to, $subject, $html)) $sent++; + } + return ['sent' => $sent, 'error' => $sent === 0 ? 'Kein Empfänger erreicht (E-Mail-Versand prüfen).' : null, 'skipped' => null, 'hash' => $hash]; +} + +/** Sendet alle fälligen Zeitpläne. @return array{sent:int,failed:int} */ +function report_schedules_run_due(PDO $pdo): array +{ + $now = db_now(); + $stmt = $pdo->prepare('SELECT * FROM report_schedules WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ? ORDER BY next_run_at LIMIT 50'); + $stmt->execute([$now]); + $ok = 0; $bad = 0; + foreach ($stmt->fetchAll() as $s) { + // Zeitplan atomar weiterschalten: nur ein Lauf darf senden, auch bei gleichzeitigen Anfragen + $next = report_schedule_next($s); + $claim = $pdo->prepare('UPDATE report_schedules SET next_run_at = ? WHERE id = ? AND next_run_at = ?'); + $claim->execute([$next, (int) $s['id'], $s['next_run_at']]); + if ($claim->rowCount() === 0) continue; + try { + $r = report_schedule_send($pdo, $s, true); + } catch (Throwable $e) { + $r = ['sent' => 0, 'error' => $e->getMessage(), 'skipped' => null, 'hash' => null]; + } + $pdo->prepare('UPDATE report_schedules SET last_sent_at = ?, last_error = ?, last_note = ?, last_hash = ? WHERE id = ?') + ->execute([$r['sent'] > 0 ? $now : $s['last_sent_at'], $r['error'], $r['skipped'], $r['sent'] > 0 ? $r['hash'] : ($s['last_hash'] ?? null), (int) $s['id']]); + if ($r['error'] !== null) $bad++; + elseif ($r['skipped'] === null) $ok++; + } + return ['sent' => $ok, 'failed' => $bad]; +} diff --git a/DeskHub-Webseite/includes/scheduled_replies.php b/DeskHub-Webseite/includes/scheduled_replies.php new file mode 100644 index 00000000..aa7d864d --- /dev/null +++ b/DeskHub-Webseite/includes/scheduled_replies.php @@ -0,0 +1,135 @@ + time() + SCHEDULED_REPLY_MAX_DAYS * 86400) return 'Der Sendezeitpunkt darf höchstens ' . SCHEDULED_REPLY_MAX_DAYS . ' Tage entfernt liegen.'; + $status = (string) ($opts['set_status'] ?? 'answered'); + if (!in_array($status, ['answered', 'pending', 'resolved', 'closed'], true)) $status = 'answered'; + $code = (string) ($opts['resolution_code'] ?? ''); + if (in_array($status, ['resolved', 'closed'], true) && !isset(ticket_resolution_reasons()[$code])) return 'Bitte beim Schließen einen Abschlussgrund auswählen.'; + $pdo->prepare( + 'INSERT INTO scheduled_replies (ticket_id, staff_id, message, set_status, resolution_code, resolution_note, signature_choice, send_at, state, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?)' + )->execute([ + (int) $ticket['id'], $staffId, $message, $status, + in_array($status, ['resolved', 'closed'], true) ? $code : null, + trim(mb_substr((string) ($opts['resolution_note'] ?? ''), 0, 2000)) ?: null, + isset($opts['signature_choice']) ? (string) $opts['signature_choice'] : null, + $sendAt, 'pending', db_now(), + ]); + return null; +} + +/** @return list> Ausstehende geplante Antworten eines Tickets. */ +function scheduled_replies_pending(PDO $pdo, int $ticketId): array +{ + $stmt = $pdo->prepare("SELECT r.*, s.name AS staff_name FROM scheduled_replies r LEFT JOIN staff s ON s.id = r.staff_id WHERE r.ticket_id = ? AND r.state = 'pending' ORDER BY r.send_at"); + $stmt->execute([$ticketId]); + return $stmt->fetchAll(); +} + +/** Bricht eine geplante Antwort ab (Verfasser oder Administrator). */ +function scheduled_reply_cancel(PDO $pdo, int $id, int $ticketId, array $staff): bool +{ + $stmt = $pdo->prepare("UPDATE scheduled_replies SET state = 'cancelled' WHERE id = ? AND ticket_id = ? AND state = 'pending' AND (staff_id = ? OR ? = 'admin')"); + $stmt->execute([$id, $ticketId, (int) $staff['id'], (string) ($staff['role'] ?? '')]); + return $stmt->rowCount() > 0; +} + +/** + * Sendet eine fällige geplante Antwort: legt die Nachricht im Ticket an, setzt den Status, benachrichtigt den Kunden und die Beobachter. + * Gibt true bei Erfolg zurück; bei Fehlern steht der Grund in der Zeile (state = failed). + */ +function scheduled_reply_deliver(PDO $pdo, array $row): bool +{ + // Atomar übernehmen: nur ein Lauf darf die Antwort senden + $claim = $pdo->prepare("UPDATE scheduled_replies SET state = 'sending' WHERE id = ? AND state = 'pending'"); + $claim->execute([(int) $row['id']]); + if ($claim->rowCount() === 0) return false; + $fail = function (string $why) use ($pdo, $row): bool { + $pdo->prepare("UPDATE scheduled_replies SET state = 'failed', error = ? WHERE id = ?")->execute([mb_substr($why, 0, 500), (int) $row['id']]); + return false; + }; + try { + $t = $pdo->prepare('SELECT * FROM tickets WHERE id = ?'); + $t->execute([(int) $row['ticket_id']]); + $ticket = $t->fetch(); + if (!$ticket) return $fail('Das Ticket existiert nicht mehr.'); + $s = $pdo->prepare('SELECT * FROM staff WHERE id = ?'); + $s->execute([(int) $row['staff_id']]); + $staff = $s->fetch(); + if (!$staff || (isset($staff['is_active']) && (int) $staff['is_active'] === 0)) return $fail('Der Verfasser ist nicht mehr aktiv.'); + + $now = db_now(); + $message = (string) $row['message']; + $newStatus = (string) $row['set_status']; + $pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,?,?)') + ->execute([$ticket['id'], 'staff', $staff['id'], $message, 0, $now]); + $threadId = (int) $pdo->lastInsertId(); + + $terminal = in_array($newStatus, ['resolved', 'closed'], true); + $resolvedAt = $newStatus === 'resolved' && empty($ticket['resolved_at']) ? $now : $ticket['resolved_at']; + $closedAt = $newStatus === 'closed' && empty($ticket['closed_at']) ? $now : $ticket['closed_at']; + $pdo->prepare('UPDATE tickets SET status = ?, updated_at = ?, first_responded_at = COALESCE(first_responded_at, ?), assigned_staff_id = COALESCE(assigned_staff_id, ?), resolved_at = ?, closed_at = ?, resolution_code = ?, resolution_note = ? WHERE id = ?') + ->execute([ + $newStatus, $now, $now, (int) $staff['id'], $resolvedAt, $closedAt, + $terminal ? $row['resolution_code'] : $ticket['resolution_code'], + $terminal ? ($row['resolution_note'] !== null && $row['resolution_note'] !== '' ? $row['resolution_note'] : $ticket['resolution_note']) : $ticket['resolution_note'], + $ticket['id'], + ]); + if (function_exists('ticket_sla_status_transition')) ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus, (int) $staff['id']); + + $signature = resolve_reply_signature($pdo, $staff, (int) $ticket['department_id'], $row['signature_choice'] !== null ? (string) $row['signature_choice'] : null); + $collab = $pdo->prepare('SELECT email FROM ticket_collaborators WHERE ticket_id = ?'); + $collab->execute([$ticket['id']]); + $ccEmails = array_column($collab->fetchAll(), 'email'); + notify_customer_new_reply($ticket, $message, $signature, $ccEmails); + save_mentions($pdo, $ticket['id'], $threadId, $staff['id'], $message, $ticket); + log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Geplante Antwort gesendet (eingeplant am ' . format_dt((string) $row['created_at']) . ')'); + if (file_exists(__DIR__ . '/push.php')) { + require_once __DIR__ . '/push.php'; + if (function_exists('push_new_reply')) push_new_reply($ticket, $staff['name']); + } + ticket_followers_notify($ticket, 'Antwort von ' . $staff['name'], (int) $staff['id']); + webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'staff', 'internal' => false, 'scheduled' => true]); + + $pdo->prepare("UPDATE scheduled_replies SET state = 'sent', sent_at = ? WHERE id = ?")->execute([$now, (int) $row['id']]); + return true; + } catch (Throwable $e) { + return $fail($e->getMessage()); + } +} + +/** Sendet alle fälligen geplanten Antworten. @return array{sent:int,failed:int} */ +function scheduled_replies_run_due(PDO $pdo, int $limit = 50): array +{ + $stmt = $pdo->prepare("SELECT * FROM scheduled_replies WHERE state = 'pending' AND send_at <= ? ORDER BY send_at LIMIT " . (int) $limit); + $stmt->execute([db_now()]); + $sent = 0; $failed = 0; + foreach ($stmt->fetchAll() as $row) { + scheduled_reply_deliver($pdo, $row) ? $sent++ : $failed++; + } + return ['sent' => $sent, 'failed' => $failed]; +} diff --git a/DeskHub-Webseite/includes/smtp_mailer.php b/DeskHub-Webseite/includes/smtp_mailer.php index 981b2d5d..858f4109 100644 --- a/DeskHub-Webseite/includes/smtp_mailer.php +++ b/DeskHub-Webseite/includes/smtp_mailer.php @@ -136,6 +136,10 @@ class SmtpMailer $headers[] = "Subject: {$encodedSubject}"; $headers[] = "MIME-Version: 1.0"; $headers[] = "X-DeskHub-Mail: 1"; // erkennt der E-Mail-Abruf wieder und macht daraus kein Ticket + // Mails zu einem Ticket bekommen eine Message-ID mit der Ticketnummer: Antwortet der Kunde, nennt sein Mailprogramm sie in + // In-Reply-To/References, und der E-Mail-Abruf ordnet die Antwort auch dann dem Ticket zu, wenn der Betreff geändert wurde. + $mid = function_exists('mail_ticket_message_id') ? mail_ticket_message_id($subject, preg_match('/@([^>\s]+)>?\s*$/', $from, $hm) ? $hm[1] : ($_SERVER['SERVER_NAME'] ?? 'localhost')) : null; + if ($mid !== null) $headers[] = "Message-ID: {$mid}"; $headers[] = "Content-Type: text/html; charset=UTF-8"; $headers[] = "Content-Transfer-Encoding: base64"; return implode("\r\n", $headers) . "\r\n"; diff --git a/DeskHub-Webseite/includes/status_page.php b/DeskHub-Webseite/includes/status_page.php index fd21e0c7..3d45a90b 100644 --- a/DeskHub-Webseite/includes/status_page.php +++ b/DeskHub-Webseite/includes/status_page.php @@ -171,6 +171,53 @@ function status_execute_component_check(PDO $pdo, array $check): array return ['ok' => $ok, 'error' => mb_substr($error, 0, 255), 'ms' => (int) round((microtime(true) - $started) * 1000)]; } +/** Sollen Administratoren bei Ausfall und Wiederkehr eines Dienstes benachrichtigt werden? (Standard: ja) */ +function status_notify_admins_enabled(): bool +{ + return get_setting('status_notify_admins', '1') === '1'; +} + +/** + * Benachrichtigt die aktiven Administratoren per E-Mail und Push, wenn ein überwachter Dienst ausfällt oder wieder läuft. + * Höchstens eine Meldung je Dienst und Richtung innerhalb von 10 Minuten (kein Dauerfeuer bei einem flackernden Dienst). + * + * @return int Anzahl der angeschriebenen Administratoren + */ +function status_notify_admins(PDO $pdo, array $component, bool $recovered, string $error = ''): int +{ + if (!status_notify_admins_enabled()) return 0; + $key = 'status_notified_' . ($recovered ? 'up_' : 'down_') . (int) $component['id']; + $last = (string) get_setting($key, ''); + if ($last !== '' && strtotime($last) > time() - 600) return 0; + set_setting($key, db_now()); + + $ids = $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN); + $name = (string) $component['name']; + $internal = !empty($component['is_internal']); + $title = $recovered ? "Dienst läuft wieder: {$name}" : "Dienst nicht erreichbar: {$name}"; + $url = base_url() . '/staff/status_page.php'; + $inner = '

' . e($title) . '

' + . '

' . ($recovered ? 'Die automatische Prüfung erreicht den Dienst wieder.' : 'Die automatische Prüfung erreicht den Dienst seit mindestens zwei Prüfungen nicht.') . '

' + . ($error !== '' && !$recovered ? '

Fehler: ' . e($error) . '

' : '') + . ($internal ? '

Dieser Dienst ist intern und steht nicht auf der öffentlichen Statusseite.

' : '') + . '

Statusseite im Mitarbeiterbereich öffnen

'; + $sent = 0; + if (function_exists('staff_emails_by_ids')) { + $emails = staff_emails_by_ids($pdo, function_exists('vacation_expand_ids') ? vacation_expand_ids($pdo, $ids) : $ids); + foreach ($emails as $email) { + email_send_and_log($email, '[DeskHub] ' . $title, email_wrap($inner), null, 'Statusmeldung'); + $sent++; + } + } + if (function_exists('push_to_staff')) { + foreach ($ids as $id) { + try { push_to_staff((int) $id, $title, $recovered ? 'Der Dienst läuft wieder.' : ($error !== '' ? $error : 'Die Prüfung schlägt fehl.'), '/staff/status_page.php'); } catch (Throwable $e) { /* Push ist optional */ } + } + } + try { audit_log(null, mb_substr('Statusseite: ' . $title . ' (Administratoren benachrichtigt)', 0, 250)); } catch (Throwable $e) { /* Protokoll ist optional */ } + return $sent; +} + function status_run_component_checks(PDO $pdo, bool $force = false): int { $components = status_components($pdo); @@ -189,9 +236,11 @@ function status_run_component_checks(PDO $pdo, bool $force = false): int $currentStatus = (string) $component['status']; if ($result['ok'] && $currentStatus !== 'maintenance' && $currentStatus !== 'operational') { status_set_component($pdo, (int) $component['id'], 'operational'); + if ($currentStatus === 'outage') status_notify_admins($pdo, $component, true); $currentStatus = 'operational'; } elseif (!$result['ok'] && $failures >= 2 && $currentStatus !== 'maintenance' && $currentStatus !== 'outage') { status_set_component($pdo, (int) $component['id'], 'outage'); + status_notify_admins($pdo, $component, false, (string) $result['error']); $currentStatus = 'outage'; } $sample->execute([(int) $component['id'], $currentStatus, db_now(), $result['ms']]); diff --git a/DeskHub-Webseite/includes/ticket_attention.php b/DeskHub-Webseite/includes/ticket_attention.php new file mode 100644 index 00000000..c7d9bb28 --- /dev/null +++ b/DeskHub-Webseite/includes/ticket_attention.php @@ -0,0 +1,53 @@ +prepare('UPDATE ticket_acks SET acked_at = ? WHERE staff_id = ? AND ticket_id = ?'); + $upd->execute([$now, $staffId, $ticketId]); + if ($upd->rowCount() === 0) { + $exists = $pdo->prepare('SELECT 1 FROM ticket_acks WHERE staff_id = ? AND ticket_id = ?'); + $exists->execute([$staffId, $ticketId]); + if (!$exists->fetchColumn()) { + $pdo->prepare('INSERT INTO ticket_acks (staff_id, ticket_id, acked_at) VALUES (?,?,?)')->execute([$staffId, $ticketId, $now]); + } + } + } catch (Throwable $e) { + error_log('ticket_ack: ' . $e->getMessage()); + } +} + +/** + * Tickets, die für den Mitarbeiter Aufmerksamkeit brauchen. + * @return array{unassigned:list,mine:list} + */ +function staff_attention_tickets(PDO $pdo, array $staff, int $limit = 300): array +{ + [$deptSql, $deptParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id'); + $snooze = function_exists('ticket_not_snoozed_sql') ? ticket_not_snoozed_sql('t') : '1=1'; + $stmt = $pdo->prepare( + "SELECT t.id, t.assigned_staff_id + FROM tickets t + LEFT JOIN ticket_acks a ON a.ticket_id = t.id AND a.staff_id = ? + WHERE t.status = 'open' AND t.is_spam = 0 AND t.merged_into IS NULL AND t.source <> 'staff' + AND (t.assigned_staff_id IS NULL OR t.assigned_staff_id = ?) + AND {$snooze}{$deptSql} + AND (a.acked_at IS NULL OR a.acked_at < COALESCE((SELECT MAX(th.created_at) FROM ticket_threads th WHERE th.ticket_id = t.id AND th.author_type = 'customer'), t.created_at)) + ORDER BY t.id DESC + LIMIT " . max(1, $limit) + ); + $stmt->execute([(int) $staff['id'], (int) $staff['id'], ...$deptParams]); + $out = ['unassigned' => [], 'mine' => []]; + foreach ($stmt->fetchAll() as $row) { + $out[$row['assigned_staff_id'] === null ? 'unassigned' : 'mine'][] = (int) $row['id']; + } + return $out; +}