Files
deskhub/DeskHub-Webseite/cron/fetch_emails.php
T
2026-11-04 07:56:07 +00:00

387 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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";
}
}