442 lines
23 KiB
PHP
442 lines
23 KiB
PHP
<?php
|
|
/**
|
|
* includes/mail_text.php
|
|
*
|
|
* Macht aus einer eingehenden E-Mail (IMAP-Struktur) lesbaren Text für das Ticket: Es wird der Text-Teil bevorzugt; gibt es nur HTML
|
|
* (Newsletter, Benachrichtigungen), wird es so umgewandelt, dass nur sichtbarer Text bleibt. Stil- und Skriptblöcke, versteckte
|
|
* Vorschautexte, Kommentare und Sonderzeichen für Leerräume (typisch bei Newslettern) fallen weg, Absätze und Listen bleiben als
|
|
* Zeilen erhalten. Ohne diese Umwandlung stand der CSS-Code der Mail im Ticket.
|
|
*/
|
|
|
|
/** Zeichen, die in Newslettern als unsichtbare Füllung dienen (Breite null, weiche Trennung, Kombinationszeichen ohne Inhalt). */
|
|
const MAIL_INVISIBLE_CHARS = "\u{200B}\u{200C}\u{200D}\u{200E}\u{200F}\u{2060}\u{FEFF}\u{00AD}\u{034F}\u{061C}\u{115F}\u{1160}\u{17B4}\u{17B5}\u{180E}";
|
|
|
|
/** Ob das style-Attribut ein Element unsichtbar macht (Vorschautext-Tricks, Abstandshalter). */
|
|
function mail_style_hides(string $style): bool
|
|
{
|
|
return (bool) preg_match('/display\s*:\s*none|visibility\s*:\s*hidden|mso-hide\s*:\s*all|max-height\s*:\s*0(?:px|pt)?\s*(?:;|$)|font-size\s*:\s*0(?:px|pt)?\s*(?:;|$)|opacity\s*:\s*0(?:\.0+)?\s*(?:;|$)/i', $style);
|
|
}
|
|
|
|
/** Wandelt HTML in lesbaren Text um (UTF-8). */
|
|
function mail_html_to_text(string $html): string
|
|
{
|
|
if (trim($html) === '') return '';
|
|
$text = class_exists('DOMDocument') ? mail_html_to_text_dom($html) : mail_html_to_text_regex($html);
|
|
return mail_clean_text($text);
|
|
}
|
|
|
|
function mail_html_to_text_dom(string $html): string
|
|
{
|
|
// Zeichensatz-Angabe der Mail soll das Einlesen nicht stören: wir übergeben bereits UTF-8
|
|
$html = preg_replace('/<meta[^>]+charset[^>]*>/i', '', $html) ?? $html;
|
|
$prev = libxml_use_internal_errors(true);
|
|
$dom = new DOMDocument();
|
|
$ok = $dom->loadHTML('<?xml encoding="UTF-8">' . $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 <br> 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[^>]*>.*?</\1\s*>~is', '', $html) ?? $html;
|
|
$html = preg_replace('~<br\s*/?>~i', "\n", $html) ?? $html;
|
|
$html = preg_replace('~</(p|div|tr|li|h[1-6]|table|ul|ol|blockquote)\s*>~i', "\n", $html) ?? $html;
|
|
$html = preg_replace('~<li\b[^>]*>~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<int,array{no:string,subtype:string,encoding:int,charset:string}>
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/** Dateiname eines Anhangs: MIME-Kodierung (=?UTF-8?B?…?=) und RFC 2231 (UTF-8''…) werden aufgelöst. */
|
|
function mail_decode_filename(string $value): string
|
|
{
|
|
$value = trim($value);
|
|
if (preg_match("/^([A-Za-z0-9_-]+)'[^']*'(.*)$/", $value, $m)) {
|
|
$decoded = rawurldecode($m[2]);
|
|
$cs = strtolower($m[1]);
|
|
if ($cs !== 'utf-8' && $cs !== 'utf8') {
|
|
try { $decoded = (string) mb_convert_encoding($decoded, 'UTF-8', $cs); } catch (Throwable) { /* bleibt */ }
|
|
}
|
|
return $decoded;
|
|
}
|
|
if (str_contains($value, '=?')) {
|
|
$decoded = function_exists('iconv_mime_decode') ? @iconv_mime_decode($value, 0, 'UTF-8') : false;
|
|
if ($decoded === false || $decoded === '') $decoded = mb_decode_mimeheader($value);
|
|
return (string) $decoded;
|
|
}
|
|
return mb_check_encoding($value, 'UTF-8') ? $value : (string) mb_convert_encoding($value, 'UTF-8', 'ISO-8859-1');
|
|
}
|
|
|
|
/**
|
|
* Sammelt die Anhänge einer IMAP-Struktur: Teile mit Dateinamen oder der Anordnung "attachment" sowie weitergeleitete Mails
|
|
* (message/rfc822). Bilder mit Content-ID, die im Text der Mail verwendet werden (Logos, Signaturbilder, eingefügte Fotos), sind als
|
|
* "inline" gekennzeichnet: Sie gehören zur Darstellung der Mail und werden nur dann als Anhang gezeigt, wenn es keine HTML-Fassung gibt.
|
|
*
|
|
* @return array<int,array{no:string,name:string,encoding:int,bytes:int,inline:bool,cid:string,eml:bool}>
|
|
*/
|
|
function mail_collect_attachment_parts(object $part, string $no = ''): array
|
|
{
|
|
$type = (int) ($part->type ?? -1);
|
|
if (!empty($part->parts) && is_array($part->parts) && $type !== 2) {
|
|
$found = [];
|
|
foreach ($part->parts as $i => $sub) {
|
|
$found = array_merge($found, mail_collect_attachment_parts($sub, $no === '' ? (string) ($i + 1) : $no . '.' . ($i + 1)));
|
|
}
|
|
return $found;
|
|
}
|
|
$disposition = strtolower((string) ($part->disposition ?? ''));
|
|
$name = '';
|
|
foreach (array_merge((array) ($part->dparameters ?? []), (array) ($part->parameters ?? [])) as $p) {
|
|
$attr = strtolower((string) ($p->attribute ?? ''));
|
|
if ($name === '' && ($attr === 'filename' || $attr === 'filename*' || $attr === 'name' || $attr === 'name*')) $name = mail_decode_filename((string) $p->value);
|
|
}
|
|
$cid = strtolower(trim((string) ($part->id ?? ''), " \t\r\n<>"));
|
|
$isEml = $type === 2;
|
|
$inline = $type === 5 && $disposition !== 'attachment' && !empty($part->ifid) && $cid !== '';
|
|
if (!$isEml && !$inline && $name === '' && $disposition !== 'attachment') return [];
|
|
if ($name === '') {
|
|
if ($isEml) $name = 'Weitergeleitete E-Mail.eml';
|
|
else $name = 'Anhang' . (!empty($part->subtype) ? '.' . strtolower((string) $part->subtype) : '');
|
|
}
|
|
return [['no' => $no === '' ? '1' : $no, 'name' => $name, 'encoding' => (int) ($part->encoding ?? 0), 'bytes' => (int) ($part->bytes ?? 0), 'inline' => $inline, 'cid' => $cid, 'eml' => $isEml]];
|
|
}
|
|
|
|
/** Dateiendung für ein Rasterbild anhand seines Inhalts (nur gif, jpg, png, webp), sonst null: HTML oder Skripte im Bild-Gewand fallen so durch. */
|
|
function mail_image_extension(string $data): ?string
|
|
{
|
|
$info = @getimagesizefromstring($data);
|
|
if (!$info) return null;
|
|
return match ($info[2] ?? 0) {
|
|
IMAGETYPE_GIF => 'gif',
|
|
IMAGETYPE_JPEG => 'jpg',
|
|
IMAGETYPE_PNG => 'png',
|
|
IMAGETYPE_WEBP => 'webp',
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
/** Name einer weitergeleiteten Mail (.eml) aus ihrem Betreff. */
|
|
function mail_eml_name(string $raw, string $fallback): string
|
|
{
|
|
if (preg_match('/^Subject:[ \t]*(.*(?:\r?\n[ \t]+.*)*)/mi', substr($raw, 0, 8192), $m)) {
|
|
$subject = trim(mail_decode_filename(preg_replace('/\r?\n[ \t]+/', ' ', $m[1]) ?? ''));
|
|
$subject = trim((string) preg_replace('~[\\\\/:*?"<>|\x00-\x1F]+~u', ' ', $subject));
|
|
if ($subject !== '') return mb_substr($subject, 0, 120) . '.eml';
|
|
}
|
|
return $fallback;
|
|
}
|
|
|
|
/**
|
|
* HTML-Fassung einer Mail für die Anzeige im Ticket (bereinigt: nur erlaubte Tags und Eigenschaften), null ohne HTML-Teil.
|
|
*
|
|
* @param callable(string):string $fetchPart liefert den rohen Inhalt eines Teils (imap_fetchbody)
|
|
*/
|
|
function mail_html_for_display(object $structure, callable $fetchPart): ?string
|
|
{
|
|
foreach (mail_collect_text_parts($structure) as $p) {
|
|
if ($p['subtype'] !== 'HTML') continue;
|
|
$html = mail_decode_part($fetchPart($p['no']), $p['encoding'], $p['charset']);
|
|
if (trim($html) === '' || strlen($html) > 3000000) return null;
|
|
$clean = sanitize_staff_html($html);
|
|
if (trim(strip_tags($clean)) === '' && stripos($clean, '<img') === false) return null;
|
|
return strlen($clean) > 400000 ? null : $clean;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Rohdaten eines Mail-Teils nach der Transferkodierung (Anhänge sind Binärdaten, kein Zeichensatz). */
|
|
function mail_decode_binary(string $raw, int $encoding): string
|
|
{
|
|
return match ($encoding) {
|
|
3 => (string) base64_decode(preg_replace('/\s+/', '', $raw) ?? $raw, false),
|
|
4 => quoted_printable_decode($raw),
|
|
default => $raw,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Übernimmt die Anhänge einer Mail an die Nachricht $threadId des Tickets. Nicht übernommene Anhänge (Typ nicht erlaubt, zu groß)
|
|
* werden im Verlauf des Tickets vermerkt, damit niemand glaubt, die Mail hätte keinen gehabt.
|
|
*
|
|
* @param callable(string):string $fetchPart liefert den rohen Inhalt eines Teils (imap_fetchbody)
|
|
* @param bool $skipExisting Anhänge, die an dieser Nachricht schon hängen (gleicher Name und gleiche Größe), nicht noch einmal speichern
|
|
* @param bool $inlineImages die Mail hat eine HTML-Fassung: Bilder mit Content-ID werden als eingebettete Bilder gespeichert (und nicht als
|
|
* Anhang aufgelistet); sonst zählen sie wie jeder andere Anhang
|
|
* @return array{added:int,inline:int,skipped:int,existing:int}
|
|
*/
|
|
function mail_store_attachments(PDO $pdo, ?object $structure, callable $fetchPart, int $threadId, int $ticketId, string $label, array &$log, bool $skipExisting = false, bool $inlineImages = false): array
|
|
{
|
|
$count = ['added' => 0, 'inline' => 0, 'skipped' => 0, 'existing' => 0];
|
|
if (!$structure || $threadId <= 0) return $count;
|
|
$max = get_max_upload_size();
|
|
$parts = mail_collect_attachment_parts($structure);
|
|
$imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
|
|
foreach (array_slice($parts, 0, 40) as $p) {
|
|
$reason = null;
|
|
$name = $p['name'];
|
|
$asInline = $inlineImages && $p['inline'];
|
|
// Vorab nach Größe prüfen, ohne den Teil zu laden (Base64 ist rund ein Drittel größer als die Datei)
|
|
$tooBig = $p['bytes'] > 0 && (int) ($p['encoding'] === 3 ? $p['bytes'] * 0.75 : $p['bytes']) > $max;
|
|
if ($tooBig) $reason = 'zu groß (max. ' . round($max / 1048576) . ' MB)';
|
|
if ($reason === null) {
|
|
$raw = $fetchPart($p['no']);
|
|
$data = $p['eml'] ? $raw : mail_decode_binary($raw, $p['encoding']);
|
|
$opts = ['extra_allowed' => mail_attachment_extra_extensions()];
|
|
if ($p['eml']) {
|
|
$name = mail_eml_name($data, $name);
|
|
if (!str_ends_with(strtolower($name), '.eml')) $name .= '.eml';
|
|
$opts['extra_allowed'][] = 'eml';
|
|
} elseif ($asInline) {
|
|
$ext = mail_image_extension($data);
|
|
if ($ext === null) {
|
|
$log[] = "[$label] → Eingebettetes Bild übersprungen (kein Bild): {$name}";
|
|
continue;
|
|
}
|
|
if (!in_array(strtolower(pathinfo($name, PATHINFO_EXTENSION)), $imageExts, true)) $name = 'bild-' . substr(md5($p['cid']), 0, 8) . '.' . $ext;
|
|
$opts = ['extra_allowed' => $imageExts, 'content_id' => $p['cid'], 'inline' => true];
|
|
}
|
|
if ($skipExisting) {
|
|
if ($asInline) {
|
|
$dup = $pdo->prepare('SELECT COUNT(*) FROM ticket_attachments WHERE thread_id = ? AND content_id = ?');
|
|
$dup->execute([$threadId, mb_substr($p['cid'], 0, 255)]);
|
|
} else {
|
|
$dup = $pdo->prepare('SELECT COUNT(*) FROM ticket_attachments WHERE thread_id = ? AND original_name = ? AND filesize = ?');
|
|
$dup->execute([$threadId, mb_substr(trim(basename(str_replace(chr(92), '/', $name))), 0, 255), strlen($data)]);
|
|
}
|
|
if ((int) $dup->fetchColumn() > 0) {
|
|
$count['existing']++;
|
|
continue;
|
|
}
|
|
}
|
|
$res = store_ticket_attachment_data($pdo, $threadId, $name, $data, $opts);
|
|
if ($res['ok']) {
|
|
$log[] = "[$label] → " . ($asInline ? 'Eingebettetes Bild' : 'Anhang') . " übernommen: {$res['name']}";
|
|
$count[$asInline ? 'inline' : 'added']++;
|
|
continue;
|
|
}
|
|
$reason = (string) ($res['error'] ?? 'unbekannter Fehler');
|
|
}
|
|
if ($asInline) {
|
|
$log[] = "[$label] → Eingebettetes Bild nicht übernommen: {$name} ({$reason})";
|
|
continue;
|
|
}
|
|
$count['skipped']++;
|
|
$log[] = "[$label] → Anhang nicht übernommen: {$name} ({$reason})";
|
|
log_activity($pdo, $ticketId, null, 'Anhang aus E-Mail nicht übernommen: ' . mb_substr($name, 0, 80) . ' (' . mb_substr($reason, 0, 100) . ')');
|
|
}
|
|
if (count($parts) > 40) log_activity($pdo, $ticketId, null, 'Anhänge aus E-Mail nicht übernommen: mehr als 40 Teile, die übrigen fehlen');
|
|
return $count;
|
|
}
|
|
|
|
/** Kopfzeilen-Schlüssel eines zitierten Mailkopfs ("Von: …", "Gesendet: …") am Zeilenanfang. */
|
|
const MAIL_QUOTE_HEADER_KEYS = '(?:Von|From|Gesendet|Sent|Datum|Date|An|To|Cc|Betreff|Subject)';
|
|
|
|
/**
|
|
* Trennt eine Nachricht (Text) in den neuen Teil und den zitierten Verlauf früherer Mails. Der Verlauf beginnt bei der ersten
|
|
* Zeile, die ein Zitat ankündigt: "Am … schrieb …:", "On … wrote:", "-----Ursprüngliche Nachricht-----", ein zitierter Mailkopf
|
|
* ("Von: … Gesendet: … An: … Betreff: …") oder eine mit ">" beginnende Zeile. Steht vor dieser Stelle kein Text (die ganze Mail ist
|
|
* zitiert oder weitergeleitet), wird nichts getrennt.
|
|
*
|
|
* @return array{0:string,1:string} [neuer Teil, zitierter Verlauf (leer, wenn es keinen gibt)]
|
|
*/
|
|
function mail_split_quoted_text(string $text): array
|
|
{
|
|
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
|
$lines = explode("\n", $text);
|
|
$n = count($lines);
|
|
for ($i = 0; $i < $n; $i++) {
|
|
$line = $lines[$i];
|
|
$isMarker = false;
|
|
if (preg_match('/^\s*-{2,}\s*(Original Message|Ursprüngliche Nachricht|Urspr\. Nachricht|Weitergeleitete Nachricht|Forwarded message)\s*-{2,}\s*$/iu', $line)) {
|
|
$isMarker = true;
|
|
} elseif (preg_match('/^\s*(Am|On)\s.+/iu', $line)) {
|
|
$joined = $line . ' ' . ($lines[$i + 1] ?? '');
|
|
if (preg_match('/^\s*(Am|On)\s.{5,200}\s(schrieb|wrote)\b.*:\s*$/iu', $joined) || preg_match('/^\s*(Am|On)\s.{5,200}\s(schrieb|wrote)\b.*:\s*$/iu', $line)) $isMarker = true;
|
|
} elseif (preg_match('/^\s*>/', $line)) {
|
|
$isMarker = true;
|
|
} elseif (preg_match('/^\s*' . MAIL_QUOTE_HEADER_KEYS . '\s*:/iu', $line)) {
|
|
// Zitierter Mailkopf: mehrere solche Zeilen hintereinander, darunter "Von"
|
|
$keys = [];
|
|
for ($j = $i; $j < min($n, $i + 7); $j++) {
|
|
if (preg_match('/^\s*(' . MAIL_QUOTE_HEADER_KEYS . ')\s*:/iu', $lines[$j], $m)) $keys[] = mb_strtolower($m[1]);
|
|
elseif (trim($lines[$j]) !== '') break;
|
|
}
|
|
if (count($keys) >= 3 && (in_array('von', $keys, true) || in_array('from', $keys, true))) $isMarker = true;
|
|
}
|
|
if (!$isMarker) continue;
|
|
$body = trim(implode("\n", array_slice($lines, 0, $i)));
|
|
if ($body === '') return [$text, ''];
|
|
return [$body, trim(implode("\n", array_slice($lines, $i)))];
|
|
}
|
|
return [$text, ''];
|
|
}
|
|
|
|
/** Bringt ein HTML-Bruchstück in Ordnung (offene Tags schließen, überzählige schließende Tags entfernen). */
|
|
function mail_balance_html(string $html): string
|
|
{
|
|
if (trim($html) === '' || !class_exists('DOMDocument')) return $html;
|
|
$prev = libxml_use_internal_errors(true);
|
|
$dom = new DOMDocument();
|
|
$ok = $dom->loadHTML('<?xml encoding="UTF-8"><div id="dh-root">' . $html . '</div>', LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_NONET | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
|
libxml_clear_errors();
|
|
libxml_use_internal_errors($prev);
|
|
if (!$ok) return $html;
|
|
$root = $dom->getElementById('dh-root');
|
|
if (!$root) return $html;
|
|
$out = '';
|
|
foreach ($root->childNodes as $child) $out .= $dom->saveHTML($child);
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Trennt bereinigtes Mail-HTML in den neuen Teil und den zitierten Verlauf. Gesucht wird die Trennlinie (<hr>) oder der Block mit
|
|
* oberem Rahmen, den Outlook vor dem zitierten Mailkopf setzt, und danach der Mailkopf mit mindestens drei Angaben (Von, Gesendet, An …).
|
|
*
|
|
* @return array{0:string,1:string}
|
|
*/
|
|
function mail_split_quoted_html(string $html): array
|
|
{
|
|
if (!preg_match_all('/<hr\b[^>]*>|<div\b[^>]*border-top[^>]*>/i', $html, $matches, PREG_OFFSET_CAPTURE)) return [$html, ''];
|
|
foreach ($matches[0] as [$tag, $offset]) {
|
|
$after = html_entity_decode(preg_replace('/<[^>]*>/', ' ', substr($html, $offset, 1500)) ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$after = preg_replace('/\s+/u', ' ', $after) ?? $after;
|
|
preg_match_all('/(?:^|\s)(' . MAIL_QUOTE_HEADER_KEYS . ')\s*:/iu', mb_substr($after, 0, 600), $m);
|
|
$keys = array_map('mb_strtolower', $m[1] ?? []);
|
|
if (count($keys) < 3 || !(in_array('von', $keys, true) || in_array('from', $keys, true))) continue;
|
|
$head = substr($html, 0, $offset);
|
|
if (trim(strip_tags($head)) === '' && stripos($head, '<img') === false) return [$html, ''];
|
|
$a = mail_balance_html($head);
|
|
$b = mail_balance_html(substr($html, $offset));
|
|
return [$a, $b];
|
|
}
|
|
return [$html, ''];
|
|
}
|
|
|
|
/** Zuklappbarer Block für zitierten Verlauf (derselbe Aufbau wie bei eingeklappten Zitaten). */
|
|
function mail_quote_wrap(string $contentHtml): string
|
|
{
|
|
return '<div class="email-quote-wrap"><button type="button" class="email-quote-toggle" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display===\'none\'?\'block\':\'none\';this.textContent=this.nextElementSibling.style.display===\'none\'?\'▶ Verlauf anzeigen\':\'▼ Verlauf ausblenden\'">▶ Verlauf anzeigen</button><div class="email-quoted" style="display:none">' . $contentHtml . '</div></div>';
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|