to ?? []), (array) ($header->cc ?? []), ); foreach ($recipients as $r) { $addr = strtolower(trim(($r->mailbox ?? '') . '@' . ($r->host ?? ''))); if ($addr === '@') continue; $stmt = $pdo->prepare( 'SELECT department_id, priority_id, help_topic_id FROM email_addresses WHERE LOWER(email) = ? AND is_active = 1 LIMIT 1' ); $stmt->execute([$addr]); $row = $stmt->fetch(); if ($row) return $row; } return null; } /** Sucht den zuletzt aktiven Bearbeiter des gleichen Kunden (Auto-Zuweisung) */ function find_previous_assignee(PDO $pdo, string $requesterEmail): ?int { $stmt = $pdo->prepare( "SELECT assigned_staff_id FROM tickets WHERE LOWER(requester_email) = LOWER(?) AND assigned_staff_id IS NOT NULL AND status NOT IN ('closed','resolved') ORDER BY updated_at DESC LIMIT 1" ); $stmt->execute([$requesterEmail]); $id = $stmt->fetchColumn(); if (!$id) return null; // Mitarbeiter im Urlaub nicht zuweisen $vac = $pdo->prepare('SELECT is_on_vacation, vacation_substitute_id FROM staff WHERE id = ? AND is_active = 1'); $vac->execute([(int)$id]); $row = $vac->fetch(); if (!$row) return null; if ($row['is_on_vacation']) { // Vertretung zurückgeben falls gesetzt return $row['vacation_substitute_id'] ? (int)$row['vacation_substitute_id'] : null; } return (int) $id; } /** * Ruft neue E-Mails aus ALLEN aktiven Postfächern ab. Kann vom Cron und * manuell aus settings.php aufgerufen werden. * * @return array{processed: int, log: string[]} */ function run_fetch_emails(PDO $pdo, bool $verbose = false): array { $log = []; $accounts = $pdo->query('SELECT * FROM imap_accounts WHERE is_active = 1 ORDER BY id')->fetchAll(); if (!$accounts) { $log[] = "[" . date('Y-m-d H:i:s') . "] Kein aktives Postfach eingerichtet. Siehe Einstellungen → E-Mail-Adressen → Postfächer (Abruf)."; return ['processed' => 0, 'log' => $log]; } if (!function_exists('imap_open')) { $log[] = 'PHP-IMAP-Erweiterung nicht installiert (php-imap). Abbruch.'; return ['processed' => 0, 'log' => $log]; } $totalProcessed = 0; foreach ($accounts as $account) { $result = fetch_emails_for_account($pdo, $account, $verbose); $log = array_merge($log, $result['log']); $totalProcessed += $result['processed']; } set_setting('cron_fetch_emails_last_run', db_now()); if ($verbose) { $log[] = "[" . date('Y-m-d H:i:s') . "] Fertig. Insgesamt {$totalProcessed} E-Mail(s) über " . count($accounts) . " Postfach/Postfächer verarbeitet."; } return ['processed' => $totalProcessed, 'log' => $log]; } /** Ruft ein einzelnes Postfach ab und verarbeitet dessen neue Mails. */ function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): array { $log = []; $label = $account['label'] ?: $account['username']; $sslFlag = match ($account['encryption']) { 'ssl' => '/imap/ssl', 'tls' => '/imap/tls', default => '/imap/notls', }; $mailbox = '{' . $account['host'] . ':' . $account['port'] . $sslFlag . '}' . $account['folder']; $mbox = @imap_open($mailbox, $account['username'], decrypt_secret($account['password_enc'])); if (!$mbox) { $imapError = imap_last_error() ?: 'unbekannter Fehler'; $log[] = "[$label] IMAP-Verbindung fehlgeschlagen: " . $imapError; $pdo->prepare('UPDATE imap_accounts SET last_run_at = ? WHERE id = ?')->execute([db_now(), $account['id']]); health_record_mail_result($pdo, $account, $imapError); return ['processed' => 0, 'log' => $log]; } $pdo->prepare('UPDATE imap_accounts SET last_run_at = ? WHERE id = ?')->execute([db_now(), $account['id']]); health_record_mail_result($pdo, $account, null); $afterFetch = in_array($account['after_fetch'] ?? 'seen', ['seen','archive','delete'], true) ? $account['after_fetch'] : 'seen'; $archiveFolder = $account['archive_folder'] ?: 'Processed'; $emails = imap_search($mbox, 'UNSEEN'); if (!$emails) { $log[] = "[$label] Keine neuen E-Mails."; imap_close($mbox); return ['processed' => 0, 'log' => $log]; } $processed = 0; foreach ($emails as $emailNum) { $header = imap_headerinfo($mbox, $emailNum); $fromEmail = $header->from[0]->mailbox . '@' . $header->from[0]->host; $fromName = $header->from[0]->personal ?? $fromEmail; $subject = $header->subject ? imap_utf8($header->subject) : '(kein Betreff)'; $messageUid = (int) @imap_uid($mbox, $emailNum); $messageId = trim((string)($header->message_id ?? '')); // Bei "archive" liegt die Mail nach dem Abruf im Archivordner; bei "seen" bleibt sie im Quellordner. // Bei "delete" bleibt die Zuordnung zwar erhalten, die Originalmail ist danach jedoch nicht mehr verschiebbar. $sourceFolderAfterFetch = $afterFetch === 'archive' ? $archiveFolder : $account['folder']; // Mails, die DeskHub selbst verschickt hat (zum Beispiel SLA-Meldungen an eine Postfach-Adresse), dürfen keine Tickets erzeugen if (mail_is_own_system_mail($pdo, (string) $fromEmail, (string) @imap_fetchheader($mbox, $emailNum))) { $log[] = "[$label] Mail von {$fromEmail} ignoriert (eigene Systemmail, würde sonst ein Ticket erzeugen): {$subject}"; handle_after_fetch($mbox, $emailNum, $afterFetch, $archiveFolder); $processed++; continue; } // Nachrichtentext: Text-Teil bevorzugt, bei reinen HTML-Mails (Newsletter) nur der sichtbare Text ohne CSS und Skripte $structure = @imap_fetchstructure($mbox, $emailNum); $body = $structure ? mail_best_text($structure, fn(string $no) => (string) imap_fetchbody($mbox, $emailNum, $no)) : ''; if (trim($body) === '') { // Unbekannter Aufbau: wie bisher den ersten Teil lesen, aber ebenfalls als Text aufbereiten $raw = quoted_printable_decode((string) imap_fetchbody($mbox, $emailNum, '1')); $body = mail_html_to_text($raw !== '' ? $raw : (string) imap_fetchbody($mbox, $emailNum, '1.1')); } if (trim($body) === '') $body = '(kein Inhalt)'; // ── Antwort auf bestehendes Ticket? ────────────────────────────────── if (preg_match('/\[#([0-9]{6}-[0-9]{4})\]/', $subject, $m)) { $ticketNumber = $m[1]; $stmt = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ?'); $stmt->execute([$ticketNumber]); $ticket = $stmt->fetch(); if ($ticket) { $now = db_now(); $pdo->prepare( 'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)' )->execute([$ticket['id'], 'customer', $body, $now]); $newStatus = in_array($ticket['status'], ['answered', 'resolved', 'pending']) ? 'open' : $ticket['status']; $pdo->prepare('UPDATE tickets SET status=?, updated_at=? WHERE id=?')->execute([$newStatus, $now, $ticket['id']]); ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus); log_activity($pdo, $ticket['id'], null, 'Antwort per E-Mail empfangen'); notify_new_reply_channels($ticket, $body); webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'customer']); $pdo->prepare( 'INSERT INTO ticket_email_sources (ticket_id, imap_account_id, message_uid, message_id, current_folder, created_at) VALUES (?,?,?,?,?,?)' )->execute([$ticket['id'], $account['id'], $messageUid ?: null, $messageId ?: null, $sourceFolderAfterFetch, $now]); $log[] = "[$label] Antwort zu Ticket #{$ticketNumber} hinzugefügt (von {$fromEmail})."; handle_after_fetch($mbox, $emailNum, $afterFetch, $archiveFolder); $processed++; continue; } } // ── Neues Ticket erstellen ──────────────────────────────────────────── // Sperrliste prüfen if (is_banned($fromEmail)) { $log[] = "[$label] Neues Ticket von {$fromEmail} verworfen (Sperrliste)."; handle_after_fetch($mbox, $emailNum, $afterFetch, $archiveFolder); $processed++; continue; } // Abteilung/Priorität: 1) feste Postfach-Zuordnung, 2) Empfängeradresse (Routing-Tabelle), 3) globale Standardwerte $cfgBase = config()['imap']; if ($account['department_id']) { $defaultDeptId = (int) $account['department_id']; } else { $defaultDeptId = (int) get_setting('default_department_id', (string) ($cfgBase['default_department_id'] ?? 1)); } $defaultPrioId = $account['priority_id'] ? (int) $account['priority_id'] : (int) ($cfgBase['default_priority_id'] ?? 2); $defaultTopicId = null; // Empfängeradresse kann die Postfach-Standardabteilung zusätzlich überschreiben // (z.B. Sammelpostfach mit mehreren Aliassen, die auf verschiedene Abteilungen zeigen). $recipientMatch = resolve_dept_from_recipient($pdo, $header); if ($recipientMatch) { $defaultDeptId = (int) $recipientMatch['department_id']; if ($recipientMatch['priority_id']) $defaultPrioId = (int) $recipientMatch['priority_id']; if (!empty($recipientMatch['help_topic_id'])) $defaultTopicId = (int) $recipientMatch['help_topic_id']; $log[] = "[$label] → Abteilung aus Empfängeradresse: Dept-ID {$defaultDeptId}"; } // Ticket-Regeln (können Abteilung/Priorität nochmal überschreiben) $ruleResult = evaluate_ticket_rules($pdo, [ 'subject' => $subject, 'message' => $body, 'requester_email' => $fromEmail, 'requester_name' => $fromName, 'source' => 'email', ]); if ($ruleResult['reject']) { $log[] = "[$label] Neues Ticket von {$fromEmail} verworfen (Ticket-Regel: " . implode(', ', $ruleResult['matched_rules']) . ")."; handle_after_fetch($mbox, $emailNum, $afterFetch, $archiveFolder); $processed++; continue; } if ($ruleResult['set_department'] !== null) $defaultDeptId = $ruleResult['set_department']; if ($ruleResult['set_priority'] !== null) $defaultPrioId = $ruleResult['set_priority']; if ($ruleResult['set_help_topic'] !== null) $defaultTopicId = $ruleResult['set_help_topic']; // Deaktivierte Abteilungen bekommen keine neuen Tickets: auf aktive Standardabteilung ausweichen $activeDeptId = active_department_id($pdo, $defaultDeptId); if ($activeDeptId !== null && $activeDeptId !== $defaultDeptId) { $log[] = "[$label] → Abteilung {$defaultDeptId} ist deaktiviert, verwende Dept-ID {$activeDeptId}"; $defaultDeptId = $activeDeptId; } $stmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?'); $stmt->execute([$defaultPrioId]); $priority = $stmt->fetch(); $sla = calc_sla_dates($priority, null, $defaultDeptId, $defaultTopicId); $ticketNumber = generate_ticket_number($pdo); $now = db_now(); $pdo->prepare( 'INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, help_topic_id, priority_id, status, source, first_response_due_at, resolution_due_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)' )->execute([ $ticketNumber, $subject, $fromName, $fromEmail, $defaultDeptId, $defaultTopicId, $defaultPrioId, 'open', 'email', $sla['first_response_due_at'], $sla['resolution_due_at'], $now, $now, ]); $ticketId = (int) $pdo->lastInsertId(); $pdo->prepare( 'INSERT INTO ticket_email_sources (ticket_id, imap_account_id, message_uid, message_id, current_folder, created_at) VALUES (?,?,?,?,?,?)' )->execute([$ticketId, $account['id'], $messageUid ?: null, $messageId ?: null, $sourceFolderAfterFetch, db_now()]); $pdo->prepare( 'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)' )->execute([$ticketId, 'customer', $body, $now]); log_activity($pdo, $ticketId, null, 'Ticket per E-Mail erstellt'); $log[] = "[$label] Neues Ticket #{$ticketNumber} erstellt (von {$fromEmail})."; // Zuweisung: Ticket-Regel hat Vorrang, danach Auto-Zuweisung an letzten Bearbeiter $assignTo = $ruleResult['assign_staff']; // Einstellung der Abteilung ("Neue Tickets automatisch zuweisen") vor dem letzten Bearbeiter $deptAssignee = $assignTo === null ? department_auto_assignee($pdo, (int) $defaultDeptId) : null; if ($deptAssignee !== null) { $assignTo = $deptAssignee; log_activity($pdo, $ticketId, null, 'Automatisch zugewiesen (Einstellung der Abteilung)'); } elseif ($assignTo === null) { $assignTo = find_previous_assignee($pdo, $fromEmail); if ($assignTo !== null) { log_activity($pdo, $ticketId, null, "Automatisch zugewiesen (letzter Bearbeiter: Staff-ID {$assignTo})"); } } else { log_activity($pdo, $ticketId, null, 'Automatisch zugewiesen (Ticket-Regel)'); } if ($assignTo !== null) { $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$assignTo, $ticketId]); } foreach ($ruleResult['add_tags'] as $tagName) { apply_tag_to_ticket($pdo, $ticketId, $tagName); } if ($ruleResult['matched_rules']) { log_activity($pdo, $ticketId, null, 'Ticket-Regel angewendet: ' . implode(', ', $ruleResult['matched_rules'])); } apply_auto_reply($pdo, $ticketId, $ruleResult['auto_reply']); $closedByRule = apply_rule_close($pdo, $ticketId, $ruleResult); $deptStmt = $pdo->prepare('SELECT name, email FROM departments WHERE id = ?'); $deptStmt->execute([$defaultDeptId]); $dept = $deptStmt->fetch(); $ticketForNotify = [ 'ticket_number' => $ticketNumber, 'subject' => $subject, 'requester_name' => $fromName, 'requester_email' => $fromEmail, 'department_id' => $defaultDeptId, 'department_name' => $dept['name'] ?? null, ]; if (!$closedByRule) { notify_new_ticket_channels($ticketForNotify, $dept['email'] ?? null); notify_staff_new_ticket($ticketForNotify); webhook_dispatch($pdo, 'ticket.created', $ticketId); } handle_after_fetch($mbox, $emailNum, $afterFetch, $archiveFolder); $processed++; } imap_close($mbox); if ($verbose) { $log[] = "[$label] {$processed} E-Mail(s) verarbeitet."; } return ['processed' => $processed, 'log' => $log]; } if (php_sapi_name() === 'cli') { $result = run_fetch_emails(get_db(), true); foreach ($result['log'] as $line) { echo $line . "\n"; } }