360 lines
17 KiB
PHP
360 lines
17 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'];
|
|
|
|
// 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']) ? 'open' : $ticket['status'];
|
|
$pdo->prepare('UPDATE tickets SET status=?, updated_at=? WHERE id=?')->execute([$newStatus, $now, $ticket['id']]);
|
|
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'];
|
|
if ($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";
|
|
}
|
|
}
|