OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

fetch-tickets.php

13 KB
<?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;
});