Upload via GUI (396 Dateien)

This commit is contained in:
2026-11-04 07:55:36 +00:00
parent 65d42108db
commit 58a8fab9f5
11 changed files with 1035 additions and 1 deletions
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
/**
* Prüft die Schnittstellen der Apps zum E-Mail-Abruf von Hand und zum Nachladen von Mail-Anhängen: Ohne Postfach eine klare Meldung,
* nur für E-Mail-Tickets, Rechte, und dass eingebettete Bilder nicht in der Anhangliste der Ticket-Schnittstelle stehen.
*/
date_default_timezone_set('Europe/Berlin'); // wie in der Test-Installation
$root = dirname(__DIR__);
$work = sys_get_temp_dir() . '/deskhub-apimail-' . bin2hex(random_bytes(4));
$tmp = $work . '/inst';
mkdir($tmp, 0755, true);
$webPort = random_int(36500, 36999);
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) {
$rel = str_replace(chr(92), '/', substr($f->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', '<?php return ' . var_export([
'app_key' => 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";
+1 -1
View File
@@ -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);
+152
View File
@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
/**
* Kleiner IMAP-Server für Tests (nur das, was PHP-imap/c-client für Abruf und Anhänge braucht): php fake_imap_server.php <Port> <Mail-Datei> [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] ?? '<x@test>';
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);
}
+146
View File
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
/**
* Prüft die Anhänge abgerufener E-Mails: Anhänge werden erkannt (auch mit kodierten Dateinamen), eingebettete Signaturbilder nicht,
* die Dateien landen als Anhang der Nachricht im Ticket, und was nicht übernommen wird (Typ nicht erlaubt, zu groß), steht im Verlauf.
*/
$uploads = sys_get_temp_dir() . '/deskhub-mailatt-' . bin2hex(random_bytes(4));
mkdir($uploads, 0755, true);
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_SQLITE_PATH=:memory:');
putenv('DESKHUB_CONFIG_JSON=' . json_encode(['uploads' => ['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 = '<logo1@example.de>';
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('<html><script>alert(1)</script></html>') : '', $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";
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
/**
* Ganzer Weg des E-Mail-Abrufs gegen einen kleinen IMAP-Server (tests/fixtures/fake_imap_server.php), gestartet über den Abruf von Hand:
* Aus der Mail wird ein Ticket mit Text (samt Absätzen), HTML-Fassung, eingebettetem Bild und Anhang; die Mail wird als gelesen markiert,
* ein zweiter Abruf findet nichts mehr, und der Abruf von Hand lässt sich nicht kurz hintereinander doppelt starten.
*/
if (!function_exists('imap_open')) { echo "Abruf (ganzer Weg): übersprungen, PHP-IMAP fehlt.\n"; exit(0); }
$tmp = sys_get_temp_dir() . '/deskhub-e2e-' . bin2hex(random_bytes(4));
mkdir($tmp . '/uploads', 0755, true);
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_SQLITE_PATH=:memory:');
putenv('DESKHUB_CONFIG_JSON=' . json_encode(['uploads' => ['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 = '<html><body><p>Hallo Team</p><p>Zweiter Absatz mit <b>fett</b></p><img src="cid:logo1@example.de" alt="Logo"></body></html>';
$mail = "From: Olaf Andresen <olaf@example.de>\r\nTo: einsendungen@example.org\r\nSubject: Neue Geschichte Teil 12\r\nMessage-ID: <e2e1@example.de>\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: <logo1@example.de>\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'] ?? ''), '<b>fett</b>') && 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'] === '<e2e1@example.de>' && (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";
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
/**
* Prüft den E-Mail-Abruf von Hand (Knopf in der Ticketliste, includes/mail_fetch_manual.php): ohne Postfach eine Meldung, mit Postfach
* läuft der Abruf genau einmal, ein zweiter Klick innerhalb der Sperrzeit startet keinen zweiten Lauf, und das Ergebnis (neue Tickets,
* Antworten, Fehler) wird verständlich zusammengefasst.
*/
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_SQLITE_PATH=:memory:');
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();
$calls = 0;
$runner = function (array $out) use (&$calls): callable {
return function () use (&$calls, $out): array { $calls++; return $out; };
};
$reset = fn() => $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";
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/**
* Prüft das Einklappen des zitierten Verlaufs in E-Mails (includes/mail_text.php): Text und HTML, Outlook, Gmail/Thunderbird-Stil,
* ">"-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 <einsendungen@windelgeschichten.org>\nAn: \"Olaf Andresen\" <olaf@gmx.de>\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 <s@x.de>\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 <anna@example.de>:\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<anna@example.de>:\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 <anna@example.com> 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 <s@x.de>\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 = '<div><p>und de nächste Teil von Haus Ledwin</p></div><hr style="display:inline-block;width:98%"><div><p><b>Gesendet:</b> Sonntag, 11. Oktober 2026 um 07:42<br><b>Von:</b> Windelgeschichten &lt;einsendungen@windelgeschichten.org&gt;<br><b>An:</b> Olaf<br><b>Betreff:</b> AW: [#923425] Haus Ledwin Teil 11</p><h3>Hallo Olaf Andresen,</h3><p>Vielen Dank!</p></div>';
[$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, '<div') === substr_count($new, '</div>') && substr_count($q, '<div') === substr_count($q, '</div>'), 'beide Teile sind in sich ausgeglichen (offene Tags geschlossen)');
$nested = '<div class="x"><p>Neu</p><hr><div><b>Von:</b> a<br><b>Gesendet:</b> b<br><b>An:</b> c<br><b>Betreff:</b> d</div><p>alt</p></div>';
[$new, $q] = mail_split_quoted_html($nested);
$check(substr_count($new, '<div') === substr_count($new, '</div>') && !str_contains($q, '</div></div>') || substr_count($q, '<div') >= 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 = '<p>Danke</p><div style="border-top: solid 1px"><p><b>Von:</b> x<br><b>Gesendet:</b> y<br><b>An:</b> z<br><b>Betreff:</b> w</p></div><p>alter Text</p>';
[$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('<p>Hallo</p><hr><p>Nur eine Linie, kein Mailkopf</p>');
$check($q === '', 'Linie ohne Mailkopf trennt nichts');
[$new, $q] = mail_split_quoted_html('<hr><div><b>Von:</b> a<br><b>Gesendet:</b> b<br><b>An:</b> c</div>');
$check($q === '', 'HTML ganz ohne eigenen Text davor wird nicht getrennt');
[$new, $q] = mail_split_quoted_html('<p>Ohne Zitat</p>');
$check($q === '' && $new === '<p>Ohne Zitat</p>', 'HTML ohne Zitat bleibt unverändert');
// Eingeklappter Block
$w = mail_quote_wrap('<p>alt</p>');
$check(str_contains($w, 'email-quote-toggle') && str_contains($w, 'display:none') && str_contains($w, '<p>alt</p>'), '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('<p>Neu</p><hr><div><b>Von:</b> a<br><b>Gesendet:</b> b<br><b>An:</b> c</div><script>alert(1)</script><img src="x" onerror="alert(2)">'));
$check(!str_contains($new . $q, '<script') && !str_contains($new . $q, 'onerror'), 'keine Skripte nach dem Trennen');
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Mail quote split checks passed.\n";
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
/**
* Prüft das Nachladen von Anhängen aus dem Postfach (includes/mail_reload.php) gegen einen kleinen IMAP-Server
* (tests/fixtures/fake_imap_server.php): Die Mail wird über UID bzw. Message-ID gefunden, der Anhang kommt ins Ticket, ein zweiter
* Durchlauf speichert nichts doppelt, und die Fehlerfälle (Mail nicht da, Postfach nicht erreichbar) werden gemeldet.
*/
if (!function_exists('imap_open')) { echo "Mail-Nachladen: übersprungen, PHP-IMAP fehlt.\n"; exit(0); }
$tmp = sys_get_temp_dir() . '/deskhub-reload-' . bin2hex(random_bytes(4));
mkdir($tmp . '/uploads', 0755, true);
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_SQLITE_PATH=:memory:');
putenv('DESKHUB_CONFIG_JSON=' . json_encode(['uploads' => ['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 = '<html><head><style>p{color:red}</style></head><body><p>und de n&auml;chste Teil</p><p>Absatz zwei <b>fett</b></p><img src="cid:logo1@example.de" alt="Logo"><img src="https://tracker.example.com/pixel.gif" alt="x"><script>alert(1)</script></body></html>';
$mail = "From: Olaf Andresen <olaf@example.de>\r\nTo: einsendungen@example.org\r\nSubject: Aw: [#260101-0001] Haus Ledwin Teil 11\r\nMessage-ID: <abc123@example.de>\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: <logo1@example.de>\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, '<abc123@example.de>', '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, '<p>und de n&auml;chste Teil</p>') && str_contains($mh, '<b>fett</b>') && str_contains($mh, 'cid:logo1@example.de'), 'HTML-Fassung der Nachricht ist gespeichert: ' . substr($mh, 0, 200));
$check(!str_contains($mh, '<script') && !str_contains($mh, 'alert(1)') && !str_contains($mh, '<style') && !str_contains($mh, 'color:red'), 'Skripte und Stilblöcke sind aus der gespeicherten HTML-Fassung entfernt');
$check($files && is_file($tmp . '/uploads/' . $files[0]['stored_name']) && file_get_contents($tmp . '/uploads/' . $files[0]['stored_name']) === $docx, 'Dateiinhalt stimmt mit dem Anhang in der Mail überein');
// Anzeige: cid-Bild zeigt auf die gespeicherte Datei, fremdes Bild wird nicht geladen
$disp = sanitize_staff_html($mh, ['remote_images' => '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'] . '&amp;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 = '<gibtesnicht@example.de>' 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";
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
/**
* Antworten beim E-Mail-Abruf (gegen den kleinen IMAP-Server): Eine Mail mit der Nummer eines früheren Ticketsystems im Betreff
* ("Aw: AW: [#923425] …") wird als Antwort an das Ticket mit dieser Nummer gehängt (samt Anhang), nicht als neues Ticket; ebenso eine Mail
* mit geändertem Betreff, die in References die Message-ID einer DeskHub-Mail nennt. Von einem fremden Absender entsteht ein neues Ticket.
*/
if (!function_exists('imap_open')) { echo "Abruf (ganzer Weg): übersprungen, PHP-IMAP fehlt.\n"; exit(0); }
$tmp = sys_get_temp_dir() . '/deskhub-reply-' . bin2hex(random_bytes(4));
mkdir($tmp . '/uploads', 0755, true);
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_SQLITE_PATH=:memory:');
putenv('DESKHUB_CONFIG_JSON=' . json_encode(['uploads' => ['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 = '<html><body><p>Hallo Team</p><p>Zweiter Absatz mit <b>fett</b></p><img src="cid:logo1@example.de" alt="Logo"></body></html>';
$mail = "From: Olaf Andresen <olaf@example.de>\r\nTo: einsendungen@example.org\r\nSubject: Aw: AW: [#923425] Haus Ledwin Teil 11\r\nMessage-ID: <reply1@example.de>\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: <logo1@example.de>\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";
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
/**
* Prüft die Zuordnung eingehender Mails zu Tickets (includes/mail_threading.php): Nummer im Betreff, fremde Nummer im Betreff,
* Kopfzeilen In-Reply-To/References, Absenderprüfung, und dass ausgehende Mails zu einem Ticket eine Message-ID mit der Nummer tragen.
*/
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;
};
$pdo = get_db();
$now = date('Y-m-d H:i:s');
$mk = function (string $number, string $email) use ($pdo, $now): array {
$pdo->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, '<Orig-Mail-77@Mail.Example.De>', 'INBOX', $now]);
// Kopfzeilen lesen
$raw = "From: x\r\nIn-Reply-To: <abc@x.de>\r\nReferences: <r1@x.de>\r\n <R2@x.de>\r\n\t<r3@x.de>\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('/^<deskhub\.260101-0001\.[0-9a-f]{16}@support\.example\.de>$/', $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: <deskhub.260101-0001.0123456789abcdef@support.example.de>\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: <deskhub.260101-0001.0123456789abcdef@support.example.de>\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: <orig-mail-77@mail.example.de>\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: <orig-mail-77@mail.example.de>\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: <unbekannt@x.de>\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 <support@firma.example>', 'Antwort auf Ihr Ticket [#260101-0001]');
$h2 = (string) $m->invoke($mailer, 'kunde@example.de', 'Support <support@firma.example>', 'Willkommen');
$check(preg_match('/^Message-ID: <deskhub\.260101-0001\.[0-9a-f]{16}@firma\.example> ?$/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";
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/**
* Prüft die Erkennung von HTML in Nachrichten: Eine Adresse in spitzen Klammern ("Von: Name <name@example.de>") 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 <einsendungen@windelgeschichten.org>\nAn: \"Olaf Andresen\" <olaf.andresen@gmx.de>",
'Antwort an Olaf <olaf@gmx.de>',
'Bitte an <p.muster@example.de> senden',
'Kontakt: <b@example.de>',
'wenn a < b und c > d gilt',
'Preis <10 € und >5 €',
'Link: <https://example.de/seite>',
'<script>alert(1)</script>', // 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 = [
'<p>Hallo</p>', 'Zeile 1<br>Zeile 2', 'Zeile 1<br/>Zeile 2', 'Zeile 1<br />Zeile 2', '<div style="color:red">x</div>',
'<a href="https://example.de">Link</a>', 'Text</p>', '<B>fett</B>', '<o:p></o:p>', '<table><tr><td>x</td></tr></table>',
'<h3>Hallo</h3>', 'Text <span class="x">y</span>', '<ul><li>a</li></ul>', '<img src="cid:x" alt="image">',
"<html>\n<body>\n<p>x</p></body></html>",
];
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";