Upload via GUI (396 Dateien)

This commit is contained in:
2026-11-04 07:55:19 +00:00
parent c55cc54ba2
commit 27b3e4dbaf
19 changed files with 1559 additions and 9 deletions
+8
View File
@@ -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';
+4
View File
@@ -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') {
+3 -3
View File
@@ -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;
}
+92 -5
View File
@@ -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 <p>, <div>, <br>, <b> …) und deshalb als HTML dargestellt werden muss.
* Eine Adresse in spitzen Klammern ("Olaf <olaf@gmx.de>") 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<string,string>} $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"';
+169
View File
@@ -0,0 +1,169 @@
<?php
/**
* Datenschutz-Werkzeuge für Auskunft und Löschung auf Anfrage eines Kunden (DSGVO Art. 15 und 17).
* Nur für Administratoren und nur, wenn unter Einstellungen → Datenschutz eingeschaltet (gdpr_tools_enabled).
*
* - gdpr_customer_export(): alle zum Kunden gespeicherten Daten als Struktur (wird als JSON-Datei geliefert)
* - gdpr_customer_erase(): anonymisiert Tickets, entfernt Nachrichten und Anhänge des Kunden, Notizen und das Kundenkonto
*
* Ein Kunde ist entweder ein Konto (user_id) oder ein Gast, der nur über seine E-Mail-Adresse in Tickets vorkommt.
*/
function gdpr_tools_enabled(): bool
{
return get_setting('gdpr_tools_enabled', '0') === '1';
}
/**
* @param array{user_id?:int,email?:string} $ref
* @return array{email:string,user:?array,tickets:list<array>}|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<string,mixed>|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;
}
+7
View File
@@ -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;
}
@@ -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) {
@@ -0,0 +1,82 @@
<?php
/**
* includes/mail_fetch_manual.php
*
* E-Mail-Abruf von Hand starten (Knopf in der Ticketliste). Läuft über dieselbe Funktion wie der Cronjob. Damit der Abruf nicht doppelt
* läuft (Doppelklick, zugleich der eingebaute Zeitgeber oder ein anderer Mitarbeiter), wird vorher der Zeitstempel des letzten Abrufs
* atomar beansprucht: Wer ihn bekommt, ruft ab; alle anderen innerhalb der Sperrzeit bekommen eine Meldung.
*/
/** Mindestabstand zwischen zwei Abrufen von Hand, in Sekunden. */
const MAIL_FETCH_MANUAL_COOLDOWN = 20;
/** Gibt es etwas abzurufen (mindestens ein aktives Postfach)? */
function mail_fetch_available(PDO $pdo): bool
{
try {
return (int) $pdo->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;
}
+126
View File
@@ -0,0 +1,126 @@
<?php
/**
* includes/mail_reload.php
*
* Lädt die Anhänge einer schon abgerufenen E-Mail nachträglich ins Ticket (für Mails, die abgerufen wurden, bevor DeskHub Anhänge
* übernommen hat, oder deren Anhang damals nicht übernommen werden konnte). Die Original-Mail wird im Postfach gesucht: über die
* gespeicherte UID im aktuellen Ordner, sonst über die Message-ID.
*/
require_once __DIR__ . '/imap_spam.php';
require_once __DIR__ . '/mail_text.php';
/**
* Nachricht des Tickets, zu der eine Mail gehört: die Kundennachricht, die zur Zeit des Abrufs angelegt wurde (die nächstliegende).
*
* @param array<int,array{id:int|string,created_at:string}> $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;
}
+271
View File
@@ -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<int,array{no:string,name:string,encoding:int,bytes:int,inline:bool,cid:string,eml:bool}>
*/
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, '<img') === false) return null;
return strlen($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('<?xml encoding="UTF-8"><div id="dh-root">' . $html . '</div>', 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 (<hr>) 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('/<hr\b[^>]*>|<div\b[^>]*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, '<img') === false) return [$html, ''];
$a = mail_balance_html($head);
$b = mail_balance_html(substr($html, $offset));
return [$a, $b];
}
return [$html, ''];
}
/** Zuklappbarer Block für zitierten Verlauf (derselbe Aufbau wie bei eingeklappten Zitaten). */
function mail_quote_wrap(string $contentHtml): string
{
return '<div class="email-quote-wrap"><button type="button" class="email-quote-toggle" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display===\'none\'?\'block\':\'none\';this.textContent=this.nextElementSibling.style.display===\'none\'?\'▶ Verlauf anzeigen\':\'▼ Verlauf ausblenden\'">▶ Verlauf anzeigen</button><div class="email-quoted" style="display:none">' . $contentHtml . '</div></div>';
}
/**
* 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.
@@ -0,0 +1,104 @@
<?php
/**
* includes/mail_threading.php
*
* Ordnet eine eingehende Mail einem bestehenden Ticket zu (Antwort statt neues Ticket):
* 1. Ticketnummer im Betreff "[#260101-0001]" (so wie bisher, ohne weitere Prüfung);
* 2. andere Nummern im Betreff "[#923425]" (zum Beispiel aus einem früheren Ticketsystem), wenn es ein Ticket mit genau dieser Nummer gibt;
* 3. die Kopfzeilen In-Reply-To und References: DeskHub schreibt in jede Mail zu einem Ticket eine Message-ID mit der Ticketnummer
* (<deskhub.260101-0001.…@host>), 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 (<deskhub.NUMMER.zufall@host>) 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 '<deskhub.' . $m[1] . '.' . bin2hex(random_bytes(8)) . '@' . $host . '>';
}
/** 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;
}
+3 -1
View File
@@ -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(['<br>', '<br/>', '<br />', '</p>'], "\n", $rawMessage)), ENT_QUOTES | ENT_HTML5, 'UTF-8'))
: $rawMessage;
$rows .= email_chat_transcript_row(e($label), $plainMessage, e($time), $isStaff);
+168
View File
@@ -0,0 +1,168 @@
<?php
/**
* „Meine Woche“: persönliche Kennzahlen eines Mitarbeiters (gelöste Tickets, gesendete Antworten, durchschnittliche Zeit bis zur ersten
* Antwort, Zufriedenheit der Kunden, Pünktlichkeit). Sichtbar nur für den Mitarbeiter selbst (staff/my_week.php).
*/
/** @return array<string,string> */
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<string,int> 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];
}
@@ -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'],
];
}
@@ -0,0 +1,218 @@
<?php
/**
* Berichte per E-Mail nach Zeitplan: Ein gespeicherter Bericht aus dem Bericht-Baukasten wird täglich, wöchentlich oder monatlich
* für einen relativen Zeitraum (z. B. „letzte Woche“) neu berechnet und als Tabelle an Empfänger geschickt
* (cron/send_report_schedules.php). Der Bericht wird mit den Abteilungsrechten des Einrichters berechnet.
*/
const REPORT_SCHEDULE_MAX_RECIPIENTS = 10;
/** @return array<string,string> */
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<string,string> */
function report_schedule_frequencies(): array
{
return ['daily' => 'Täglich', 'weekly' => 'Wöchentlich', 'monthly' => 'Monatlich'];
}
/** @return array<int,string> */
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<string>,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 = '<h2 style="margin:0 0 6px;font-size:20px;color:#0f172a">' . e($saved['name']) . '</h2>'
. '<p style="margin:0 0 14px;color:#64748b;font-size:14px">Zeitraum: ' . e(date('d.m.Y', strtotime($from))) . ' bis ' . e(date('d.m.Y', strtotime($to))) . '</p>'
. '<table style="border-collapse:collapse;width:100%"><thead><tr>';
foreach ($report['columns'] as $c) $html .= '<th ' . $th . '>' . e($c['label']) . '</th>';
$html .= '</tr></thead><tbody>';
foreach ($report['rows'] as $row) {
$html .= '<tr><td ' . $td . '><strong>' . e((string) $row['label']) . '</strong></td>';
foreach (array_slice($report['columns'], 1) as $c) $html .= '<td ' . $td . '>' . e($fmt($row[$c['key']] ?? null, (string) $c['unit'])) . '</td>';
$html .= '</tr>';
}
if (!$report['rows']) {
$html .= '<tr><td ' . $td . ' colspan="' . count($report['columns']) . '">Keine Tickets im Zeitraum.</td></tr>';
} else {
$html .= '<tr><td ' . $td . '><strong>Gesamt</strong></td>';
foreach (array_slice($report['columns'], 1) as $c) $html .= '<td ' . $td . '><strong>' . e($fmt($report['totals'][$c['key']] ?? null, (string) $c['unit'])) . '</strong></td>';
$html .= '</tr>';
}
$html .= '</tbody></table><p style="margin:16px 0 0;color:#94a3b8;font-size:12px">Automatisch versendet von DeskHub (' . e(strtolower(report_schedule_frequencies()[$schedule['frequency']] ?? '')) . ').</p>';
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];
}
@@ -0,0 +1,135 @@
<?php
/**
* Zeitversetzt gesendete Antworten: Eine öffentliche Antwort wird geschrieben und zu einem späteren Zeitpunkt an den Kunden
* geschickt (Hintergrundjob cron/send_scheduled_replies.php, alle Minute über den eingebauten Cron oder echten Cronjob).
* Bis dahin steht sie im Ticket als „geplant“ und lässt sich vom Verfasser oder einem Administrator abbrechen.
* Anhänge werden bei zeitversetztem Senden nicht unterstützt.
*/
const SCHEDULED_REPLY_MAX_DAYS = 90;
/** Parst die Eingabe eines datetime-local-Feldes (Y-m-d\TH:i). Gibt "Y-m-d H:i:s" oder null zurück. */
function scheduled_reply_parse_time(string $input): ?string
{
$input = trim($input);
if (!preg_match('/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::\d{2})?$/', $input, $m)) return null;
$ts = strtotime($m[1] . ' ' . $m[2] . ':00');
return $ts === false ? null : date('Y-m-d H:i:s', $ts);
}
/** Legt eine geplante Antwort an. Gibt eine Fehlermeldung zurück oder null bei Erfolg. $message ist bereits bereinigtes HTML. */
function scheduled_reply_create(PDO $pdo, array $ticket, int $staffId, string $message, ?string $sendAt, array $opts = []): ?string
{
if (trim(strip_tags($message)) === '') return 'Bitte geben Sie eine Nachricht ein.';
if ($sendAt === null) return 'Bitte Datum und Uhrzeit für das Senden angeben.';
$ts = strtotime($sendAt);
if ($ts === false || $ts < time() + 30) return 'Der Sendezeitpunkt muss in der Zukunft liegen.';
if ($ts > 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<array<string,mixed>> 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];
}
@@ -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";
+49
View File
@@ -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 = '<p><strong>' . e($title) . '</strong></p>'
. '<p>' . ($recovered ? 'Die automatische Prüfung erreicht den Dienst wieder.' : 'Die automatische Prüfung erreicht den Dienst seit mindestens zwei Prüfungen nicht.') . '</p>'
. ($error !== '' && !$recovered ? '<p>Fehler: ' . e($error) . '</p>' : '')
. ($internal ? '<p style="color:#64748b">Dieser Dienst ist intern und steht nicht auf der öffentlichen Statusseite.</p>' : '')
. '<p><a href="' . e($url) . '">Statusseite im Mitarbeiterbereich öffnen</a></p>';
$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']]);
@@ -0,0 +1,53 @@
<?php
/**
* „Braucht Aufmerksamkeit“: der Zähler in Chrome-Erweiterung und Windows-Programm. Er zeigt den echten Stand und keine aufsummierten Ereignisse:
* Ein Ticket zählt, solange es offen (unbeantwortet) ist, dem Mitarbeiter gehört oder noch niemandem zugewiesen ist und er es seit der
* letzten Kundennachricht noch nicht geöffnet hat. Wird es geöffnet, beantwortet, geschlossen, jemand anderem zugewiesen oder zurückgestellt,
* verschwindet es beim nächsten Abruf aus dem Zähler.
*/
/** Merkt, dass der Mitarbeiter das Ticket jetzt gesehen hat. */
function ticket_ack(PDO $pdo, int $ticketId, int $staffId): void
{
try {
$now = db_now();
$upd = $pdo->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<int>,mine:list<int>}
*/
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;
}