Upload via GUI (258 Dateien)
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
<?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';
|
||||
|
||||
/** 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
|
||||
$body = imap_fetchbody($mbox, $emailNum, '1');
|
||||
$body = quoted_printable_decode($body);
|
||||
if (trim($body) === '') {
|
||||
$body = imap_fetchbody($mbox, $emailNum, '1.1') ?: '(kein Inhalt)';
|
||||
}
|
||||
$body = trim(strip_tags($body));
|
||||
|
||||
// ── 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']);
|
||||
|
||||
$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,
|
||||
];
|
||||
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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user