Quelltext des aktuellen Standes
<?php
// Holt Tickets per IMAP und schließt abgelaufene Tickets automatisch
//
// Dieses Script sollte regelmaessig per Cron ausgefuehrt werden (z.B. alle 5 Minuten):
// */5 * * * * /usr/bin/php /pfad/zu/bin/fetch-tickets.php >> /pfad/zu/storage/logs/fetch-tickets.log 2>&1
//
// Es fuehrt zwei Aufgaben aus:
// 1. Holt neue E-Mails aus dem IMAP-Postfach und legt sie als Tickets an
// 2. Schliesst geloeste Tickets automatisch nach 3 Tagen
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../bootstrap.php';
use OpenNIT\Core\Database;
use OpenNIT\Directory\DirectorySync;
use OpenNIT\Tickets\TicketManager;
use OpenNIT\Tickets\ImapFetcher;
use OpenNIT\Mail\MailTransportFactory;
use OpenNIT\Security\CredentialCrypto;
// Single-instance guard. A slow IMAP run (server timeout, large batch) must
// not be overlapped by the next cron tick — otherwise both runs would fetch
// the same UNSEEN UIDs and one of them would crash on the mail_message_id
// UNIQUE constraint, leaving the message stuck in INBOX. flock(LOCK_NB) is
// released automatically when the process exits or is killed.
$lockDir = dirname(__DIR__) . '/storage/locks';
if (!is_dir($lockDir)) {
@mkdir($lockDir, 0775, true);
}
$lockFile = $lockDir . '/fetch-tickets.lock';
$lockHandle = @fopen($lockFile, 'c');
if ($lockHandle === false) {
fwrite(STDERR, date('Y-m-d H:i:s') . " - fetch-tickets: Lock-Datei nicht beschreibbar ({$lockFile}) — laufe trotzdem.\n");
} elseif (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
// Another run is in progress; exit silently so cron logs stay clean.
fclose($lockHandle);
exit(0);
}
// Umgebungsvariablen laden
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->load();
// Konfiguration laden
$dbConfig = require __DIR__ . '/../config/database.php';
$runtimeFile = __DIR__ . '/../config/runtime.php';
$runtimeConfig = file_exists($runtimeFile) ? include $runtimeFile : [];
// Zeitzone setzen
$timezone = $runtimeConfig['app']['timezone'] ?? 'Europe/Berlin';
date_default_timezone_set($timezone);
// Datenbankverbindung
$db = new Database($dbConfig);
// SMTP/Versand-Konfiguration (basic, Graph oder XOAUTH2 – über die Factory)
$appKey = $_ENV['APP_KEY'] ?? '';
$cacheDir = dirname(__DIR__) . '/storage/cache';
$smtpConfig = MailTransportFactory::ticketSenderConfig($runtimeConfig, $appKey, $cacheDir);
$appName = $runtimeConfig['app']['name'] ?? 'IT-Dokumentation';
$manager = new TicketManager($db, $smtpConfig, $appName);
/**
* Protokolliert einen Cronjob-Lauf in der Datenbank
*/
function trackCronjob(Database $db, string $jobName, callable $callback): void
{
$startedAt = date('Y-m-d H:i:s');
try {
$runId = $db->insert('cronjob_runs', [
'job_name' => $jobName,
'started_at' => $startedAt,
'status' => 'running',
]);
} catch (\Throwable $e) {
echo date('Y-m-d H:i:s') . " - [WARNUNG] Cronjob-Tracking fehlgeschlagen ({$jobName}): " . $e->getMessage() . "\n";
$callback();
return;
}
try {
$result = $callback();
$message = is_string($result) ? $result : null;
$count = is_int($result) ? $result : 0;
$db->update('cronjob_runs', [
'finished_at' => date('Y-m-d H:i:s'),
'status' => 'success',
'message' => $message,
'items_processed' => $count,
], 'id = ?', [$runId]);
if ($count > 0) {
echo date('Y-m-d H:i:s') . " - {$jobName}: {$count} verarbeitet\n";
}
} catch (\Throwable $e) {
$db->update('cronjob_runs', [
'finished_at' => date('Y-m-d H:i:s'),
'status' => 'error',
'message' => substr($e->getMessage(), 0, 1000),
], 'id = ?', [$runId]);
echo date('Y-m-d H:i:s') . " - {$jobName} FEHLER: " . $e->getMessage() . "\n";
}
}
// ----------------------------------------------------------------
// 1. IMAP-Abruf
// ----------------------------------------------------------------
$fetchConfigured = MailTransportFactory::isFetchConfigured($runtimeConfig);
$fetchConfig = MailTransportFactory::ticketFetcherConfig($runtimeConfig, $appKey, $cacheDir);
trackCronjob($db, 'ticket_fetch', function () use ($fetchConfigured, $fetchConfig, $manager, $db) {
if (!$fetchConfigured) {
echo date('Y-m-d H:i:s') . " - Mail-Abruf: Nicht konfiguriert, übersprungen.\n";
return 0;
}
if (($fetchConfig['auth_method'] ?? 'basic') === 'basic' && !ImapFetcher::isAvailable()) {
echo date('Y-m-d H:i:s') . " - IMAP: php-imap-Erweiterung nicht installiert.\n";
return 0;
}
$fetcher = MailTransportFactory::fetcherFromMailConfig($fetchConfig);
$fetcher->connect();
// Cap per-run batch so a giant backlog doesn't OOM or time out a single cron tick.
$messages = $fetcher->fetchUnseen(200);
$created = 0;
$appended = 0;
$skipped = 0;
// Load DirectorySync once before the message loop for email→person lookup
$cfgFile = dirname(__DIR__) . '/config/runtime.php';
$dirCfg = file_exists($cfgFile) ? require $cfgFile : [];
$dirSync = new DirectorySync($db, $dirCfg);
foreach ($messages as $msg) {
$existingId = $manager->extractTicketIdFromSubject($msg['subject']);
$messageId = trim((string)($msg['message_id'] ?? ''));
// Poison-loop guard: if a previous run already inserted this Message-ID
// but failed before the IMAP delete/move (or a parallel run beat us to
// it), the same UNSEEN UID would otherwise hit the mail_message_id
// UNIQUE constraint forever. Detect that here and just clean up IMAP.
if (!$existingId && $messageId !== '') {
$existingId = $manager->findIdByMailMessageId($messageId);
if ($existingId !== null) {
error_log("fetch-tickets: Nachricht {$msg['uid']} (Message-ID {$messageId}) bereits als Ticket #{$existingId} vorhanden — IMAP-seitig aufräumen.");
try {
$fetcher->processFetchedMessage($msg['uid']);
} catch (\Throwable $e) {
error_log("fetch-tickets: Aufräumen von UID {$msg['uid']} fehlgeschlagen: " . $e->getMessage());
}
$skipped++;
continue;
}
}
try {
// Antwort nur an ein OFFENES Ticket anhängen. Verweist die Mail auf
// ein geschlossenes/gelöschtes Ticket, fällt sie in den else-Zweig
// und wird als NEUES Ticket aufgenommen — niemals lautlos verworfen.
$ticket = $existingId ? $manager->getTicket($existingId) : null;
if ($existingId && $ticket && $ticket['status'] !== 'closed') {
{
// Re-resolve the directory person on every reply so the avatar
// gets a canonical display name even when the client sent an
// empty From-personal field. Falls back to the IMAP-supplied
// from_name, then to the bare email — same hierarchy as on
// ticket creation.
$replyAuthorName = trim((string)($msg['from_name'] ?? ''));
if ($replyAuthorName === '' && !empty($msg['from_email']) && isset($dirSync)) {
$person = $dirSync->findByEmail($msg['from_email']);
if ($person) {
$replyAuthorName = trim((string)(
$person['display_name']
?? trim(($person['first_name'] ?? '') . ' ' . ($person['last_name'] ?? ''))
));
}
}
if ($replyAuthorName === '') {
$replyAuthorName = (string)($msg['from_email'] ?? '');
}
// Nur bei Antworten an ein bestehendes Ticket die zitierte
// Historie abschneiden (steht schon im Ticket). NEUE Tickets /
// Weiterleitungen behalten unten ihren vollen Inhalt.
$replyBody = \OpenNIT\Tickets\ImapFetcher::stripEmailQuote(
(string)($msg['body'] ?? ''),
(bool)($msg['body_is_html'] ?? false)
);
// Wrap reply + attachments in a transaction
$db->transaction(function () use ($existingId, $msg, $manager, $replyAuthorName, $replyBody) {
$replyId = $manager->addCustomerReply($existingId, $replyBody, $replyAuthorName, (string)($msg['from_email'] ?? ''));
foreach ($msg['attachments'] ?? [] as $att) {
$manager->saveAttachment($existingId, $att['data'], $att['name'], $att['mime'], 'email', $replyId);
}
});
$appended++;
}
} else {
// E-Mail-Adresse im Verzeichnis nachschlagen (auto-match)
$requesterPersonId = null;
if ($dirSync && !empty($msg['from_email'])) {
$person = $dirSync->findByEmail($msg['from_email']);
if ($person) $requesterPersonId = (int)$person['id'];
}
// Wrap ticket creation + attachments in a transaction
$db->transaction(function () use ($msg, $manager, $requesterPersonId, &$created) {
$ticketId = $manager->createTicket([
'subject' => $msg['subject'] ?: '(kein Betreff)',
'body' => $msg['body'],
'from_email' => $msg['from_email'],
'from_name' => $msg['from_name'] ?: $msg['from_email'],
'mail_message_id' => $msg['message_id'] ?? null,
'requester_person_id' => $requesterPersonId,
// Bei Autoreply/Bounce/eigener Adresse keine Bestätigung (Loop-Schutz).
'suppress_ack' => !empty($msg['is_auto']),
]);
foreach ($msg['attachments'] ?? [] as $att) {
$manager->saveAttachment($ticketId, $att['data'], $att['name'], $att['mime']);
}
$created++;
});
}
// Only apply post-fetch action (delete or move-to-trash) after a successful DB write.
$fetcher->processFetchedMessage($msg['uid']);
} catch (\Throwable $e) {
$errMsg = $e->getMessage();
// Treat duplicate-key violations as "already processed" — the row
// exists from a parallel/earlier run, so leaving the IMAP message
// in INBOX would loop indefinitely. Safety net for the pre-check
// above (covers the race window between SELECT and INSERT).
// WICHTIG: Database maskiert SQL-Fehler als generisches "Datenbankfehler"
// und hängt die echte PDOException als previous an — daher über
// isDuplicateKeyError() prüfen statt im (maskierten) Text zu suchen.
if (\OpenNIT\Core\Database::isDuplicateKeyError($e)) {
error_log("fetch-tickets: Nachricht {$msg['uid']} kollidiert mit vorhandenem Ticket (UNIQUE) — als verarbeitet markieren.");
try {
$fetcher->processFetchedMessage($msg['uid']);
} catch (\Throwable $cleanup) {
error_log("fetch-tickets: Aufräumen nach UNIQUE-Konflikt fehlgeschlagen: " . $cleanup->getMessage());
}
$skipped++;
continue;
}
error_log("fetch-tickets: Fehler beim Verarbeiten von Nachricht {$msg['uid']}: " . $errMsg);
// Leave message in IMAP to retry next run (transient errors only).
}
}
$fetcher->disconnect();
$total = $created + $appended + $skipped;
if ($total > 0) {
echo date('Y-m-d H:i:s') . " - IMAP: {$created} neue Ticket(s), {$appended} Antwort(en), {$skipped} Duplikat(e) bereinigt.\n";
}
return $total;
});
// ----------------------------------------------------------------
// 2. Abgelaufene Tickets automatisch schließen
// ----------------------------------------------------------------
trackCronjob($db, 'ticket_auto_close', function () use ($manager) {
$closed = $manager->autoCloseExpired();
if ($closed > 0) {
echo date('Y-m-d H:i:s') . " - Auto-Close: {$closed} Ticket(s) geschlossen.\n";
}
return $closed;
});