Files
2026-11-04 07:55:19 +00:00

105 lines
5.4 KiB
PHP

<?php
/**
* includes/mail_threading.php
*
* Ordnet eine eingehende Mail einem bestehenden Ticket zu (Antwort statt neues Ticket):
* 1. Ticketnummer im Betreff "[#260101-0001]" (so wie bisher, ohne weitere Prüfung);
* 2. andere Nummern im Betreff "[#923425]" (zum Beispiel aus einem früheren Ticketsystem), wenn es ein Ticket mit genau dieser Nummer gibt;
* 3. die Kopfzeilen In-Reply-To und References: DeskHub schreibt in jede Mail zu einem Ticket eine Message-ID mit der Ticketnummer
* (<deskhub.260101-0001.…@host>), die Mailprogramme in der Antwort mitschicken. Außerdem zählen die Message-IDs früher
* abgerufener Mails des Tickets.
* Bei 2 und 3 (die nicht schon durch die DeskHub-Nummer im Betreff gesichert sind) muss der Absender zum Ticket gehören
* (Auftraggeber oder früherer Verfasser), damit niemand mit einer geratenen Nummer fremden Tickets Nachrichten unterschieben kann.
*/
/** Message-IDs aus In-Reply-To und References, klein und ohne spitze Klammern. @return string[] */
function mail_reference_ids(string $rawHeader): array
{
$ids = [];
// Kopfzeilen können gefaltet sein (Fortsetzungszeilen beginnen mit Leerraum)
$unfolded = preg_replace('/\r?\n[ \t]+/', ' ', $rawHeader) ?? $rawHeader;
foreach (['In-Reply-To', 'References'] as $name) {
if (preg_match_all('/^' . $name . ':(.*)$/mi', $unfolded, $m)) {
foreach ($m[1] as $value) {
if (preg_match_all('/<([^<>\s]+)>/', $value, $mm)) foreach ($mm[1] as $id) $ids[] = strtolower(trim($id));
}
}
}
return array_values(array_unique($ids));
}
/** Ticketnummer aus einer von DeskHub erzeugten Message-ID (<deskhub.NUMMER.zufall@host>) oder null. */
function mail_ticket_number_from_message_id(string $id): ?string
{
return preg_match('/^deskhub\.([a-z0-9-]{4,24})\.[a-z0-9]+@/i', $id, $m) ? $m[1] : null;
}
/** Message-ID für eine ausgehende Mail zu einem Ticket (Nummer steht im Betreff als [#NUMMER]); null, wenn der Betreff keine Nummer enthält. */
function mail_ticket_message_id(string $subject, string $host): ?string
{
if (!preg_match('/\[#([A-Za-z0-9-]{4,24})\]/', $subject, $m)) return null;
$host = preg_replace('/[^A-Za-z0-9.-]/', '', $host) ?: 'localhost';
return '<deskhub.' . $m[1] . '.' . bin2hex(random_bytes(8)) . '@' . $host . '>';
}
/** Gehört der Absender zum Ticket (Auftraggeber oder früherer Verfasser einer Kundennachricht)? */
function mail_sender_belongs_to_ticket(PDO $pdo, array $ticket, string $fromEmail): bool
{
$from = strtolower(trim($fromEmail));
if ($from === '') return false;
if (strtolower(trim((string) ($ticket['requester_email'] ?? ''))) === $from) return true;
try {
$stmt = $pdo->prepare("SELECT 1 FROM ticket_threads tt JOIN users u ON u.id = tt.user_id WHERE tt.ticket_id = ? AND tt.author_type = 'customer' AND LOWER(u.email) = ? LIMIT 1");
$stmt->execute([(int) $ticket['id'], $from]);
return (bool) $stmt->fetchColumn();
} catch (Throwable $e) {
return false;
}
}
/**
* Sucht das Ticket zu einer eingehenden Mail.
*
* @return array{ticket:array,via:string}|null via: subject (DeskHub-Nummer), legacy (andere Nummer im Betreff), header (In-Reply-To/References)
*/
function mail_find_ticket_for_reply(PDO $pdo, string $subject, string $rawHeader, string $fromEmail): ?array
{
$byNumber = $pdo->prepare('SELECT * FROM tickets WHERE LOWER(ticket_number) = LOWER(?)');
// 1. DeskHub-Nummer im Betreff wie bisher
if (preg_match('/\[#([0-9]{6}-[0-9]{4})\]/', $subject, $m)) {
$byNumber->execute([$m[1]]);
if ($t = $byNumber->fetch()) return ['ticket' => $t, 'via' => 'subject'];
}
// 2. Andere Nummer im Betreff (genau diese Nummer muss es als Ticket geben)
if (preg_match_all('/\[#([A-Za-z0-9-]{4,24})\]/', $subject, $all)) {
foreach ($all[1] as $num) {
$byNumber->execute([$num]);
if (($t = $byNumber->fetch()) && mail_sender_belongs_to_ticket($pdo, $t, $fromEmail)) return ['ticket' => $t, 'via' => 'legacy'];
}
}
// 3. Kopfzeilen
$ids = mail_reference_ids($rawHeader);
foreach ($ids as $id) {
$num = mail_ticket_number_from_message_id($id);
if ($num !== null) {
$byNumber->execute([$num]);
if (($t = $byNumber->fetch()) && mail_sender_belongs_to_ticket($pdo, $t, $fromEmail)) return ['ticket' => $t, 'via' => 'header'];
}
}
if ($ids) {
// Message-IDs früher abgerufener Mails des Tickets (gespeichert mit oder ohne spitze Klammern): grob in SQL vorfiltern, genau in PHP prüfen
$ids = array_slice($ids, 0, 15);
$likes = implode(' OR ', array_fill(0, count($ids), 'LOWER(message_id) LIKE ?'));
$stmt = $pdo->prepare("SELECT ticket_id, message_id FROM ticket_email_sources WHERE message_id IS NOT NULL AND ({$likes}) ORDER BY id DESC LIMIT 50");
$stmt->execute(array_map(fn($id) => '%' . str_replace(['%', '_'], ['\%', '\_'], $id) . '%', $ids));
foreach ($stmt->fetchAll() as $row) {
if (!in_array(strtolower(trim((string) $row['message_id'], '<> ')), $ids, true)) continue;
$byId = $pdo->prepare('SELECT * FROM tickets WHERE id = ?');
$byId->execute([(int) $row['ticket_id']]);
if (($t = $byId->fetch()) && mail_sender_belongs_to_ticket($pdo, $t, $fromEmail)) return ['ticket' => $t, 'via' => 'header'];
}
}
return null;
}