['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";