Files
deskhub/DeskHub-Webseite/cron/fetch_emails.php
T
2026-10-30 21:49:53 +00:00

374 lines
18 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';
/** 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? ──────────────────────────────────
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";
}
}