387 lines
20 KiB
PHP
387 lines
20 KiB
PHP
<?php
|
||
// Direkter Web-Zugriff (Browser/URL) wird geblockt. Ausnahmen: echter CLI-Cronjob
|
||
// sowie Einbindung per require_once aus dem authentifizierten Backend (staff/settings.php),
|
||
// das vor dem require_once die Konstante DESKHUB_CRON_RUNNER definiert.
|
||
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
|
||
/**
|
||
* cron/fetch_emails.php
|
||
*
|
||
* Ruft per IMAP neue E-Mails aus allen aktiven Postfächern ab (siehe Tabelle
|
||
* imap_accounts, verwaltet unter staff/imap_accounts.php) und erstellt Tickets
|
||
* oder hängt Antworten an. Nach dem Verarbeiten wird jede Mail je nach
|
||
* Postfach-Einstellung gelöscht, archiviert oder nur als gelesen markiert.
|
||
*
|
||
* Abteilungs-Zuordnung, in dieser Reihenfolge:
|
||
* 1. Postfach hat eine feste Standardabteilung hinterlegt → diese wird verwendet.
|
||
* 2. Sonst: Ist in der Tabelle email_addresses eine Zeile mit der Empfängeradresse
|
||
* der eingehenden Mail hinterlegt, wird das Ticket in deren Abteilung eingeordnet.
|
||
* 3. Sonst: globale Standardabteilung (Einstellungen → Allgemein).
|
||
* Ticket-Regeln können Abteilung/Priorität in jedem Fall zusätzlich überschreiben.
|
||
*
|
||
* Automatische Zuweisung:
|
||
* Wenn der Absender bereits ein offenes Ticket hat das einem Mitarbeiter
|
||
* zugewiesen ist, wird das neue Ticket demselben Mitarbeiter zugewiesen,
|
||
* sofern keine Ticket-Regel eine explizite Zuweisung vorgibt.
|
||
*
|
||
* Kann außerdem manuell aus dem Backend (Einstellungen → Cron-Jobs) ausgeführt werden.
|
||
*/
|
||
|
||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||
require_once __DIR__ . '/../includes/mail_text.php';
|
||
require_once __DIR__ . '/../includes/mail_threading.php';
|
||
|
||
/** Verarbeitet eine Mail nach dem Einlesen gemäß dem gewählten Modus */
|
||
function handle_after_fetch(mixed $mbox, int $emailNum, string $mode, string $folder): void
|
||
{
|
||
if ($mode === 'delete') {
|
||
imap_setflag_full($mbox, $emailNum, "\\Deleted");
|
||
imap_expunge($mbox);
|
||
} elseif ($mode === 'archive') {
|
||
imap_setflag_full($mbox, $emailNum, "\\Seen");
|
||
@imap_mail_move($mbox, (string) $emailNum, $folder);
|
||
imap_expunge($mbox);
|
||
} else {
|
||
imap_setflag_full($mbox, $emailNum, "\\Seen");
|
||
}
|
||
}
|
||
|
||
/** Ermittelt Abteilungs-ID (und optional Priorität) anhand der Empfänger-E-Mail */
|
||
function resolve_dept_from_recipient(PDO $pdo, mixed $header): ?array
|
||
{
|
||
// "To:" und "Cc:" auswerten
|
||
$recipients = array_merge(
|
||
(array) ($header->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? ──────────────────────────────────
|
||
// Zuordnung über die Nummer im Betreff, eine andere Nummer (früheres Ticketsystem) oder die Kopfzeilen In-Reply-To/References
|
||
$replyMatch = mail_find_ticket_for_reply($pdo, $subject, (string) @imap_fetchheader($mbox, $emailNum), (string) $fromEmail);
|
||
if ($replyMatch) {
|
||
$ticket = $replyMatch['ticket'];
|
||
$ticketNumber = (string) $ticket['ticket_number'];
|
||
$replyVia = ['subject' => '', 'legacy' => ' – zugeordnet über die Nummer im Betreff', 'header' => ' – zugeordnet über die Mail-Kopfzeilen'][$replyMatch['via']] ?? '';
|
||
|
||
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]);
|
||
$replyThreadId = (int) $pdo->lastInsertId();
|
||
$fetchPart = fn(string $no) => (string) imap_fetchbody($mbox, $emailNum, $no);
|
||
$htmlBody = ticket_mail_html_ready($pdo) && $structure ? mail_html_for_display($structure, $fetchPart) : null;
|
||
if ($htmlBody !== null) $pdo->prepare('UPDATE ticket_threads SET message_html = ? WHERE id = ?')->execute([$htmlBody, $replyThreadId]);
|
||
mail_store_attachments($pdo, $structure ?: null, $fetchPart, $replyThreadId, (int) $ticket['id'], $label, $log, false, $htmlBody !== null);
|
||
|
||
$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}){$replyVia}.";
|
||
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]);
|
||
$firstThreadId = (int) $pdo->lastInsertId();
|
||
$fetchPart = fn(string $no) => (string) imap_fetchbody($mbox, $emailNum, $no);
|
||
$htmlBody = ticket_mail_html_ready($pdo) && $structure ? mail_html_for_display($structure, $fetchPart) : null;
|
||
if ($htmlBody !== null) $pdo->prepare('UPDATE ticket_threads SET message_html = ? WHERE id = ?')->execute([$htmlBody, $firstThreadId]);
|
||
mail_store_attachments($pdo, $structure ?: null, $fetchPart, $firstThreadId, $ticketId, $label, $log, false, $htmlBody !== null);
|
||
|
||
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];
|
||
}
|
||
|
||
// Nur starten, wenn diese Datei selbst aufgerufen wird (Cronjob), nicht wenn sie von einer anderen Datei eingebunden wird (Abruf von Hand, Tests)
|
||
if (php_sapi_name() === 'cli' && isset($_SERVER['argv'][0]) && realpath((string) $_SERVER['argv'][0]) === realpath(__FILE__)) {
|
||
$result = run_fetch_emails(get_db(), true);
|
||
foreach ($result['log'] as $line) {
|
||
echo $line . "\n";
|
||
}
|
||
}
|