Quelltext des aktuellen Standes
<?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;
});