From 58a8fab9f532287a4564ef0038243a194160d645 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Wed, 4 Nov 2026 07:55:36 +0000 Subject: [PATCH] Upload via GUI (396 Dateien) --- DeskHub-Webseite/tests/api_mail_actions.php | 135 ++++++++++++++++ DeskHub-Webseite/tests/fcm_push.php | 2 +- .../tests/fixtures/fake_imap_server.php | 152 ++++++++++++++++++ DeskHub-Webseite/tests/mail_attachments.php | 146 +++++++++++++++++ DeskHub-Webseite/tests/mail_fetch_e2e.php | 97 +++++++++++ DeskHub-Webseite/tests/mail_fetch_manual.php | 76 +++++++++ DeskHub-Webseite/tests/mail_quote_split.php | 73 +++++++++ DeskHub-Webseite/tests/mail_reload.php | 135 ++++++++++++++++ DeskHub-Webseite/tests/mail_reply_e2e.php | 93 +++++++++++ DeskHub-Webseite/tests/mail_threading.php | 85 ++++++++++ .../tests/message_html_detect.php | 42 +++++ 11 files changed, 1035 insertions(+), 1 deletion(-) create mode 100644 DeskHub-Webseite/tests/api_mail_actions.php create mode 100644 DeskHub-Webseite/tests/fixtures/fake_imap_server.php create mode 100644 DeskHub-Webseite/tests/mail_attachments.php create mode 100644 DeskHub-Webseite/tests/mail_fetch_e2e.php create mode 100644 DeskHub-Webseite/tests/mail_fetch_manual.php create mode 100644 DeskHub-Webseite/tests/mail_quote_split.php create mode 100644 DeskHub-Webseite/tests/mail_reload.php create mode 100644 DeskHub-Webseite/tests/mail_reply_e2e.php create mode 100644 DeskHub-Webseite/tests/mail_threading.php create mode 100644 DeskHub-Webseite/tests/message_html_detect.php diff --git a/DeskHub-Webseite/tests/api_mail_actions.php b/DeskHub-Webseite/tests/api_mail_actions.php new file mode 100644 index 00000000..4b30f201 --- /dev/null +++ b/DeskHub-Webseite/tests/api_mail_actions.php @@ -0,0 +1,135 @@ +getPathname(), strlen($root) + 1)); + if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue; + if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; } + @mkdir(dirname($tmp . '/' . $rel), 0755, true); + copy($f->getPathname(), $tmp . '/' . $rel); +} +@mkdir($tmp . '/uploads'); +@mkdir($tmp . '/config'); +file_put_contents($tmp . '/config/config.php', ' bin2hex(random_bytes(32)), + 'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'], + 'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'], + 'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'], +], true) . ';'); + +putenv('DESKHUB_CONFIG_JSON'); +putenv('DESKHUB_SQLITE_PATH'); +putenv('DESKHUB_TESTING'); +putenv('DESKHUB_API_ALLOW_INSECURE=1'); +require_once $root . '/database/sqlite_schema.php'; +require_once $root . '/database/migrate.php'; +$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite'); +$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); +$db->exec('PRAGMA foreign_keys = ON'); +$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql'))); +$mig = run_migrations($db, true); +$now = date('Y-m-d H:i:s'); +$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); +$db->exec('DELETE FROM staff'); +$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)'); +$ids = []; +foreach ([['Chef', 'chef@test.de', 'admin'], ['Agent', 'agent@test.de', 'agent']] as [$n, $e, $r]) { + $ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]); + $ids[$n] = (int) $db->lastInsertId(); +} +$db = null; + +$failures = []; +$total = 0; +$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; }; +$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp); + +try { + for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); } + $pdo = function () use ($tmp): PDO { + $p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite'); + $p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + return $p; + }; + $api = function (string $method, string $path, ?array $json = null, ?string $bearer = null) use ($webPort): array { + $ch = curl_init("http://127.0.0.1:$webPort$path"); + $h = ['Content-Type: application/json']; + if ($bearer) $h[] = 'Authorization: Bearer ' . $bearer; + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_HTTPHEADER => $h, CURLOPT_CUSTOMREQUEST => $method]); + if ($json !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($json)); + $b = (string) curl_exec($ch); + $s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + return [$s, json_decode($b, true) ?? $b]; + }; + $p = $pdo(); + $nowS = date('Y-m-d H:i:s'); + $p->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)') + ->execute(['T9001', 'API-Test', 'Kunde', 'kunde@test.de', $dept, 2, 'open', 'web', $nowS, $nowS]); + $tid = (int) $p->lastInsertId(); + $login = function (string $email) use ($api): string { + [, $lg] = $api('POST', '/api/staff/v1/login.php', ['email' => $email, 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'Test']); + return (string) ($lg['token'] ?? ''); + }; + $admin = $login('chef@test.de'); + $agent = $login('agent@test.de'); + $check($admin !== '' && $agent !== '', 'Anmeldung von Administrator und Mitarbeiter'); + + // E-Mail-Abruf von Hand: ohne Postfach eine verständliche Meldung (kein Absturz) + [$s, $r] = $api('POST', '/api/staff/v1/mail_fetch.php', [], $agent); + $check($s === 200 && $r['ok'] === false && $r['available'] === false && str_contains((string) $r['message'], 'kein aktives Postfach'), 'Abruf ohne Postfach: Meldung: ' . json_encode($r)); + [$s, ] = $api('POST', '/api/staff/v1/mail_fetch.php', []); + $check($s === 401, 'Abruf ohne Anmeldung: ' . $s); + [$s, ] = $api('GET', '/api/staff/v1/mail_fetch.php', null, $agent); + $check($s === 405, 'Abruf nur per POST: ' . $s); + + // Nachladen: nur für E-Mail-Tickets + [$s, $r] = $api('POST', '/api/staff/v1/ticket_mail_reload.php', ['number' => 'T9001'], $agent); + $check($s === 422 && ($r['error']['code'] ?? '') === 'EMAIL_TICKET_REQUIRED', 'Web-Ticket hat keine Original-E-Mail: ' . json_encode($r)); + [$s, $r] = $api('POST', '/api/staff/v1/ticket_mail_reload.php', [], $agent); + $check($s === 422 && ($r['error']['code'] ?? '') === 'TICKET_NUMBER_REQUIRED', 'Ticketnummer ist Pflicht'); + $p->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)') + ->execute(['T9002', 'Mail-Test', 'Kunde', 'kunde@test.de', $dept, 2, 'open', 'email', $nowS, $nowS]); + $mailTid = (int) $p->lastInsertId(); + $p->exec("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES ({$mailTid},'customer','Hallo',0,'{$nowS}')"); + $thId = (int) $p->lastInsertId(); + [$s, $r] = $api('POST', '/api/staff/v1/ticket_mail_reload.php', ['number' => 'T9002'], $agent); + $check($s === 422 && ($r['error']['code'] ?? '') === 'MAIL_RELOAD_FAILED' && str_contains((string) $r['error']['message'], 'keine Original-E-Mail'), 'E-Mail-Ticket ohne gespeicherte Mail: verständliche Meldung: ' . json_encode($r)); + + // Eingebettete Bilder stehen nicht in der Anhangliste der Ticket-Schnittstelle + $p->exec("INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at, content_id, is_inline) VALUES ({$thId},'Bericht.docx','a.docx',100,'{$nowS}',NULL,0)"); + $p->exec("INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at, content_id, is_inline) VALUES ({$thId},'logo.png','b.png',100,'{$nowS}','logo@x',1)"); + [$s, $r] = $api('GET', '/api/staff/v1/ticket.php?number=T9002', null, $agent); + $names = []; + foreach ($r['ticket']['messages'] ?? $r['ticket']['threads'] ?? [] as $m) foreach ($m['attachments'] ?? [] as $a) $names[] = $a['name'] ?? $a['originalName'] ?? ''; + $check($s === 200 && $names === ['Bericht.docx'], 'nur der echte Anhang steht in der Liste, nicht das eingebettete Bild: ' . json_encode($names) . ' ' . substr(json_encode($r), 0, 300)); +} finally { + if (isset($webProc) && is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); } +} + +usleep(300000); +$rm = function (string $d) use (&$rm) { + foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); } + @rmdir($d); +}; +if (str_starts_with($work, sys_get_temp_dir() . '/deskhub-apicss-')) $rm($work); + +if ($failures) { + fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} +echo "API Mail actions checks passed ({$total} Prüfungen).\n"; diff --git a/DeskHub-Webseite/tests/fcm_push.php b/DeskHub-Webseite/tests/fcm_push.php index eb202376..4f244227 100644 --- a/DeskHub-Webseite/tests/fcm_push.php +++ b/DeskHub-Webseite/tests/fcm_push.php @@ -311,7 +311,7 @@ try { [, $chefReg] = $call('POST', '/api/staff/v1/push_device.php', ['action' => 'register', 'token' => 'devChef_' . str_repeat('b', 40), 'deviceName' => 'Chef-Handy'], $chefToken); // Push-Einstellungen und Absturzberichte der App [$s, $r] = $call('GET', '/api/staff/v1/push_prefs.php', null, $agentToken); - $check($s === 200 && count($r['events'] ?? []) === 7 && count($r['eventTypes'] ?? []) === 7 && !in_array('chat_new', $r['events'] ?? [], true) && ($r['quietFrom'] ?? 'x') === '', 'Push-Einstellungen lesen: Standard alles an (HTTP ' . $s . ')'); + $check($s === 200 && count($r['events'] ?? []) === 8 && count($r['eventTypes'] ?? []) === 8 && !in_array('chat_new', $r['events'] ?? [], true) && ($r['quietFrom'] ?? 'x') === '', 'Push-Einstellungen lesen: Standard alles an (HTTP ' . $s . ')'); [$s, $r] = $call('POST', '/api/staff/v1/push_prefs.php', ['events' => ['ticket_new', 'nix'], 'quietFrom' => '22:00', 'quietTo' => '06:00'], $agentToken); $check($s === 200 && ($r['events'] ?? []) === ['ticket_new'] && ($r['quietFrom'] ?? '') === '22:00', 'Push-Einstellungen speichern (unbekannte Ereignisse verworfen)'); [$s] = $call('POST', '/api/staff/v1/push_prefs.php', ['events' => [], 'quietFrom' => '25:00', 'quietTo' => '06:00'], $agentToken); diff --git a/DeskHub-Webseite/tests/fixtures/fake_imap_server.php b/DeskHub-Webseite/tests/fixtures/fake_imap_server.php new file mode 100644 index 00000000..1b919179 --- /dev/null +++ b/DeskHub-Webseite/tests/fixtures/fake_imap_server.php @@ -0,0 +1,152 @@ + [Log-Datei] + * Die Mail-Datei enthält EINE Mail (mit \r\n); sie liegt im Ordner INBOX als Nachricht 1 mit UID 7. + */ + +[$_, $port, $mailFile] = $argv + [null, '14300', '']; +$logFile = $argv[3] ?? ''; +$raw = (string) file_get_contents($mailFile); +$raw = str_replace(["\r\n", "\n"], ["\n", "\r\n"], $raw); +[$headerPart, $bodyPart] = explode("\r\n\r\n", $raw, 2); +$headerPart .= "\r\n\r\n"; + +/** Zerlegt die Mail in MIME-Teile: Teilnummer => [Kopf, Rumpf, Content-Type, Transferkodierung, Content-Disposition, Content-ID]. */ +function mime_parts(string $head, string $body, string $no = ''): array +{ + $out = []; + $h = []; + foreach (preg_split('/\r\n(?=\S)/', rtrim($head)) as $line) { + if (strpos($line, ':') === false) continue; + [$k, $v] = explode(':', $line, 2); + $h[strtolower(trim($k))] = trim(preg_replace('/\r\n\s+/', ' ', $v)); + } + $ct = $h['content-type'] ?? 'text/plain'; + if (stripos($ct, 'multipart/') === 0 && preg_match('/boundary="?([^";]+)"?/i', $ct, $m)) { + $pieces = preg_split('/\r?\n?--' . preg_quote($m[1], '/') . '(?:--)?(?:\r\n?)?/', "\r\n" . $body); + $n = 0; + foreach ($pieces as $piece) { + if (trim($piece) === '') continue; + $n++; + [$ph, $pb] = array_pad(explode("\r\n\r\n", ltrim($piece, "\r\n"), 2), 2, ''); + $out['__children'][] = mime_parts($ph . "\r\n", $pb, $no === '' ? (string) $n : $no . '.' . $n); + } + $out['__multi'] = ['ct' => $ct, 'no' => $no]; + return $out; + } + return ['__leaf' => ['no' => $no === '' ? '1' : $no, 'h' => $h, 'body' => $body]]; +} + +function q(string $s): string { return '"' . addcslashes($s, '"\\') . '"'; } + +/** BODYSTRUCTURE-Ausdruck. */ +function bodystructure(array $node): string +{ + if (isset($node['__multi'])) { + $inner = ''; + foreach ($node['__children'] as $c) $inner .= bodystructure($c); + $sub = 'MIXED'; + if (preg_match('#multipart/(\w+)#i', $node['__multi']['ct'], $m)) $sub = strtoupper($m[1]); + $bd = preg_match('/boundary="?([^";]+)"?/i', $node['__multi']['ct'], $b) ? $b[1] : 'x'; + return '(' . $inner . ' ' . q($sub) . ' ("BOUNDARY" ' . q($bd) . ') NIL NIL)'; + } + $leaf = $node['__leaf']; + $h = $leaf['h']; + $ct = $h['content-type'] ?? 'text/plain; charset=us-ascii'; + [$type, $sub] = array_pad(explode('/', strtolower(trim(explode(';', $ct)[0])), 2), 2, 'plain'); + $params = []; + preg_match_all('/;\s*([\w*-]+)=(?:"([^"]*)"|([^;\s]+))/', $ct, $pm, PREG_SET_ORDER); + foreach ($pm as $p) $params[] = q(strtoupper($p[1])) . ' ' . q($p[2] !== '' ? $p[2] : ($p[3] ?? '')); + $pstr = $params ? '(' . implode(' ', $params) . ')' : 'NIL'; + $id = isset($h['content-id']) ? q($h['content-id']) : 'NIL'; + $enc = strtoupper($h['content-transfer-encoding'] ?? '7BIT'); + $size = strlen($leaf['body']); + $lines = substr_count($leaf['body'], "\n"); + $disp = 'NIL'; + if (isset($h['content-disposition'])) { + $dt = strtoupper(trim(explode(';', $h['content-disposition'])[0])); + $dp = []; + preg_match_all('/;\s*([\w*-]+)=(?:"([^"]*)"|([^;\s]+))/', $h['content-disposition'], $dm, PREG_SET_ORDER); + foreach ($dm as $p) $dp[] = q(strtoupper($p[1])) . ' ' . q($p[2] !== '' ? $p[2] : ($p[3] ?? '')); + $disp = '(' . q($dt) . ($dp ? ' (' . implode(' ', $dp) . ')' : ' NIL') . ')'; + } + $tail = ($type === 'text') ? " {$size} {$lines}" : " {$size}"; + return '(' . q(strtoupper($type)) . ' ' . q(strtoupper($sub)) . ' ' . $pstr . ' ' . $id . ' NIL ' . q($enc) . $tail . ' NIL ' . $disp . ' NIL)'; +} + +function find_leaf(array $node, string $no): ?array +{ + if (isset($node['__leaf'])) return $node['__leaf']['no'] === $no ? $node['__leaf'] : null; + foreach ($node['__children'] ?? [] as $c) if ($r = find_leaf($c, $no)) return $r; + return null; +} + +$tree = mime_parts($headerPart, $bodyPart); +preg_match('/^Message-ID:\s*(<[^>]+>)/mi', $headerPart, $mm); +$messageId = $mm[1] ?? ''; +preg_match('/^Subject:\s*(.*)$/mi', $headerPart, $sm); +$subject = trim($sm[1] ?? ''); +$envelope = '("Mon, 03 Nov 2026 00:39:00 +0100" ' . q($subject) . ' (("Olaf" NIL "olaf" "example.de")) (("Olaf" NIL "olaf" "example.de")) (("Olaf" NIL "olaf" "example.de")) (("" NIL "einsendungen" "example.org")) NIL NIL NIL ' . q($messageId) . ')'; + +$log = function (string $line) use ($logFile): void { if ($logFile !== '') file_put_contents($logFile, $line . "\n", FILE_APPEND); }; +$seenFlag = false; +$server = stream_socket_server('tcp://127.0.0.1:' . $port, $errno, $errstr); +if (!$server) { fwrite(STDERR, "Server nicht startbar: $errstr\n"); exit(1); } +while (true) { + $conn = @stream_socket_accept($server, 600); + if (!$conn) break; + fwrite($conn, "* OK [CAPABILITY IMAP4rev1] fake ready\r\n"); + while (($line = fgets($conn)) !== false) { + $line = rtrim($line, "\r\n"); + $log('C: ' . $line); + if (!preg_match('/^(\S+)\s+(.*)$/', $line, $m)) continue; + [$_, $tag, $cmd] = $m; + $send = function (string $s) use ($conn, $log): void { $log('S: ' . substr($s, 0, 200)); fwrite($conn, $s); }; + $upper = strtoupper($cmd); + if (strpos($upper, 'CAPABILITY') === 0) { + $send("* CAPABILITY IMAP4rev1\r\n{$tag} OK done\r\n"); + } elseif (strpos($upper, 'LOGIN') === 0) { + $send("{$tag} OK logged in\r\n"); + } elseif (strpos($upper, 'SELECT') === 0 || strpos($upper, 'EXAMINE') === 0) { + $send("* 1 EXISTS\r\n* 0 RECENT\r\n* OK [UIDVALIDITY 1] ok\r\n* OK [UIDNEXT 8] ok\r\n* FLAGS (\\Seen \\Deleted)\r\n{$tag} OK [READ-WRITE] selected\r\n"); + } elseif (strpos($upper, 'NOOP') === 0 || strpos($upper, 'CHECK') === 0) { + $send("{$tag} OK done\r\n"); + } elseif (strpos($upper, 'LOGOUT') === 0) { + $send("* BYE bye\r\n{$tag} OK done\r\n"); + break; + } elseif (preg_match('/^(UID\s+)?STORE\b/i', $cmd)) { + if (stripos($cmd, '\\Seen') !== false && stripos($cmd, '-FLAGS') === false) $seenFlag = true; + $send("{$tag} OK stored\r\n"); + } elseif (preg_match('/^(EXPUNGE|COPY|UID COPY)\b/i', $cmd)) { + $send("{$tag} OK done\r\n"); + } elseif (preg_match('/^(UID\s+)?(SEARCH|SORT)\b/i', $cmd, $sm2)) { + $uid = !empty($sm2[1]); + // Bereits als gelesen markiert: bei "UNSEEN" nichts finden + $none = $seenFlag && stripos($cmd, 'UNSEEN') !== false; + $send('* ' . strtoupper($sm2[2]) . ($none ? '' : ' ' . ($uid ? '7' : '1')) . "\r\n{$tag} OK done\r\n"); + } elseif (preg_match('/^(UID\s+)?FETCH\s+(\S+)\s+(.*)$/i', $cmd, $fm)) { + $isUid = $fm[1] !== ''; + $items = $fm[3]; + $parts = []; + if ($isUid || stripos($items, 'UID') !== false) $parts[] = 'UID 7'; + if (stripos($items, 'FLAGS') !== false) $parts[] = 'FLAGS (\\Seen)'; + if (stripos($items, 'INTERNALDATE') !== false) $parts[] = 'INTERNALDATE "03-Nov-2026 00:39:00 +0100"'; + if (stripos($items, 'RFC822.SIZE') !== false) $parts[] = 'RFC822.SIZE ' . strlen($raw); + if (stripos($items, 'ENVELOPE') !== false) $parts[] = 'ENVELOPE ' . $envelope; + if (stripos($items, 'BODYSTRUCTURE') !== false) $parts[] = 'BODYSTRUCTURE ' . bodystructure($tree); + $literal = ''; + if (preg_match('/BODY(?:\.PEEK)?\[([^\]]*)\]/i', $items, $bm)) { + $sec = $bm[1]; + if ($sec === '' || stripos($sec, 'HEADER') === 0) { $data = $sec === '' ? $raw : $headerPart; } + else { $leaf = find_leaf($tree, $sec); $data = $leaf ? $leaf['body'] : ''; } + $parts[] = 'BODY[' . $sec . '] {' . strlen($data) . "}\r\n" . $data; + } + $send('* 1 FETCH (' . implode(' ', $parts) . ")\r\n{$tag} OK done\r\n"); + } else { + $send("{$tag} BAD unknown command\r\n"); + } + } + fclose($conn); +} diff --git a/DeskHub-Webseite/tests/mail_attachments.php b/DeskHub-Webseite/tests/mail_attachments.php new file mode 100644 index 00000000..434be76d --- /dev/null +++ b/DeskHub-Webseite/tests/mail_attachments.php @@ -0,0 +1,146 @@ + ['path' => $uploads, 'max_size_bytes' => 5000, 'allowed_extensions' => ['png', 'jpg', 'pdf', 'txt', 'docx']]])); +require_once dirname(__DIR__) . '/includes/bootstrap.php'; +require_once dirname(__DIR__) . '/includes/mail_text.php'; + +$failures = []; +$check = function (bool $ok, string $what) use (&$failures): void { + if (!$ok) $failures[] = $what; +}; +$pdo = get_db(); +$check(realpath((string) config()['uploads']['path']) === realpath($uploads), 'Test nutzt einen eigenen Upload-Ordner: ' . (string) config()['uploads']['path']); + +/** Baut einen Teil wie imap_fetchstructure ihn liefert. */ +$param = fn(string $a, string $v) => (object) ['attribute' => $a, 'value' => $v]; +$leaf = function (int $type, string $subtype, int $enc, int $bytes, array $dparams = [], string $disp = '', bool $ifid = false) use ($param): object { + $o = (object) ['type' => $type, 'subtype' => $subtype, 'encoding' => $enc, 'bytes' => $bytes, 'ifid' => $ifid]; + if ($ifid) $o->id = ''; + if ($disp !== '') { $o->ifdisposition = 1; $o->disposition = $disp; } + if ($dparams) { $o->ifdparameters = 1; $o->dparameters = array_map(fn($p) => $param($p[0], $p[1]), $dparams); } + return $o; +}; +$docx = random_bytes(1200); // Word-Datei (Inhalt zufällig, nur die Endung zählt) +$parts = [ // Rohinhalt je Teilnummer (Teil 1 = Text, 2 = Word, 3 = Signaturbild, 4 = zu große PDF, 5 = exe) + '2' => chunk_split(base64_encode($docx)), + '4' => base64_encode(str_repeat('x', 8000)), + '5' => base64_encode('MZ-programm'), +]; +$structure = (object) ['type' => 1, 'subtype' => 'MIXED', 'parts' => [ + (object) ['type' => 1, 'subtype' => 'ALTERNATIVE', 'parts' => [$leaf(0, 'PLAIN', 3, 40)]], + $leaf(3, 'VND.OPENXMLFORMATS-OFFICEDOCUMENT.WORDPROCESSINGML.DOCUMENT', 3, 1600, [['filename', 'Haus Ledwin .docx']], 'attachment'), + $leaf(5, 'GIF', 3, 100, [], 'inline', true), // Signaturbild mit Content-ID + $leaf(3, 'PDF', 3, 11000, [['filename', 'gross.pdf']], 'attachment'), + $leaf(3, 'OCTET-STREAM', 3, 80, [['filename', 'programm.exe']], 'attachment'), +]]; + +$found = mail_collect_attachment_parts($structure); +$check(count($found) === 4, 'vier Teile erkannt (Word, Signaturbild, PDF, exe): ' . json_encode(array_column($found, 'name'))); +$inl = array_values(array_filter($found, fn($f) => $f['inline'])); +$check(count($inl) === 1 && $inl[0]['cid'] === 'logo1@example.de', 'Signaturbild mit Content-ID ist als eingebettet gekennzeichnet'); +$check(($found[0]['name'] ?? '') === 'Haus Ledwin .docx' && ($found[0]['no'] ?? '') === '2', 'Word-Datei mit Teilnummer 2 erkannt'); + +// Dateinamen: MIME-kodiert und RFC 2231 +$check(mail_decode_filename('=?UTF-8?B?' . base64_encode('Übersicht März.pdf') . '?=') === 'Übersicht März.pdf', 'MIME-Dateiname (Base64) wird dekodiert'); +$check(mail_decode_filename('=?ISO-8859-1?Q?Gr=FC=DFe.txt?=') === 'Grüße.txt', 'MIME-Dateiname (Quoted-Printable) wird dekodiert'); +$check(mail_decode_filename("UTF-8''Bericht%20f%C3%BCr%20Mai.docx") === 'Bericht für Mai.docx', 'RFC-2231-Dateiname wird dekodiert'); +$check(mail_decode_filename('einfach.txt') === 'einfach.txt', 'einfacher Dateiname bleibt'); +// Teil ohne Dateinamen, aber als Anhang gekennzeichnet; reiner Text ohne Namen ist kein Anhang +$check(count(mail_collect_attachment_parts($leaf(3, 'PDF', 3, 500, [], 'attachment'))) === 1, 'Anhang ohne Dateinamen bekommt einen Namen'); +$check(mail_collect_attachment_parts($leaf(0, 'PLAIN', 0, 500)) === [], 'Textteil ohne Namen ist kein Anhang'); +// Verschachtelt: Anhang in multipart/related innerhalb multipart/mixed (Teilnummer 1.2) +$nested = (object) ['type' => 1, 'subtype' => 'MIXED', 'parts' => [(object) ['type' => 1, 'subtype' => 'RELATED', 'parts' => [$leaf(0, 'HTML', 0, 10), $leaf(3, 'PDF', 3, 100, [['name', 'a.pdf']], 'attachment')]]]]; +$n = mail_collect_attachment_parts($nested); +$check(count($n) === 1 && $n[0]['no'] === '1.2', 'verschachtelter Anhang hat die Teilnummer 1.2: ' . json_encode($n)); + +// Speichern +$nowStr = date('Y-m-d H:i:s'); +$pdo->exec("INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES ('260101-0001','Test','Olaf','olaf@example.de',1,2,'open','email','{$nowStr}','{$nowStr}')"); +$ticketId = (int) $pdo->lastInsertId(); +$pdo->exec("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES ({$ticketId},'customer','x',0,'{$nowStr}')"); +$threadId = (int) $pdo->lastInsertId(); +$log = []; +mail_store_attachments($pdo, $structure, fn(string $no) => $parts[$no] ?? '', $threadId, $ticketId, 'Test', $log, false, true); +$rows = $pdo->query("SELECT * FROM ticket_attachments WHERE thread_id = {$threadId}")->fetchAll(PDO::FETCH_ASSOC); +$check(count($rows) === 1 && $rows[0]['original_name'] === 'Haus Ledwin .docx', 'nur die Word-Datei wird gespeichert: ' . json_encode(array_column($rows, 'original_name'))); +$stored = $rows ? $uploads . '/' . $rows[0]['stored_name'] : ''; +$check($stored !== '' && is_file($stored) && file_get_contents($stored) === $docx, 'Dateiinhalt stimmt mit dem Anhang überein'); +$check($rows && (int) $rows[0]['filesize'] === strlen($docx), 'Dateigröße stimmt'); +$act = $pdo->query("SELECT action FROM activity_log WHERE ticket_id = {$ticketId}")->fetchAll(PDO::FETCH_COLUMN); +$joined = implode("\n", $act); +$check(str_contains($joined, 'gross.pdf') && str_contains($joined, 'zu groß'), 'zu großer Anhang steht im Verlauf: ' . $joined); +$check(str_contains($joined, 'programm.exe'), 'gesperrter Typ steht im Verlauf'); +$check(count(array_filter($log, fn($l) => str_contains($l, 'übernommen: Haus Ledwin'))) === 1, 'Protokollzeile für übernommenen Anhang'); + +// Eingebettetes Bild (echtes PNG) mit Content-ID: wird als eingebettet gespeichert und nicht als Anhang gezählt +$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='); +$inStruct = (object) ['type' => 1, 'subtype' => 'RELATED', 'parts' => [$leaf(0, 'HTML', 3, 40), $leaf(5, 'PNG', 3, 200, [], 'inline', true)]]; +$log3 = []; +$c3 = mail_store_attachments($pdo, $inStruct, fn(string $no) => $no === '2' ? chunk_split(base64_encode($png)) : '', $threadId, $ticketId, 'Test', $log3, false, true); +$row = $pdo->query("SELECT * FROM ticket_attachments WHERE thread_id = {$threadId} AND is_inline = 1")->fetch(PDO::FETCH_ASSOC); +$check($c3['inline'] === 1 && $row && $row['content_id'] === 'logo1@example.de' && str_ends_with($row['original_name'], '.png'), 'eingebettetes Bild gespeichert (Content-ID, .png): ' . json_encode($row)); +$c3b = mail_store_attachments($pdo, $inStruct, fn(string $no) => $no === '2' ? chunk_split(base64_encode($png)) : '', $threadId, $ticketId, 'Test', $log3, true, true); +$check($c3b['existing'] === 1 && $c3b['inline'] === 0, 'eingebettetes Bild wird beim zweiten Mal nicht doppelt gespeichert'); +// Ohne HTML-Fassung ist dasselbe Bild ein normaler Anhang (in der Liste sichtbar) +$noHtml = (object) ['type' => 1, 'subtype' => 'MIXED', 'parts' => [$leaf(0, 'PLAIN', 3, 40), $leaf(5, 'PNG', 3, 200, [['name', 'foto.png']], 'inline', true)]]; +$c4 = mail_store_attachments($pdo, $noHtml, fn(string $no) => $no === '2' ? chunk_split(base64_encode($png)) : '', $threadId, $ticketId, 'Test', $log3, false, false); +$check($c4['added'] === 1 && (int) $pdo->query("SELECT is_inline FROM ticket_attachments WHERE thread_id = {$threadId} AND original_name = 'foto.png'")->fetchColumn() === 0, 'ohne HTML-Fassung zählt das Bild als Anhang'); +// Ein "Bild", das keins ist, wird nicht als eingebettetes Bild gespeichert +$fake = (object) ['type' => 1, 'subtype' => 'RELATED', 'parts' => [$leaf(0, 'HTML', 3, 40), $leaf(5, 'PNG', 3, 100, [['name', 'x.png']], 'inline', true)]]; +$before2 = (int) $pdo->query('SELECT COUNT(*) FROM ticket_attachments')->fetchColumn(); +mail_store_attachments($pdo, $fake, fn(string $no) => $no === '2' ? base64_encode('') : '', $threadId, $ticketId, 'Test', $log3, false, true); +$check((int) $pdo->query('SELECT COUNT(*) FROM ticket_attachments')->fetchColumn() === $before2, 'HTML im Bild-Gewand wird nicht gespeichert'); +// Weitergeleitete Mail (.eml): Name aus dem Betreff +$rfc = "From: a@example.de + +Subject: =?UTF-8?B?" . base64_encode('Rechnung März') . "?= + + + +Inhalt"; +$emlStruct = (object) ['type' => 1, 'subtype' => 'MIXED', 'parts' => [$leaf(0, 'PLAIN', 3, 40), $leaf(2, 'RFC822', 0, strlen($rfc), [], '')]]; +$c5 = mail_store_attachments($pdo, $emlStruct, fn(string $no) => $no === '2' ? $rfc : '', $threadId, $ticketId, 'Test', $log3, false, true); +$check($c5['added'] === 1 && (int) $pdo->query("SELECT COUNT(*) FROM ticket_attachments WHERE thread_id = {$threadId} AND original_name = 'Rechnung März.eml'")->fetchColumn() === 1, 'weitergeleitete Mail wird als .eml gespeichert, Name aus dem Betreff'); + +// Zusätzliche Endungen nur für Mails: "doc" ist im Formular nicht erlaubt (Liste im Test: png, jpg, pdf, txt, docx), per Mail schon +$docStruct = (object) ['type' => 1, 'subtype' => 'MIXED', 'parts' => [$leaf(0, 'PLAIN', 3, 40), $leaf(3, 'MSWORD', 3, 300, [['filename', 'alt.doc']], 'attachment'), $leaf(3, 'OCTET-STREAM', 3, 300, [['filename', 'schaedling.php']], 'attachment')]]; +$c6 = mail_store_attachments($pdo, $docStruct, fn(string $no) => base64_encode('Dokument ' . $no), $threadId, $ticketId, 'Test', $log3, false, false); +$check($c6['added'] === 1 && $c6['skipped'] === 1 && (int) $pdo->query("SELECT COUNT(*) FROM ticket_attachments WHERE thread_id = {$threadId} AND original_name = 'alt.doc'")->fetchColumn() === 1, 'doc ist per Mail erlaubt, .php bleibt gesperrt: ' . json_encode($c6)); +$check(isset(handle_ticket_upload(['name' => 'alt.doc', 'type' => '', 'tmp_name' => __FILE__, 'error' => UPLOAD_ERR_OK, 'size' => 10], false)['error']), 'im Formular bleibt doc gesperrt (die Zusatzliste gilt nur für Mails)'); +set_setting('mail_attachment_extra_extensions', 'xyz'); +$c7 = mail_store_attachments($pdo, (object) ['type' => 1, 'subtype' => 'MIXED', 'parts' => [$leaf(0, 'PLAIN', 3, 40), $leaf(3, 'MSWORD', 3, 300, [['filename', 'neu.doc']], 'attachment')]], fn(string $no) => base64_encode('x'), $threadId, $ticketId, 'Test', $log3, false, false); +$check($c7['added'] === 0 && $c7['skipped'] === 1, 'Zusatzliste ist einstellbar (mit "xyz" ist doc wieder gesperrt)'); +set_setting('mail_attachment_extra_extensions', MAIL_ATTACHMENT_EXTRA_DEFAULT); + +// Mail ohne Anhänge: nichts passiert +$before = (int) $pdo->query('SELECT COUNT(*) FROM ticket_attachments')->fetchColumn(); +$log2 = []; +mail_store_attachments($pdo, (object) ['type' => 0, 'subtype' => 'PLAIN', 'encoding' => 0, 'bytes' => 10], fn($n) => 'x', $threadId, $ticketId, 'Test', $log2); +$check((int) $pdo->query('SELECT COUNT(*) FROM ticket_attachments')->fetchColumn() === $before && $log2 === [], 'Mail ohne Anhang: keine Einträge'); +// Gefährliche Namen +$res = store_ticket_attachment_data($pdo, $threadId, '../../evil.php', 'x'); +$check(!$res['ok'], 'Pfad und .php werden abgelehnt'); +$res = store_ticket_attachment_data($pdo, $threadId, "a\x00b.txt", 'inhalt'); +$check($res['ok'] && $res['name'] === 'ab.txt', 'Steuerzeichen im Namen werden entfernt: ' . json_encode($res)); + +$rm = function (string $d) use (&$rm) { + foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); } + @rmdir($d); +}; +$rm($uploads); + +if ($failures) { + fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} +echo "Mail attachment checks passed.\n"; diff --git a/DeskHub-Webseite/tests/mail_fetch_e2e.php b/DeskHub-Webseite/tests/mail_fetch_e2e.php new file mode 100644 index 00000000..0c5a3c4b --- /dev/null +++ b/DeskHub-Webseite/tests/mail_fetch_e2e.php @@ -0,0 +1,97 @@ + ['path' => $tmp . '/uploads', 'max_size_bytes' => 100000, 'allowed_extensions' => ['png', 'pdf', 'txt', 'docx']]])); +require_once dirname(__DIR__) . '/includes/bootstrap.php'; +require_once dirname(__DIR__) . '/includes/mail_fetch_manual.php'; + +$failures = []; +$check = function (bool $ok, string $what) use (&$failures): void { + if (!$ok) $failures[] = $what; +}; +$pdo = get_db(); + +$docx = random_bytes(700); +$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='); +$html = '

Hallo Team

Zweiter Absatz mit fett

Logo'; +$mail = "From: Olaf Andresen \r\nTo: einsendungen@example.org\r\nSubject: Neue Geschichte Teil 12\r\nMessage-ID: \r\nDate: Mon, 03 Nov 2026 00:39:00 +0100\r\nMIME-Version: 1.0\r\n" + . "Content-Type: multipart/mixed; boundary=\"BND1\"\r\n\r\n" + . "--BND1\r\nContent-Type: multipart/related; boundary=\"BND2\"\r\n\r\n" + . "--BND2\r\nContent-Type: multipart/alternative; boundary=\"BND3\"\r\n\r\n" + . "--BND3\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\nHallo Team\r\n\r\nZweiter Absatz mit fett\r\n" + . "--BND3\r\nContent-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n" . chunk_split(base64_encode($html)) + . "--BND3--\r\n" + . "--BND2\r\nContent-Type: image/png; name=\"logo.png\"\r\nContent-Disposition: inline; filename=\"logo.png\"\r\nContent-ID: \r\nContent-Transfer-Encoding: base64\r\n\r\n" . chunk_split(base64_encode($png)) + . "--BND2--\r\n" + . "--BND1\r\nContent-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document; name=\"Teil 12.docx\"\r\nContent-Disposition: attachment; filename=\"Teil 12.docx\"\r\nContent-Transfer-Encoding: base64\r\n\r\n" . chunk_split(base64_encode($docx)) + . "--BND1--\r\n"; +file_put_contents($tmp . '/mail.eml', $mail); + +$port = 0; +for ($try = 0; $try < 20 && !$port; $try++) { + $p = random_int(20000, 60000); + $s = @stream_socket_server('tcp://127.0.0.1:' . $p); + if ($s) { fclose($s); $port = $p; } +} +$proc = proc_open([PHP_BINARY, __DIR__ . '/fixtures/fake_imap_server.php', (string) $port, $tmp . '/mail.eml', $tmp . '/imap.log'], [0 => ['file', 'php://stdin', 'r'], 1 => ['file', $tmp . '/srv.out', 'w'], 2 => ['file', $tmp . '/srv.err', 'w']], $pipes); +for ($i = 0; $i < 50; $i++) { + $c = @fsockopen('127.0.0.1', $port, $en, $es, 0.2); + if ($c) { fclose($c); break; } + usleep(100000); +} + +try { + $deptId = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); + $pdo->prepare("INSERT INTO imap_accounts (label, host, port, encryption, username, password_enc, folder, after_fetch, department_id, priority_id, is_active) VALUES ('Test','127.0.0.1',?,'none','u',?,'INBOX','seen',?,2,1)") + ->execute([$port, encrypt_secret('geheim'), $deptId]); + + $r = mail_fetch_manual($pdo); + $check($r['ok'] && $r['processed'] === 1 && $r['new_tickets'] === 1, 'Abruf von Hand: 1 neues Ticket: ' . json_encode($r) . ' | ' . @file_get_contents($tmp . '/srv.err')); + $t = $pdo->query("SELECT * FROM tickets WHERE subject = 'Neue Geschichte Teil 12'")->fetch(PDO::FETCH_ASSOC); + $check((bool) $t && $t['source'] === 'email' && $t['requester_email'] === 'olaf@example.de', 'Ticket aus der Mail angelegt'); + $th = $t ? $pdo->query('SELECT * FROM ticket_threads WHERE ticket_id = ' . (int) $t['id'])->fetch(PDO::FETCH_ASSOC) : []; + $check(($th['message'] ?? '') === "Hallo Team\n\nZweiter Absatz mit fett", 'Text mit Absatz: ' . json_encode($th['message'] ?? null)); + $check(str_contains((string) ($th['message_html'] ?? ''), 'fett') && str_contains((string) ($th['message_html'] ?? ''), 'cid:logo1@example.de'), 'HTML-Fassung gespeichert'); + $att = $th ? $pdo->query('SELECT * FROM ticket_attachments WHERE thread_id = ' . (int) $th['id'] . ' ORDER BY is_inline')->fetchAll(PDO::FETCH_ASSOC) : []; + $check(count($att) === 2 && $att[0]['original_name'] === 'Teil 12.docx' && (int) $att[1]['is_inline'] === 1 && $att[1]['content_id'] === 'logo1@example.de', 'Anhang und eingebettetes Bild gespeichert: ' . json_encode(array_column($att, 'original_name'))); + $check($att && file_get_contents($tmp . '/uploads/' . $att[0]['stored_name']) === $docx, 'Inhalt des Anhangs stimmt'); + $src = $t ? $pdo->query('SELECT * FROM ticket_email_sources WHERE ticket_id = ' . (int) $t['id'])->fetch(PDO::FETCH_ASSOC) : false; + $check((bool) $src && $src['message_id'] === '' && (int) $src['message_uid'] === 7, 'Herkunft der Mail (UID, Message-ID) ist vermerkt'); + + // Direkt danach: gesperrt + $r2 = mail_fetch_manual($pdo); + $check(!$r2['ok'] && str_contains($r2['message'], 'gerade eben'), 'zweiter Klick direkt danach wird abgewiesen'); + // Nach der Sperrzeit: nichts Neues (Mail ist als gelesen markiert) + $pdo->prepare("UPDATE settings SET setting_value = ? WHERE setting_key = 'cron_fetch_emails_last_run'")->execute([date('Y-m-d H:i:s', time() - 120)]); + $r3 = mail_fetch_manual($pdo); + $check($r3['ok'] && $r3['processed'] === 0 && str_contains($r3['message'], 'Keine neuen E-Mails'), 'zweiter Abruf findet nichts mehr: ' . json_encode($r3)); + $check((int) $pdo->query("SELECT COUNT(*) FROM tickets WHERE subject = 'Neue Geschichte Teil 12'")->fetchColumn() === 1, 'es gibt weiterhin genau ein Ticket'); +} finally { + if (is_resource($proc)) { proc_terminate($proc); proc_close($proc); } +} + +$logTxt = (string) @file_get_contents($tmp . '/imap.log'); +$rm = function (string $d) use (&$rm) { + foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); } + @rmdir($d); +}; +if ($failures) $failures[] = 'IMAP-Protokoll (Ende): ' . substr($logTxt, -1200); +$rm($tmp); + +if ($failures) { + fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} +echo "Mail fetch end-to-end checks passed.\n"; diff --git a/DeskHub-Webseite/tests/mail_fetch_manual.php b/DeskHub-Webseite/tests/mail_fetch_manual.php new file mode 100644 index 00000000..f1835378 --- /dev/null +++ b/DeskHub-Webseite/tests/mail_fetch_manual.php @@ -0,0 +1,76 @@ + $pdo->exec("DELETE FROM settings WHERE setting_key = 'cron_fetch_emails_last_run'"); + +// Kein Postfach +$check(!mail_fetch_available($pdo), 'ohne Postfach nicht verfügbar'); +$r = mail_fetch_manual($pdo, $runner(['processed' => 0, 'log' => []])); +$check(!$r['ok'] && $calls === 0 && str_contains($r['message'], 'kein aktives Postfach'), 'ohne Postfach: verständliche Meldung, kein Lauf: ' . $r['message']); + +// Mit Postfach +$pdo->exec("INSERT INTO imap_accounts (label, host, port, encryption, username, password_enc, folder, is_active) VALUES ('Test','mail.example.test',993,'ssl','u','x','INBOX',1)"); +$check(mail_fetch_available($pdo), 'mit aktivem Postfach verfügbar'); +$reset(); +$r = mail_fetch_manual($pdo, $runner(['processed' => 0, 'log' => ['[Test] Keine neuen E-Mails.']])); +$check($r['ok'] && $r['level'] === 'info' && $calls === 1 && str_contains($r['message'], 'Keine neuen E-Mails'), 'keine neuen Mails: Hinweis: ' . $r['message']); + +// Sperrzeit: ein zweiter Klick direkt danach läuft nicht +$r = mail_fetch_manual($pdo, $runner(['processed' => 9, 'log' => []])); +$check(!$r['ok'] && $r['level'] === 'info' && $calls === 1 && str_contains($r['message'], 'gerade eben'), 'zweiter Klick innerhalb der Sperrzeit startet keinen zweiten Lauf'); +// Auch ein laufender Zeitgeber (Zeitstempel gerade gesetzt) sperrt den manuellen Abruf +$check(pseudo_cron_try_claim($pdo, 'cron_fetch_emails_last_run', 3600) === false, 'Zeitstempel ist gerade beansprucht'); + +// Nach der Sperrzeit wieder möglich; Ergebnis mit neuen Tickets, Antworten, ignorierten Mails +$pdo->prepare("UPDATE settings SET setting_value = ? WHERE setting_key = 'cron_fetch_emails_last_run'")->execute([date('Y-m-d H:i:s', time() - 60)]); +$r = mail_fetch_manual($pdo, $runner(['processed' => 5, 'log' => [ + '[Test] Neues Ticket #260301-0001 erstellt (von a@example.de).', + '[Test] Neues Ticket #260301-0002 erstellt (von b@example.de).', + '[Test] Antwort zu Ticket #260101-0005 hinzugefügt (von c@example.de).', + '[Test] → Anhang übernommen: a.pdf', + '[Test] Mail von noreply@example.de ignoriert (eigene Systemmail, würde sonst ein Ticket erzeugen): x', + '[Test] → Anhang nicht übernommen: gross.pdf (zu groß)', +]])); +$check($r['ok'] && $r['level'] === 'success' && $calls === 2 && $r['new_tickets'] === 2 && $r['replies'] === 1 && $r['processed'] === 5, 'Ergebnis wird gezählt: ' . json_encode($r)); +$check(str_contains($r['message'], '2 neue(s) Ticket(s)') && str_contains($r['message'], '1 Antwort(en)') && str_contains($r['message'], '2 weitere'), 'Zusammenfassung nennt Tickets, Antworten und Übriges: ' . $r['message']); +$check($r['errors'] === [], 'ein nicht übernommener Anhang ist kein Fehler des Abrufs'); + +// Fehler: Verbindung zum Postfach schlägt fehl +$pdo->prepare("UPDATE settings SET setting_value = ? WHERE setting_key = 'cron_fetch_emails_last_run'")->execute([date('Y-m-d H:i:s', time() - 60)]); +$r = mail_fetch_manual($pdo, $runner(['processed' => 0, 'log' => ['[Test] IMAP-Verbindung fehlgeschlagen: Login failed (Authentication failure)']])); +$check(!$r['ok'] && $r['level'] === 'error' && str_contains($r['message'], 'Login failed') && !str_contains($r['message'], '[Test]'), 'Verbindungsfehler wird gezeigt: ' . $r['message']); + +// Ausnahme im Abruf +$pdo->prepare("UPDATE settings SET setting_value = ? WHERE setting_key = 'cron_fetch_emails_last_run'")->execute([date('Y-m-d H:i:s', time() - 60)]); +$r = mail_fetch_manual($pdo, function () { throw new RuntimeException('Datenbank weg'); }); +$check(!$r['ok'] && str_contains($r['message'], 'Datenbank weg'), 'Ausnahme wird als Meldung gezeigt, nicht als Absturz'); + +// Deaktiviertes Postfach zählt nicht +$pdo->exec('UPDATE imap_accounts SET is_active = 0'); +$check(!mail_fetch_available($pdo), 'deaktivierte Postfächer zählen nicht'); + +if ($failures) { + fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} +echo "Manual mail fetch checks passed.\n"; diff --git a/DeskHub-Webseite/tests/mail_quote_split.php b/DeskHub-Webseite/tests/mail_quote_split.php new file mode 100644 index 00000000..009a8260 --- /dev/null +++ b/DeskHub-Webseite/tests/mail_quote_split.php @@ -0,0 +1,73 @@ +"-Zitate, und dass nichts getrennt wird, wenn vor dem Zitat kein eigener Text steht. + */ + +putenv('DESKHUB_TESTING=1'); +putenv('DESKHUB_SQLITE_PATH=:memory:'); +require_once dirname(__DIR__) . '/includes/bootstrap.php'; +require_once dirname(__DIR__) . '/includes/mail_text.php'; + +$failures = []; +$check = function (bool $ok, string $what) use (&$failures): void { + if (!$ok) $failures[] = $what; +}; + +// ── Text ───────────────────────────────────────────────────────────────────── +[$new, $q] = mail_split_quoted_text("und de nächste Teil von Haus Ledwin\n\nGesendet: Sonntag, 11. Oktober 2026 um 07:42\nVon: Windelgeschichten \nAn: \"Olaf Andresen\" \nBetreff: AW: [#923425] Haus Ledwin Teil 11\n\nHallo Olaf Andresen,\nVielen Dank!"); +$check($new === 'und de nächste Teil von Haus Ledwin' && str_starts_with($q, 'Gesendet: Sonntag') && str_contains($q, 'Vielen Dank!'), 'Outlook-Mailkopf (Gesendet vor Von): Verlauf wird abgetrennt: ' . json_encode([$new, substr($q, 0, 30)])); +[$new, $q] = mail_split_quoted_text("Danke, passt.\n\nVon: Support \nGesendet: Montag\nAn: Kunde\nBetreff: Re: Frage\n\nAlter Text"); +$check($new === 'Danke, passt.' && str_starts_with($q, 'Von: Support'), 'Mailkopf in der üblichen Reihenfolge (Von zuerst)'); +[$new, $q] = mail_split_quoted_text("Ja, bitte so machen.\n\nAm 02.11.2026 um 10:15 schrieb Anna Muster :\n> Können Sie das prüfen?\n> Danke"); +$check($new === 'Ja, bitte so machen.' && str_starts_with($q, 'Am 02.11.2026') && str_contains($q, '> Können'), '„Am … schrieb …:“ wird abgetrennt'); +[$new, $q] = mail_split_quoted_text("Ja.\n\nAm Montag, 2. November 2026 um 10:15 Uhr schrieb Anna Muster\n:\n\nText"); +$check($new === 'Ja.' && $q !== '', '„schrieb“ mit Umbruch in der Ankündigungszeile'); +[$new, $q] = mail_split_quoted_text("Yes, thanks.\n\nOn Mon, Nov 2, 2026 at 10:15 AM Anna wrote:\n> Please check"); +$check($new === 'Yes, thanks.' && str_starts_with($q, 'On Mon'), '„On … wrote:“ wird abgetrennt'); +[$new, $q] = mail_split_quoted_text("Antwort\n\n-----Ursprüngliche Nachricht-----\nVon: x\nText"); +$check($new === 'Antwort' && str_starts_with($q, '-----Urspr'), '„Ursprüngliche Nachricht“ wird abgetrennt'); +[$new, $q] = mail_split_quoted_text("Antwort\n> altes Zitat\n> noch eins"); +$check($new === 'Antwort' && str_starts_with($q, '> altes'), '">"-Zeilen ohne Ankündigung werden abgetrennt'); +[$new, $q] = mail_split_quoted_text("Von: Support \nGesendet: Montag\nAn: Kunde\nBetreff: Re\n\nText der weitergeleiteten Mail"); +$check($q === '' && str_starts_with($new, 'Von: Support'), 'ganz zitierte/weitergeleitete Mail ohne eigenen Text wird nicht getrennt'); +[$new, $q] = mail_split_quoted_text("Hallo,\n\nAn Ihrer Adresse hat sich nichts geändert.\nVon uns aus gibt es nichts Neues: bitte melden.\n\nGruß"); +$check($q === '' && $new === "Hallo,\n\nAn Ihrer Adresse hat sich nichts geändert.\nVon uns aus gibt es nichts Neues: bitte melden.\n\nGruß", 'normaler Text mit „An“ und „Von“ im Satz bleibt ungetrennt'); +[$new, $q] = mail_split_quoted_text("Nur Text ohne Zitat."); +$check($q === '' && $new === 'Nur Text ohne Zitat.', 'Text ohne Zitat bleibt unverändert'); +[$new, $q] = mail_split_quoted_text("Von: Anna\nIch melde mich morgen.\n\nBis dann"); +$check($q === '', 'eine einzelne „Von:“-Zeile ist noch kein Mailkopf'); + +// ── HTML ───────────────────────────────────────────────────────────────────── +$outlook = '

und de nächste Teil von Haus Ledwin


Gesendet: Sonntag, 11. Oktober 2026 um 07:42
Von: Windelgeschichten <einsendungen@windelgeschichten.org>
An: Olaf
Betreff: AW: [#923425] Haus Ledwin Teil 11

Hallo Olaf Andresen,

Vielen Dank!

'; +[$new, $q] = mail_split_quoted_html($outlook); +$check(str_contains($new, 'und de nächste Teil') && !str_contains($new, 'Gesendet') && str_contains($q, 'Gesendet:') && str_contains($q, 'Vielen Dank!'), 'Outlook-HTML: Linie und Mailkopf trennen den Verlauf ab'); +$check(substr_count($new, '') && substr_count($q, ''), 'beide Teile sind in sich ausgeglichen (offene Tags geschlossen)'); +$nested = '

Neu


Von: a
Gesendet: b
An: c
Betreff: d

alt

'; +[$new, $q] = mail_split_quoted_html($nested); +$check(substr_count($new, '') && !str_contains($q, '') || substr_count($q, '= 0, 'verschachtelte Blöcke werden nicht zerrissen'); +$check(trim(strip_tags($new)) === 'Neu' && str_contains(strip_tags($q), 'alt'), 'verschachtelt: neuer Teil „Neu“, Verlauf „alt“: ' . json_encode([strip_tags($new), strip_tags($q)])); +$border = '

Danke

Von: x
Gesendet: y
An: z
Betreff: w

alter Text

'; +[$new, $q] = mail_split_quoted_html($border); +$check(trim(strip_tags($new)) === 'Danke' && str_contains(strip_tags($q), 'alter Text'), 'Block mit oberem Rahmen (Outlook klassisch) trennt den Verlauf ab'); +[$new, $q] = mail_split_quoted_html('

Hallo


Nur eine Linie, kein Mailkopf

'); +$check($q === '', 'Linie ohne Mailkopf trennt nichts'); +[$new, $q] = mail_split_quoted_html('
Von: a
Gesendet: b
An: c
'); +$check($q === '', 'HTML ganz ohne eigenen Text davor wird nicht getrennt'); +[$new, $q] = mail_split_quoted_html('

Ohne Zitat

'); +$check($q === '' && $new === '

Ohne Zitat

', 'HTML ohne Zitat bleibt unverändert'); + +// Eingeklappter Block +$w = mail_quote_wrap('

alt

'); +$check(str_contains($w, 'email-quote-toggle') && str_contains($w, 'display:none') && str_contains($w, '

alt

'), 'Block ist standardmäßig zugeklappt'); +// Sicherheit: Skripte im zitierten Teil bleiben auch nach dem Trennen bereinigt (Eingabe ist bereits bereinigt) +[$new, $q] = mail_split_quoted_html(sanitize_staff_html('

Neu


Von: a
Gesendet: b
An: c
')); +$check(!str_contains($new . $q, ' ['path' => $tmp . '/uploads', 'max_size_bytes' => 100000, 'allowed_extensions' => ['png', 'pdf', 'txt', 'docx']]])); +require_once dirname(__DIR__) . '/includes/bootstrap.php'; +require_once dirname(__DIR__) . '/includes/mail_reload.php'; + +$failures = []; +$check = function (bool $ok, string $what) use (&$failures): void { + if (!$ok) $failures[] = $what; +}; +$pdo = get_db(); + +// Mail wie von Outlook: Text und HTML (alternative) + eingebettetes Logo (related) + Word-Datei (mixed) +$docx = random_bytes(900); +$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='); +$html = '

und de nächste Teil

Absatz zwei fett

Logox'; +$mail = "From: Olaf Andresen \r\nTo: einsendungen@example.org\r\nSubject: Aw: [#260101-0001] Haus Ledwin Teil 11\r\nMessage-ID: \r\nDate: Mon, 03 Nov 2026 00:39:00 +0100\r\nMIME-Version: 1.0\r\n" + . "Content-Type: multipart/mixed; boundary=\"BND1\"\r\n\r\n" + . "--BND1\r\nContent-Type: multipart/related; boundary=\"BND2\"\r\n\r\n" + . "--BND2\r\nContent-Type: multipart/alternative; boundary=\"BND3\"\r\n\r\n" + . "--BND3\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nund de n=C3=A4chste Teil\r\n" + . "--BND3\r\nContent-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n" . chunk_split(base64_encode($html)) + . "--BND3--\r\n" + . "--BND2\r\nContent-Type: image/png; name=\"logo.png\"\r\nContent-Disposition: inline; filename=\"logo.png\"\r\nContent-ID: \r\nContent-Transfer-Encoding: base64\r\n\r\n" . chunk_split(base64_encode($png)) + . "--BND2--\r\n" + . "--BND1\r\nContent-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document; name=\"Haus Ledwin .docx\"\r\nContent-Disposition: attachment; filename=\"Haus Ledwin .docx\"\r\nContent-Transfer-Encoding: base64\r\n\r\n" . chunk_split(base64_encode($docx)) + . "--BND1--\r\n"; +file_put_contents($tmp . '/mail.eml', $mail); + +// Server starten +$port = 0; +for ($try = 0; $try < 20 && !$port; $try++) { + $p = random_int(20000, 60000); + $s = @stream_socket_server('tcp://127.0.0.1:' . $p); + if ($s) { fclose($s); $port = $p; } +} +$logFile = $tmp . '/imap.log'; +$proc = proc_open([PHP_BINARY, __DIR__ . '/fixtures/fake_imap_server.php', (string) $port, $tmp . '/mail.eml', $logFile], [0 => ['file', 'php://stdin', 'r'], 1 => ['file', $tmp . '/srv.out', 'w'], 2 => ['file', $tmp . '/srv.err', 'w']], $pipes); +for ($i = 0; $i < 50; $i++) { + $c = @fsockopen('127.0.0.1', $port, $en, $es, 0.2); + if ($c) { fclose($c); break; } + usleep(100000); +} + +try { + $pdo->prepare("INSERT INTO imap_accounts (label, host, port, encryption, username, password_enc, folder) VALUES ('Test','127.0.0.1',?, 'none','u',?, 'INBOX')") + ->execute([$port, encrypt_secret('geheim')]); + $accountId = (int) $pdo->lastInsertId(); + $now = date('Y-m-d H:i:s'); + $pdo->exec("INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES ('260101-0001','Haus Ledwin','Olaf','olaf@example.de',1,2,'open','email','{$now}','{$now}')"); + $ticketId = (int) $pdo->lastInsertId(); + $early = date('Y-m-d H:i:s', time() - 3600); + $pdo->exec("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES ({$ticketId},'customer','erste Nachricht',0,'{$early}')"); + $firstThread = (int) $pdo->lastInsertId(); + $pdo->exec("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES ({$ticketId},'customer','und de nächste Teil',0,'{$now}')"); + $replyThread = (int) $pdo->lastInsertId(); + $pdo->exec("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES ({$ticketId},'staff','Antwort',0,'{$now}')"); + $pdo->prepare('INSERT INTO ticket_email_sources (ticket_id, imap_account_id, message_uid, message_id, current_folder, created_at) VALUES (?,?,?,?,?,?)') + ->execute([$ticketId, $accountId, 7, '', 'INBOX', $now]); + $ticket = $pdo->query("SELECT * FROM tickets WHERE id = {$ticketId}")->fetch(PDO::FETCH_ASSOC); + + // Zuordnung zur Nachricht + $threads = $pdo->query("SELECT id, created_at FROM ticket_threads WHERE ticket_id = {$ticketId} AND author_type = 'customer' ORDER BY id")->fetchAll(PDO::FETCH_ASSOC); + $check(mail_reload_pick_thread($threads, $now) === $replyThread, 'Mail wird der Nachricht zugeordnet, die zur Zeit des Abrufs entstand'); + $check(mail_reload_pick_thread($threads, $early) === $firstThread, 'frühe Mail gehört zur ersten Nachricht'); + + $r = reload_ticket_mail_attachments($pdo, $ticket, 1); + $check($r['error'] === null && $r['mails'] === 1 && $r['added'] === 1 && $r['inline'] === 1 && $r['html'] === 1 && $r['failed'] === [], 'Nachladen: 1 Anhang übernommen: ' . json_encode($r) . ' | Server: ' . @file_get_contents($tmp . '/srv.err')); + $rows = $pdo->query("SELECT * FROM ticket_attachments WHERE thread_id = {$replyThread}")->fetchAll(PDO::FETCH_ASSOC); + $files = array_values(array_filter($rows, fn($r) => (int) $r['is_inline'] === 0)); + $inl = array_values(array_filter($rows, fn($r) => (int) $r['is_inline'] === 1)); + $check(count($files) === 1 && $files[0]['original_name'] === 'Haus Ledwin .docx', 'Anhang hängt an der richtigen Nachricht: ' . json_encode($rows)); + $check(count($inl) === 1 && $inl[0]['content_id'] === 'logo1@example.de', 'das Logo ist als eingebettetes Bild gespeichert (nicht in der Anhangliste)'); + $check($inl && file_get_contents($tmp . '/uploads/' . $inl[0]['stored_name']) === $png, 'Inhalt des eingebetteten Bildes stimmt'); + $mh = (string) $pdo->query("SELECT message_html FROM ticket_threads WHERE id = {$replyThread}")->fetchColumn(); + $check(str_contains($mh, '

und de nächste Teil

') && str_contains($mh, 'fett') && str_contains($mh, 'cid:logo1@example.de'), 'HTML-Fassung der Nachricht ist gespeichert: ' . substr($mh, 0, 200)); + $check(!str_contains($mh, ' 'placeholder', 'cid_map' => ['logo1@example.de' => '/download.php?id=' . (int) $inl[0]['id'] . '&preview=1']]); + $check(str_contains($disp, 'src="/download.php?id=' . (int) $inl[0]['id'] . '&preview=1"') && !str_contains($disp, 'tracker.example.com') && str_contains($disp, 'data-blocked-remote'), 'Anzeige: eingebettetes Bild wird gezeigt, fremdes Bild nicht geladen: ' . $disp); + $check((int) $pdo->query("SELECT COUNT(*) FROM ticket_attachments WHERE thread_id = {$firstThread}")->fetchColumn() === 0, 'die erste Nachricht bekommt nichts'); + $check((int) $pdo->query("SELECT COUNT(*) FROM activity_log WHERE ticket_id = {$ticketId} AND action LIKE 'Anhänge und Bilder aus der E-Mail nachgeladen%'")->fetchColumn() === 1, 'Verlaufseintrag "nachgeladen"'); + + // Zweiter Durchlauf: nichts doppelt + $r2 = reload_ticket_mail_attachments($pdo, $ticket, 1); + $check($r2['added'] === 0 && $r2['inline'] === 0 && $r2['html'] === 0 && $r2['existing'] === 2, 'zweiter Durchlauf speichert nichts doppelt: ' . json_encode($r2)); + $check((int) $pdo->query("SELECT COUNT(*) FROM ticket_attachments WHERE thread_id = {$replyThread}")->fetchColumn() === 2, 'weiterhin genau ein Anhang und ein eingebettetes Bild'); + + // UID stimmt nicht mehr (Mail wurde verschoben): Suche über die Message-ID + $pdo->exec("UPDATE ticket_email_sources SET message_uid = 999 WHERE ticket_id = {$ticketId}"); + $pdo->exec("DELETE FROM ticket_attachments WHERE thread_id = {$replyThread}"); + $r3 = reload_ticket_mail_attachments($pdo, $ticket, 1); + $check($r3['added'] === 1 && $r3['inline'] === 1, 'Mail wird über die Message-ID gefunden, wenn die UID nicht mehr passt: ' . json_encode($r3)); + + // Mail nicht auffindbar (andere Message-ID, andere UID) + $pdo->exec("UPDATE ticket_email_sources SET message_uid = 999, message_id = '' WHERE ticket_id = {$ticketId}"); + $r4 = reload_ticket_mail_attachments($pdo, $ticket, 1); + $check($r4['added'] === 0 && count($r4['failed']) === 1 && str_contains($r4['failed'][0], 'nicht gefunden'), 'nicht auffindbare Mail wird gemeldet: ' . json_encode($r4)); + + // Kein Ticket aus E-Mail + $pdo->exec("DELETE FROM ticket_email_sources WHERE ticket_id = {$ticketId}"); + $r5 = reload_ticket_mail_attachments($pdo, $ticket, 1); + $check($r5['error'] !== null && str_contains($r5['error'], 'keine Original-E-Mail'), 'Ticket ohne gespeicherte Mail: verständliche Meldung'); +} finally { + $pdo->exec('DELETE FROM imap_accounts'); + if (is_resource($proc)) { proc_terminate($proc); proc_close($proc); } +} + +$rm = function (string $d) use (&$rm) { + foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); } + @rmdir($d); +}; +$logTxt = (string) @file_get_contents($logFile); +if ($failures) $failures[] = 'IMAP-Protokoll (Ende): ' . substr($logTxt, -1500); +$rm($tmp); + +if ($failures) { + fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} +echo "Mail reload checks passed.\n"; diff --git a/DeskHub-Webseite/tests/mail_reply_e2e.php b/DeskHub-Webseite/tests/mail_reply_e2e.php new file mode 100644 index 00000000..0537dd79 --- /dev/null +++ b/DeskHub-Webseite/tests/mail_reply_e2e.php @@ -0,0 +1,93 @@ + ['path' => $tmp . '/uploads', 'max_size_bytes' => 100000, 'allowed_extensions' => ['png', 'pdf', 'txt', 'docx']]])); +require_once dirname(__DIR__) . '/includes/bootstrap.php'; +require_once dirname(__DIR__) . '/includes/mail_fetch_manual.php'; + +$failures = []; +$check = function (bool $ok, string $what) use (&$failures): void { + if (!$ok) $failures[] = $what; +}; +$pdo = get_db(); + +$docx = random_bytes(700); +$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='); +$html = '

Hallo Team

Zweiter Absatz mit fett

Logo'; +$mail = "From: Olaf Andresen \r\nTo: einsendungen@example.org\r\nSubject: Aw: AW: [#923425] Haus Ledwin Teil 11\r\nMessage-ID: \r\nDate: Mon, 03 Nov 2026 00:39:00 +0100\r\nMIME-Version: 1.0\r\n" + . "Content-Type: multipart/mixed; boundary=\"BND1\"\r\n\r\n" + . "--BND1\r\nContent-Type: multipart/related; boundary=\"BND2\"\r\n\r\n" + . "--BND2\r\nContent-Type: multipart/alternative; boundary=\"BND3\"\r\n\r\n" + . "--BND3\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\nHallo Team\r\n\r\nZweiter Absatz mit fett\r\n" + . "--BND3\r\nContent-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n" . chunk_split(base64_encode($html)) + . "--BND3--\r\n" + . "--BND2\r\nContent-Type: image/png; name=\"logo.png\"\r\nContent-Disposition: inline; filename=\"logo.png\"\r\nContent-ID: \r\nContent-Transfer-Encoding: base64\r\n\r\n" . chunk_split(base64_encode($png)) + . "--BND2--\r\n" + . "--BND1\r\nContent-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document; name=\"Teil 12.docx\"\r\nContent-Disposition: attachment; filename=\"Teil 12.docx\"\r\nContent-Transfer-Encoding: base64\r\n\r\n" . chunk_split(base64_encode($docx)) + . "--BND1--\r\n"; +file_put_contents($tmp . '/mail.eml', $mail); + +$port = 0; +for ($try = 0; $try < 20 && !$port; $try++) { + $p = random_int(20000, 60000); + $s = @stream_socket_server('tcp://127.0.0.1:' . $p); + if ($s) { fclose($s); $port = $p; } +} +$proc = proc_open([PHP_BINARY, __DIR__ . '/fixtures/fake_imap_server.php', (string) $port, $tmp . '/mail.eml', $tmp . '/imap.log'], [0 => ['file', 'php://stdin', 'r'], 1 => ['file', $tmp . '/srv.out', 'w'], 2 => ['file', $tmp . '/srv.err', 'w']], $pipes); +for ($i = 0; $i < 50; $i++) { + $c = @fsockopen('127.0.0.1', $port, $en, $es, 0.2); + if ($c) { fclose($c); break; } + usleep(100000); +} + +try { + $deptId = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); + $pdo->prepare("INSERT INTO imap_accounts (label, host, port, encryption, username, password_enc, folder, after_fetch, department_id, priority_id, is_active) VALUES ('Test','127.0.0.1',?,'none','u',?,'INBOX','seen',?,2,1)") + ->execute([$port, encrypt_secret('geheim'), $deptId]); + $now = date('Y-m-d H:i:s'); + // Vorhandenes Ticket mit der alten Nummer, beantwortet (Status "answered") + $pdo->prepare("INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES ('923425','Haus Ledwin Teil 11','Olaf','olaf@example.de',?,2,'answered','email',?,?)")->execute([$deptId, $now, $now]); + $tid = (int) $pdo->lastInsertId(); + $pdo->exec("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES ({$tid},'customer','erste Nachricht',0,'{$now}')"); + $before = (int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn(); + + $r = mail_fetch_manual($pdo); + $check($r['ok'] && $r['processed'] === 1 && $r['replies'] === 1 && $r['new_tickets'] === 0, 'Abruf: 1 Antwort, kein neues Ticket: ' . json_encode($r) . ' | ' . @file_get_contents($tmp . '/srv.err')); + $check((int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn() === $before, 'es ist kein neues Ticket entstanden'); + $threads = $pdo->query("SELECT * FROM ticket_threads WHERE ticket_id = {$tid} ORDER BY id")->fetchAll(PDO::FETCH_ASSOC); + $check(count($threads) === 2 && $threads[1]['author_type'] === 'customer' && str_contains((string) $threads[1]['message'], 'Hallo Team'), 'Antwort hängt als Kundennachricht am Ticket'); + $check(trim((string) ($threads[1]['message_html'] ?? '')) !== '', 'HTML-Fassung der Antwort gespeichert'); + $att = $pdo->query('SELECT original_name, is_inline FROM ticket_attachments WHERE thread_id = ' . (int) ($threads[1]['id'] ?? 0) . ' ORDER BY is_inline')->fetchAll(PDO::FETCH_ASSOC); + $check(count($att) === 2 && $att[0]['original_name'] === 'Teil 12.docx', 'Anhang und Bild hängen an der Antwort: ' . json_encode($att)); + $check((string) $pdo->query("SELECT status FROM tickets WHERE id = {$tid}")->fetchColumn() === 'open', 'das beantwortete Ticket ist wieder offen'); + $check(str_contains(implode(" +", array_column($pdo->query("SELECT action FROM activity_log WHERE ticket_id = {$tid}")->fetchAll(PDO::FETCH_ASSOC), 'action')), 'Antwort per E-Mail empfangen'), 'Verlaufseintrag "Antwort per E-Mail empfangen"'); +} finally { + if (is_resource($proc)) { proc_terminate($proc); proc_close($proc); } +} + +$logTxt = (string) @file_get_contents($tmp . '/imap.log'); +$rm = function (string $d) use (&$rm) { + foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); } + @rmdir($d); +}; +if ($failures) $failures[] = 'IMAP-Protokoll (Ende): ' . substr($logTxt, -1200); +$rm($tmp); + +if ($failures) { + fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} +echo "Mail fetch end-to-end checks passed.\n"; diff --git a/DeskHub-Webseite/tests/mail_threading.php b/DeskHub-Webseite/tests/mail_threading.php new file mode 100644 index 00000000..95656775 --- /dev/null +++ b/DeskHub-Webseite/tests/mail_threading.php @@ -0,0 +1,85 @@ +prepare("INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES (?,?,?,?,1,2,'open','email',?,?)") + ->execute([$number, 'Test ' . $number, 'Kunde', $email, $now, $now]); + $id = (int) $pdo->lastInsertId(); + return $pdo->query("SELECT * FROM tickets WHERE id = {$id}")->fetch(PDO::FETCH_ASSOC); +}; +$a = $mk('260101-0001', 'olaf@example.de'); +$legacy = $mk('923425', 'olaf@example.de'); +$b = $mk('260101-0002', 'anna@example.de'); +$pdo->prepare('INSERT INTO ticket_email_sources (ticket_id, imap_account_id, message_uid, message_id, current_folder, created_at) VALUES (?,?,?,?,?,?)') + ->execute([(int) $b['id'], 1, 5, '', 'INBOX', $now]); + +// Kopfzeilen lesen +$raw = "From: x\r\nIn-Reply-To: \r\nReferences: \r\n \r\n\t\r\nSubject: s\r\n"; +$ids = mail_reference_ids($raw); +$check($ids === ['abc@x.de', 'r1@x.de', 'r2@x.de', 'r3@x.de'], 'In-Reply-To und gefaltete References werden gelesen: ' . json_encode($ids)); +$check(mail_reference_ids("Subject: nur\r\n") === [], 'ohne Kopfzeilen keine IDs'); + +// Message-ID mit Ticketnummer: erzeugen und wieder lesen +$mid = mail_ticket_message_id('Neue Antwort [#260101-0001] Ihr Ticket', 'support.example.de'); +$check($mid !== null && preg_match('/^$/', $mid) === 1, 'Message-ID mit Nummer: ' . $mid); +$check(mail_ticket_number_from_message_id(trim((string) $mid, '<>')) === '260101-0001', 'Nummer wird aus der Message-ID gelesen'); +$check(mail_ticket_message_id('Ohne Nummer', 'x.de') === null, 'Betreff ohne Nummer: keine Message-ID'); +$check(mail_ticket_number_from_message_id('irgendwas@mail.example.de') === null, 'fremde Message-ID enthält keine Nummer'); + +// 1. Nummer im Betreff (DeskHub-Format): wie bisher ohne Absenderprüfung +$r = mail_find_ticket_for_reply($pdo, 'AW: [#260101-0001] Frage', '', 'jemand-anders@example.org'); +$check($r && (int) $r['ticket']['id'] === (int) $a['id'] && $r['via'] === 'subject', 'DeskHub-Nummer im Betreff ordnet zu, wie bisher'); + +// 2. Andere Nummer im Betreff +$r = mail_find_ticket_for_reply($pdo, 'Aw: AW: [#923425] Haus Ledwin Teil 11', '', 'Olaf@Example.de'); +$check($r && (int) $r['ticket']['id'] === (int) $legacy['id'] && $r['via'] === 'legacy', 'fremde Nummer (923425) ordnet zu, wenn der Absender der Auftraggeber ist: ' . json_encode($r['via'] ?? null)); +$check(mail_find_ticket_for_reply($pdo, 'Aw: [#923425] x', '', 'fremder@example.org') === null, 'fremde Nummer von einem fremden Absender wird nicht zugeordnet'); +$check(mail_find_ticket_for_reply($pdo, 'Aw: [#99999999] x', '', 'olaf@example.de') === null, 'Nummer, die es nicht gibt: neues Ticket'); + +// 3. Kopfzeilen +$r = mail_find_ticket_for_reply($pdo, 'Betreff geändert', "References: \r\n", 'olaf@example.de'); +$check($r && (int) $r['ticket']['id'] === (int) $a['id'] && $r['via'] === 'header', 'DeskHub-Message-ID in References ordnet zu, auch bei geändertem Betreff'); +$check(mail_find_ticket_for_reply($pdo, 'Betreff geändert', "In-Reply-To: \r\n", 'fremder@example.org') === null, 'DeskHub-Message-ID von fremdem Absender wird nicht zugeordnet'); +$r = mail_find_ticket_for_reply($pdo, 'Neuer Betreff', "In-Reply-To: \r\n", 'ANNA@example.de'); +$check($r && (int) $r['ticket']['id'] === (int) $b['id'] && $r['via'] === 'header', 'Message-ID einer früher abgerufenen Mail ordnet zu (Groß-/Kleinschreibung egal)'); +$check(mail_find_ticket_for_reply($pdo, 'Neuer Betreff', "In-Reply-To: \r\n", 'olaf@example.de') === null, 'Mail-ID eines fremden Tickets: Absender passt nicht'); +$check(mail_find_ticket_for_reply($pdo, 'Hallo', '', 'olaf@example.de') === null, 'ohne Nummer und ohne Kopfzeilen: neues Ticket'); +$check(mail_find_ticket_for_reply($pdo, 'Hallo', "In-Reply-To: \r\n", 'olaf@example.de') === null, 'unbekannte Message-ID: neues Ticket'); + +// Früherer Verfasser darf antworten +$pdo->exec("INSERT INTO users (name, email, password_hash, created_at) VALUES ('Kollegin','kollegin@example.de','x','{$now}')"); +$uid = (int) $pdo->lastInsertId(); +$pdo->exec("INSERT INTO ticket_threads (ticket_id, author_type, user_id, message, is_internal_note, created_at) VALUES ({$a['id']},'customer',{$uid},'x',0,'{$now}')"); +$check(mail_sender_belongs_to_ticket($pdo, $a, 'kollegin@example.de'), 'früherer Verfasser einer Kundennachricht gehört zum Ticket'); +$check(!mail_sender_belongs_to_ticket($pdo, $a, ''), 'leerer Absender gehört nirgends hin'); + +// Ausgehende Mail: Header mit Message-ID (nur wenn der Betreff eine Nummer hat) +require_once dirname(__DIR__) . '/includes/smtp_mailer.php'; +$mailer = new SmtpMailer('localhost', 25, 'none', '', ''); +$m = new ReflectionMethod($mailer, 'buildHeaders'); +$m->setAccessible(true); +$h1 = (string) $m->invoke($mailer, 'kunde@example.de', 'Support ', 'Antwort auf Ihr Ticket [#260101-0001]'); +$h2 = (string) $m->invoke($mailer, 'kunde@example.de', 'Support ', 'Willkommen'); +$check(preg_match('/^Message-ID: ?$/m', $h1) === 1, 'ausgehende Ticketmail trägt Message-ID mit Nummer und Domain des Absenders'); +$check(!str_contains($h2, 'Message-ID'), 'Mail ohne Ticketnummer bekommt keine eigene Message-ID'); + +if ($failures) { + fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} +echo "Mail threading checks passed.\n"; diff --git a/DeskHub-Webseite/tests/message_html_detect.php b/DeskHub-Webseite/tests/message_html_detect.php new file mode 100644 index 00000000..cd102a0b --- /dev/null +++ b/DeskHub-Webseite/tests/message_html_detect.php @@ -0,0 +1,42 @@ +") ist kein HTML. + * Wurde sie dafür gehalten, zeigte DeskHub den Text als HTML an und alle Zeilenumbrüche gingen verloren. + */ + +putenv('DESKHUB_TESTING=1'); +putenv('DESKHUB_SQLITE_PATH=:memory:'); +require_once dirname(__DIR__) . '/includes/bootstrap.php'; + +$failures = []; +$check = function (bool $ok, string $what) use (&$failures): void { + if (!$ok) $failures[] = $what; +}; + +$text = [ + "und de nächste Teil\n\nGesendet: Sonntag\nVon: Windelgeschichten \nAn: \"Olaf Andresen\" ", + 'Antwort an Olaf ', + 'Bitte an senden', + 'Kontakt: ', + 'wenn a < b und c > d gilt', + 'Preis <10 € und >5 €', + 'Link: ', + '', // kein erlaubtes Tag: wird als Text (maskiert) angezeigt, also sicher +]; +foreach ($text as $t) $check(!message_is_html($t), 'kein HTML: ' . substr($t, 0, 50)); + +$html = [ + '

Hallo

', 'Zeile 1
Zeile 2', 'Zeile 1
Zeile 2', 'Zeile 1
Zeile 2', '
x
', + 'Link', 'Text

', 'fett', '', '
x
', + '

Hallo

', 'Text y', '
  • a
', 'image', + "\n\n

x

", +]; +foreach ($html as $h) $check(message_is_html($h), 'HTML: ' . substr($h, 0, 50)); + +if ($failures) { + fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} +echo "Message HTML detection checks passed.\n";