136 lines
11 KiB
PHP
136 lines
11 KiB
PHP
<?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ä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ä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'] . '&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";
|