diff --git a/DeskHub-Webseite/includes/mail_text.php b/DeskHub-Webseite/includes/mail_text.php new file mode 100644 index 00000000..0ccd1d41 --- /dev/null +++ b/DeskHub-Webseite/includes/mail_text.php @@ -0,0 +1,170 @@ +]+charset[^>]*>/i', '', $html) ?? $html; + $prev = libxml_use_internal_errors(true); + $dom = new DOMDocument(); + $ok = $dom->loadHTML('' . $html, LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_NONET); + libxml_clear_errors(); + libxml_use_internal_errors($prev); + if (!$ok || !$dom->documentElement) return mail_html_to_text_regex($html); + + $blocks = ['p', 'div', 'section', 'article', 'header', 'footer', 'table', 'tr', 'ul', 'ol', 'blockquote', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'address', 'center', 'form', 'dl', 'dt', 'dd']; + $skip = ['script', 'style', 'head', 'title', 'noscript', 'template', 'meta', 'link', 'svg', 'object', 'embed', 'iframe', 'canvas', 'map']; + $out = ''; + // Zeilenwechsel für Blöcke und Listen: nie doppelt hintereinander (ein
dagegen erzeugt immer einen) + $newline = function () use (&$out): void { + if ($out !== '' && !str_ends_with(rtrim($out, ' '), " +")) $out = rtrim($out, ' ') . " +"; + }; + $walk = function (DOMNode $node) use (&$walk, &$out, $blocks, $skip, $newline): void { + foreach ($node->childNodes as $child) { + if ($child instanceof DOMText) { + // Umbrüche und Einrückung im Quelltext sind keine Zeilenwechsel + $out .= preg_replace('/[ \t\r\n\f]+/', ' ', $child->nodeValue ?? ''); + } elseif ($child instanceof DOMElement) { + $tag = strtolower($child->tagName); + if (in_array($tag, $skip, true)) continue; + if ($child->hasAttribute('hidden') || mail_style_hides($child->getAttribute('style'))) continue; + if ($tag === 'br') { $out .= "\n"; continue; } + if ($tag === 'img') { $alt = trim($child->getAttribute('alt')); if ($alt !== '') $out .= ' ' . $alt . ' '; continue; } + $block = in_array($tag, $blocks, true); + if ($block) $newline(); + if ($tag === 'li') { $newline(); $out .= '• '; } + if ($tag === 'td' || $tag === 'th') $out .= ' '; + $walk($child); + if ($tag === 'td' || $tag === 'th') $out .= ' '; + if ($block || $tag === 'li') $newline(); + } + } + }; + $walk($dom->documentElement); + return $out; +} + +function mail_html_to_text_regex(string $html): string +{ + $html = preg_replace('~~s', '', $html) ?? $html; + $html = preg_replace('~<(script|style|head|title|noscript|template)\b[^>]*>.*?~is', '', $html) ?? $html; + $html = preg_replace('~~i', "\n", $html) ?? $html; + $html = preg_replace('~~i', "\n", $html) ?? $html; + $html = preg_replace('~]*>~i', "\n• ", $html) ?? $html; + return html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'); +} + +/** Räumt Text auf: unsichtbare Zeichen, geschützte Leerzeichen, überzählige Leerzeilen. */ +function mail_clean_text(string $text): string +{ + $text = str_replace(["\r\n", "\r"], "\n", $text); + $text = str_replace("\u{00A0}", ' ', $text); + $text = preg_replace('/[' . preg_quote(MAIL_INVISIBLE_CHARS, '/') . ']+/u', '', $text) ?? $text; + $lines = []; + foreach (explode("\n", $text) as $line) { + $lines[] = trim(preg_replace('/[ \t]{2,}/', ' ', $line) ?? $line); + } + $text = implode("\n", $lines); + $text = preg_replace("/\n{3,}/", "\n\n", $text) ?? $text; + return trim($text); +} + +/** Dekodiert den Inhalt eines Mail-Teils (Transferkodierung und Zeichensatz) nach UTF-8. */ +function mail_decode_part(string $raw, int $encoding, string $charset): string +{ + $data = match ($encoding) { + 3 => (string) base64_decode(preg_replace('/\s+/', '', $raw) ?? $raw, false), + 4 => quoted_printable_decode($raw), + default => $raw, + }; + $charset = strtolower(trim($charset)); + if ($charset === '' || $charset === 'utf-8' || $charset === 'utf8' || $charset === 'us-ascii') { + return mb_check_encoding($data, 'UTF-8') ? $data : (string) mb_convert_encoding($data, 'UTF-8', 'ISO-8859-1'); + } + try { + return (string) mb_convert_encoding($data, 'UTF-8', $charset); + } catch (Throwable) { + return mb_check_encoding($data, 'UTF-8') ? $data : (string) mb_convert_encoding($data, 'UTF-8', 'ISO-8859-1'); + } +} + +/** + * Sammelt die Text-Teile (text/plain, text/html) einer IMAP-Struktur, ohne Anhänge. + * + * @return array + */ +function mail_collect_text_parts(object $part, string $no = ''): array +{ + $found = []; + $isMulti = !empty($part->parts) && is_array($part->parts); + if ($isMulti) { + foreach ($part->parts as $i => $sub) { + $found = array_merge($found, mail_collect_text_parts($sub, $no === '' ? (string) ($i + 1) : $no . '.' . ($i + 1))); + } + return $found; + } + // type 0 = text + if ((int) ($part->type ?? -1) !== 0) return []; + $disposition = strtolower((string) ($part->disposition ?? '')); + $charset = ''; + $filename = false; + foreach (array_merge((array) ($part->parameters ?? []), (array) ($part->dparameters ?? [])) as $p) { + $attr = strtolower((string) ($p->attribute ?? '')); + if ($attr === 'charset') $charset = (string) $p->value; + if ($attr === 'name' || $attr === 'filename') $filename = true; + } + if ($disposition === 'attachment' || $filename) return []; + $found[] = ['no' => $no === '' ? '1' : $no, 'subtype' => strtoupper((string) ($part->subtype ?? 'PLAIN')), 'encoding' => (int) ($part->encoding ?? 0), 'charset' => $charset]; + return $found; +} + +/** + * Bester Text einer Mail: bevorzugt text/plain; ist der nur eine kurze Floskel ("Bitte HTML-Ansicht verwenden") und die HTML-Fassung + * deutlich länger, wird die HTML-Fassung als Text genommen. + * + * @param callable(string):string $fetchPart liefert den rohen Inhalt eines Teils (imap_fetchbody) + */ +function mail_best_text(object $structure, callable $fetchPart): string +{ + $plain = ''; + $html = ''; + $havePlain = false; + $haveHtml = false; + foreach (mail_collect_text_parts($structure) as $p) { + if ($p['subtype'] === 'PLAIN' && !$havePlain) { + $plain = mail_clean_text(mail_decode_part($fetchPart($p['no']), $p['encoding'], $p['charset'])); + $havePlain = true; + } elseif ($p['subtype'] === 'HTML' && !$haveHtml) { + $html = mail_html_to_text(mail_decode_part($fetchPart($p['no']), $p['encoding'], $p['charset'])); + $haveHtml = true; + } + } + if ($plain !== '' && (mb_strlen($plain) >= 40 || $html === '' || mb_strlen($html) <= mb_strlen($plain) * 2)) return $plain; + return $html !== '' ? $html : $plain; +}