OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

process-mail-queue.php

10 KB
<?php
// Verarbeitet die Ticket-Mail-Queue und sendet ausstehende E-Mails via SMTP.
//
// Dieses Script sollte per Cron alle 2 Minuten ausgeführt werden:
//   */2 * * * * /usr/bin/php /pfad/zu/bin/process-mail-queue.php >> /pfad/zu/storage/logs/mail-queue.log 2>&1

require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../bootstrap.php';

use OpenNIT\Core\Database;
use OpenNIT\Tickets\TicketManager;
use OpenNIT\Mail\MailTransportFactory;
use OpenNIT\Security\CredentialCrypto;

// Single-instance guard. Two parallel workers would each re-fetch the same
// pending rows; the per-row claim_token stops double-sending at the DB level,
// but flock() avoids the wasted SMTP handshakes / log spam in the first place.
$lockDir = dirname(__DIR__) . '/storage/locks';
if (!is_dir($lockDir)) {
    @mkdir($lockDir, 0775, true);
}
$lockFile   = $lockDir . '/process-mail-queue.lock';
$lockHandle = @fopen($lockFile, 'c');
if ($lockHandle === false) {
    fwrite(STDERR, date('Y-m-d H:i:s') . " - process-mail-queue: Lock-Datei nicht beschreibbar ({$lockFile}) — laufe trotzdem.\n");
} elseif (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
    fclose($lockHandle);
    exit(0);
}

// Umgebungsvariablen laden
$dotenv = Dotenv\Dotenv::createImmutable(dirname(__DIR__));
$dotenv->load();

// Konfiguration laden
$dbConfig      = require __DIR__ . '/../config/database.php';
$runtimeConfig = [];
$runtimeFile   = __DIR__ . '/../config/runtime.php';
if (file_exists($runtimeFile)) {
    $runtimeConfig = include $runtimeFile;
}

$timezone = $runtimeConfig['app']['timezone'] ?? 'Europe/Berlin';
date_default_timezone_set($timezone);

$db = new Database($dbConfig);

/**
 * Protokolliert einen Cronjob-Lauf in der Datenbank
 */
function trackCronjob(Database $db, string $jobName, callable $callback): void
{
    $startedAt = date('Y-m-d H:i:s');

    // Laufenden Eintrag erstellen
    try {
        $runId = $db->insert('cronjob_runs', [
            'job_name'   => $jobName,
            'started_at' => $startedAt,
            'status'     => 'running',
        ]);
    } catch (\Throwable $e) {
        // Tracking nicht verfügbar (z.B. Migration noch nicht ausgeführt) - Job trotzdem starten
        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";
    }
}

// Versand-Konfiguration (basic, Graph oder XOAUTH2 – über die Factory,
// inkl. Entschlüsselung der Secrets)
$smtpConfig = MailTransportFactory::ticketSenderConfig(
    $runtimeConfig,
    $_ENV['APP_KEY'] ?? '',
    dirname(__DIR__) . '/storage/cache'
);

if (empty($smtpConfig)) {
    trackCronjob($db, 'ticket_mail_queue', function () {
        return 'SMTP nicht konfiguriert – übersprungen.';
    });
    exit(0);
}

$appName    = $runtimeConfig['app']['name']    ?? 'IT-Dokumentation';
$appLogoRel = $runtimeConfig['app']['logo']    ?? '';
$appUrl     = $runtimeConfig['app']['url']     ?? '';
$logoUrl    = ($appLogoRel && $appUrl) ? rtrim($appUrl, '/') . $appLogoRel : '';

$manager = new TicketManager($db, $smtpConfig, $appName, $logoUrl, $appUrl);

const MAIL_QUEUE_MAX_RETRIES = 5;
const MAIL_QUEUE_BATCH_SIZE  = 50;
// Stale claims (worker killed mid-batch) are reclaimable after this window so
// pending mail isn't lost when a process dies. Must be larger than the
// realistic batch SMTP time but small enough to retry the same day.
const MAIL_QUEUE_STALE_MIN   = 10;

// Queue verarbeiten: atomarer Claim → SELECT der eigenen Items → Senden.
trackCronjob($db, 'ticket_mail_queue', function () use ($db, $manager) {
    // Per-Run Token. Random_bytes ist krypto-sicher und kollisionsfrei zwischen
    // parallelen Worker-Instanzen, was wir hier brauchen.
    $claimToken = bin2hex(random_bytes(16));

    // Atomic claim: update first N pending+retryable rows in one shot. Two
    // workers running this UPDATE simultaneously can't end up with the same
    // claim_token because each row's UPDATE takes a row lock; the loser's
    // WHERE clause re-evaluates and skips already-claimed rows. INTERVAL und
    // LIMIT als gecastete Integer-Literale, weil prepared statements diese
    // beiden Ausdrücke je nach MariaDB-Version nicht zuverlässig parametrisieren.
    $staleMin  = (int)MAIL_QUEUE_STALE_MIN;
    $batchSize = (int)MAIL_QUEUE_BATCH_SIZE;
    try {
        $db->execute(
            "UPDATE mail_queue
             SET claim_token = ?, processing_started_at = NOW()
             WHERE processed_at IS NULL
               AND (retry_count IS NULL OR retry_count < ?)
               AND (
                   processing_started_at IS NULL
                   OR processing_started_at < (NOW() - INTERVAL {$staleMin} MINUTE)
               )
             ORDER BY created_at ASC
             LIMIT {$batchSize}",
            [$claimToken, MAIL_QUEUE_MAX_RETRIES]
        );
    } catch (\Throwable $e) {
        // Wenn die Migration 006 (claim_token / processing_started_at) auf einem
        // bestehenden System noch nicht durchgelaufen ist, schalten wir den Cron
        // sauber ab statt den ganzen Lauf zu crashen. Migration läuft beim
        // nächsten Webrequest auto-applied; danach läuft auch der Cron wieder.
        if (str_contains($e->getMessage(), 'Unknown column')) {
            echo date('Y-m-d H:i:s') . " - Mail-Queue: Migration 006 noch nicht angewendet — überspringe Lauf.\n";
            return 0;
        }
        throw $e;
    }

    $items = $db->fetchAll(
        'SELECT * FROM mail_queue WHERE claim_token = ? ORDER BY created_at ASC',
        [$claimToken]
    );

    if (empty($items)) {
        return 0;
    }

    $sent   = 0;
    $failed = 0;

    foreach ($items as $item) {
        $ticketId  = (int)$item['ticket_id'];
        $eventType = (string)$item['event_type'];
        $extra     = $item['extra_data'] ? (json_decode($item['extra_data'], true) ?? []) : [];

        try {
            if ($eventType === 'watcher_reply') {
                $manager->sendWatcherReplyEmailDirect(
                    $ticketId,
                    $extra['watcher_name']  ?? '',
                    $extra['watcher_email'] ?? '',
                    $extra['reply_body']    ?? '',
                    is_array($extra['sent_to'] ?? null) ? $extra['sent_to'] : []
                );
            } elseif ($eventType === 'mention') {
                $manager->sendMentionEmailDirect(
                    $ticketId,
                    $extra['mention_email']     ?? '',
                    $extra['mention_user_name'] ?? '',
                    $extra['author_name']       ?? '',
                    $extra['reply_body']        ?? ''
                );
            } elseif ($eventType === 'agent_notify') {
                // Bearbeiter-Benachrichtigung: Empfänger ist der Mitarbeiter.
                $manager->sendAgentNotifyEmailDirect(
                    $ticketId,
                    $extra['agent_email']     ?? '',
                    $extra['agent_user_name'] ?? '',
                    $extra['notify_headline'] ?? '',
                    $extra['notify_message']  ?? ''
                );
            } else {
                $manager->sendEmailDirect($ticketId, $eventType, $extra);
            }
            // claim_token bewusst auf NULL — der Eintrag ist abgeschlossen.
            $db->update('mail_queue', [
                'processed_at' => date('Y-m-d H:i:s'),
                'claim_token'  => null,
            ], 'id = ?', [$item['id']]);
            $sent++;
        } catch (\Throwable $e) {
            $retryCount = (int)($item['retry_count'] ?? 0) + 1;
            $errorMsg   = substr($e->getMessage(), 0, 1000);

            error_log("process-mail-queue: Fehler bei Queue-Eintrag {$item['id']} (Ticket $ticketId / $eventType) Versuch $retryCount: $errorMsg");

            // Teilweise versendet: bereits versorgte Empfänger festhalten, damit
            // der nächste Versuch nur die offenen ansteuert.
            $extraUpdate = [];
            if ($e instanceof \OpenNIT\Tickets\MailDeliveryIncomplete && $e->sentTo !== []) {
                $extra['sent_to'] = array_values(array_unique($e->sentTo));
                $extraUpdate['extra_data'] = json_encode($extra);
            }

            if ($retryCount >= MAIL_QUEUE_MAX_RETRIES) {
                // Max retries reached: mark as permanently failed.
                $db->update('mail_queue', $extraUpdate + [
                    'processed_at' => date('Y-m-d H:i:s'),
                    'retry_count'  => $retryCount,
                    'last_error'   => $errorMsg,
                    'failed_at'    => date('Y-m-d H:i:s'),
                    'claim_token'  => null,
                ], 'id = ?', [$item['id']]);
                echo date('Y-m-d H:i:s') . " - Mail-Queue Eintrag {$item['id']} permanent fehlgeschlagen nach $retryCount Versuchen\n";
            } else {
                // Increment retry counter, leave processed_at NULL so it is retried next run.
                // claim_token MUSS auf NULL gesetzt werden — sonst greift unser
                // Claim-WHERE im nächsten Lauf nicht und der Eintrag bleibt liegen,
                // bis das Stale-Fenster abgelaufen ist.
                $db->update('mail_queue', $extraUpdate + [
                    'retry_count' => $retryCount,
                    'last_error'  => $errorMsg,
                    'failed_at'   => date('Y-m-d H:i:s'),
                    'claim_token' => null,
                ], 'id = ?', [$item['id']]);
            }
            $failed++;
        }
    }

    if ($failed > 0) {
        echo date('Y-m-d H:i:s') . " - Ticket-Mail-Queue: {$sent} gesendet, {$failed} fehlgeschlagen\n";
    }

    return $sent + $failed;
});