'https://www.thomas-krenn.com/de/wiki/Neueste_Artikel', 'article_limit' => 20, 'article_fetch_limit' => 150, 'period_mode' => 'previous_month', // previous_month oder current_month 'smtp_host' => '', 'smtp_port' => 587, 'smtp_encryption' => 'tls', // tls, ssl, none 'smtp_user' => '', 'smtp_pass' => '', 'from_email' => '', 'from_name' => 'Wiki-Team', 'recipient_email' => '', 'test_recipient_email' => '', 'subject' => 'Wiki Newsletter - {MONAT}', 'firstname' => 'zusammen', 'reply_to' => '', 'unsubscribe_url' => '#', 'cron_token' => bin2hex(random_bytes(24)), ]; function h(?string $value): string { return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } function loadJsonFile(string $file, array $default): array { if (!is_file($file)) { return $default; } $json = file_get_contents($file); $data = json_decode((string)$json, true); if (!is_array($data)) { return $default; } return array_replace($default, $data); } function saveJsonFile(string $file, array $data): void { file_put_contents( $file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), LOCK_EX ); } function isAdminLoggedIn(): bool { if (ADMIN_PASSWORD === 'CHANGE_ME_ADMIN_PASSWORD') { return true; // Ersteinrichtung. Danach ADMIN_PASSWORD ändern. } return !empty($_SESSION['wiki_newsletter_admin']); } function requireAdmin(): void { if (!isAdminLoggedIn()) { http_response_code(403); exit('Nicht angemeldet.'); } } function cliArg(string $name): ?string { global $argv; foreach (($argv ?? []) as $arg) { if ($arg === '--' . $name) { return '1'; } if (str_starts_with($arg, '--' . $name . '=')) { return substr($arg, strlen('--' . $name . '=')); } } return null; } function fetchUrl(string $url): string { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 25, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_USERAGENT => 'Wiki-Newsletter/1.0 (+https://www.thomas-krenn.com/de/wiki/Hauptseite)', CURLOPT_HTTPHEADER => ['Accept: text/html,application/xhtml+xml'], ]); $html = curl_exec($ch); $error = curl_error($ch); $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($html === false || $code >= 400) { throw new RuntimeException('Wiki konnte nicht geladen werden. HTTP-Code: ' . $code . ' Fehler: ' . $error); } return (string)$html; } function absoluteWikiUrl(string $href): string { if (preg_match('~^https?://~i', $href)) { return $href; } if (str_starts_with($href, '//')) { return 'https:' . $href; } if (str_starts_with($href, '/')) { return 'https://www.thomas-krenn.com' . $href; } return 'https://www.thomas-krenn.com/de/wiki/' . ltrim($href, '/'); } function fetchLatestArticles(string $wikiUrl, int $limit = 150): array { $html = fetchUrl($wikiUrl); $articles = []; if (class_exists('DOMDocument') && class_exists('DOMXPath')) { libxml_use_internal_errors(true); $dom = new DOMDocument(); $dom->loadHTML('' . $html, LIBXML_NOWARNING | LIBXML_NOERROR); libxml_clear_errors(); $xpath = new DOMXPath($dom); $nodes = $xpath->query('//li'); foreach ($nodes as $li) { if (!$li instanceof DOMElement) { continue; } $text = trim(preg_replace('/\s+/', ' ', $li->textContent ?? '')); if (!preg_match('/\((\d{2}\.\d{2}\.\d{4})\)/', $text, $dateMatch)) { continue; } $links = $li->getElementsByTagName('a'); if ($links->length < 1) { continue; } $title = trim($links->item(0)->textContent ?? ''); $href = trim($links->item(0)->getAttribute('href')); if ($title === '' || $href === '') { continue; } $author = 'Unbekannt'; if ($links->length >= 2) { $author = trim($links->item(1)->textContent ?? 'Unbekannt'); } $date = DateTimeImmutable::createFromFormat('d.m.Y', $dateMatch[1]); if (!$date) { continue; } $articles[] = [ 'title' => $title, 'url' => absoluteWikiUrl($href), 'date' => $date->format('Y-m-d'), 'date_de' => $date->format('d.m.Y'), 'author' => $author, 'sourceText' => $text, ]; if (count($articles) >= $limit) { break; } } } else { // Fallback ohne ext-dom: robuster Regex-Parser für die Wiki-Liste if (preg_match_all('/]*>(.*?)<\/li>/is', $html, $liMatches)) { foreach ($liMatches[1] as $liHtml) { $text = trim(preg_replace('/\s+/', ' ', strip_tags($liHtml))); if (!preg_match('/\((\d{2}\.\d{2}\.\d{4})\)/', $text, $dateMatch)) { continue; } preg_match_all('/]*href=(["\'])(.*?)\1[^>]*>(.*?)<\/a>/is', $liHtml, $linkMatches, PREG_SET_ORDER); if (count($linkMatches) < 1) { continue; } $title = trim(html_entity_decode(strip_tags($linkMatches[0][3]), ENT_QUOTES | ENT_HTML5, 'UTF-8')); $href = trim(html_entity_decode($linkMatches[0][2], ENT_QUOTES | ENT_HTML5, 'UTF-8')); if ($title === '' || $href === '') { continue; } $author = 'Unbekannt'; if (count($linkMatches) >= 2) { $author = trim(html_entity_decode(strip_tags($linkMatches[1][3]), ENT_QUOTES | ENT_HTML5, 'UTF-8')); if ($author === '') { $author = 'Unbekannt'; } } $date = DateTimeImmutable::createFromFormat('d.m.Y', $dateMatch[1]); if (!$date) { continue; } $articles[] = [ 'title' => $title, 'url' => absoluteWikiUrl($href), 'date' => $date->format('Y-m-d'), 'date_de' => $date->format('d.m.Y'), 'author' => $author, 'sourceText' => $text, ]; if (count($articles) >= $limit) { break; } } } } return $articles; } function germanMonthName(int $month): string { $months = [ 1 => 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April', 5 => 'Mai', 6 => 'Juni', 7 => 'Juli', 8 => 'August', 9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember' ]; return $months[$month] ?? (string)$month; } function newsletterPeriod(string $mode): array { $now = new DateTimeImmutable('now'); if ($mode === 'current_month') { $start = $now->modify('first day of this month')->setTime(0, 0, 0); $end = $now->modify('tomorrow')->setTime(0, 0, 0); } else { $start = $now->modify('first day of previous month')->setTime(0, 0, 0); $end = $now->modify('first day of this month')->setTime(0, 0, 0); } $label = germanMonthName((int)$start->format('n')) . ' ' . $start->format('Y'); $key = $start->format('Y-m'); return [$start, $end, $label, $key]; } function filterArticlesForPeriod(array $articles, DateTimeImmutable $start, DateTimeImmutable $end, int $limit): array { $filtered = []; foreach ($articles as $article) { $date = new DateTimeImmutable($article['date'] . ' 00:00:00'); if ($date >= $start && $date < $end) { $filtered[] = $article; } if (count($filtered) >= $limit) { break; } } return $filtered; } function subjectFromTemplate(string $subject, string $periodLabel, int $count): string { return str_replace( ['{MONAT}', '{ANZAHL}'], [$periodLabel, (string)$count], $subject ); } function newsletterHtmlFragmentForClipboard(string $html): string { if (preg_match('/]*>(.*)<\/body>/is', $html, $matches)) { return trim($matches[1]); } return trim($html); } function buildNewsletterHtml(array $config, array $articles, string $periodLabel, bool $isTest = false): string { $count = count($articles); $preheader = $count === 1 ? '1 neuer Wiki-Artikel im ' . $periodLabel : $count . ' neue Wiki-Artikel im ' . $periodLabel; $replyEmail = trim((string)($config['reply_to'] ?: $config['from_email'])); $replyHref = 'mailto:' . $replyEmail; $summaryText = $count === 1 ? 'In diesem Zeitraum wurde ein neuer Wiki-Artikel veröffentlicht.' : 'In diesem Zeitraum wurden ' . $count . ' neue Wiki-Artikel veröffentlicht.'; $heroHeadline = 'Die wichtigsten Wiki-Updates fuer Ihr Team'; [$periodStart, $periodEnd] = newsletterPeriod((string)$config['period_mode']); $rangeEnd = $periodEnd; if (($config['period_mode'] ?? '') === 'current_month') { $rangeEnd = new DateTimeImmutable('today'); } $dateRangeText = 'vom ' . $periodStart->format('d.m') . ' bis ' . $rangeEnd->format('d.m'); $highlightsRows = ''; if ($count > 0) { $maxHighlights = min(3, $count); for ($i = 0; $i < $maxHighlights; $i++) { $highlightsRows .= ' ' . ($i + 1) . '. ' . h($articles[$i]['title']) . ' '; } } $highlightsSection = ''; if ($highlightsRows !== '') { $highlightsSection = '
Top Highlights
' . $highlightsRows . '
'; } $articleRows = ''; if ($count === 0) { $articleRows = '
Für diesen Zeitraum wurden keine neuen Artikel gefunden.
'; } else { foreach ($articles as $article) { $articleRows .= '
Wiki-Beitrag
' . h($article['title']) . '
Kompakte technische Einordnung und direkte Handlungsempfehlungen aus dem Thomas-Krenn-Wiki.
Veröffentlicht am ' . h($article['date_de']) . ' von ' . h($article['author']) . '.
Zum Beitrag
'; } } $testBadge = $isTest ? '
TESTVERSAND
' : ''; $unsubscribeFooter = ''; $unsubscribeUrl = trim((string)($config['unsubscribe_url'] ?? '')); if ($unsubscribeUrl !== '' && $unsubscribeUrl !== '#' && filter_var($unsubscribeUrl, FILTER_VALIDATE_URL)) { $unsubscribeFooter = '
Abmelden'; } return ' Thomas-Krenn Wiki Newsletter
' . h($preheader) . ' ‌ ‌ ‌
' . $highlightsSection . ' ' . $testBadge . ' ' . $articleRows . '
'; } function buildPlainText(array $config, array $articles, string $periodLabel, bool $isTest = false): string { $lines = []; if ($isTest) { $lines[] = '[TESTVERSAND]'; $lines[] = ''; } $lines[] = 'Wiki Newsletter'; $lines[] = ''; $lines[] = 'Servus,'; $lines[] = ''; $lines[] = 'das Wiki-Team hat im ' . $periodLabel . ' ' . count($articles) . ' Artikel für das Forum veröffentlicht!'; $lines[] = ''; if (empty($articles)) { $lines[] = 'Für diesen Zeitraum wurden keine neuen Artikel gefunden.'; } else { foreach ($articles as $article) { $lines[] = '- ' . $article['title'] . ' von ' . $article['author'] . ' | ' . $article['date_de']; $lines[] = ' ' . $article['url']; } } $lines[] = ''; $lines[] = 'Du hast Vorschläge, Ideen oder möchtest selber einen Artikel veröffentlichen?'; $lines[] = 'Dann schreib uns eine Mail: ' . ($config['reply_to'] ?: $config['from_email']); return implode("\n", $lines); } function validateConfig(array $config): void { $required = [ 'smtp_host' => 'SMTP-Host', 'smtp_port' => 'SMTP-Port', 'from_email' => 'Absender-Mail', 'recipient_email' => 'Empfänger-Mail', ]; foreach ($required as $key => $label) { if (trim((string)$config[$key]) === '') { throw new RuntimeException($label . ' fehlt.'); } } foreach (['from_email', 'recipient_email'] as $mailKey) { if (!filter_var($config[$mailKey], FILTER_VALIDATE_EMAIL)) { throw new RuntimeException($mailKey . ' ist keine gültige E-Mail-Adresse.'); } } if (!empty($config['test_recipient_email']) && !filter_var($config['test_recipient_email'], FILTER_VALIDATE_EMAIL)) { throw new RuntimeException('Test-Empfänger ist keine gültige E-Mail-Adresse.'); } if (!empty($config['reply_to']) && !filter_var($config['reply_to'], FILTER_VALIDATE_EMAIL)) { throw new RuntimeException('Reply-To ist keine gültige E-Mail-Adresse.'); } } function sendMail(array $config, string $to, string $subject, string $html, string $plain): void { validateConfig($config); $mail = new PHPMailer(true); $mail->CharSet = 'UTF-8'; $mail->Encoding = 'base64'; $mail->WordWrap = 78; $mail->XMailer = 'Thomas-Krenn Wiki Newsletter'; $mail->isSMTP(); $mail->Host = $config['smtp_host']; $mail->Port = (int)$config['smtp_port']; $mail->SMTPAuth = trim((string)$config['smtp_user']) !== '' || trim((string)$config['smtp_pass']) !== ''; if ($mail->SMTPAuth) { $mail->Username = $config['smtp_user']; $mail->Password = $config['smtp_pass']; } if ($config['smtp_encryption'] === 'tls') { $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; } elseif ($config['smtp_encryption'] === 'ssl') { $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; } else { $mail->SMTPSecure = false; $mail->SMTPAutoTLS = false; } $mail->setFrom($config['from_email'], $config['from_name'] ?: 'Wiki-Team'); if (!empty($config['reply_to'])) { $mail->addReplyTo($config['reply_to']); } $unsubscribeUrl = trim((string)($config['unsubscribe_url'] ?? '')); if ($unsubscribeUrl !== '' && $unsubscribeUrl !== '#' && filter_var($unsubscribeUrl, FILTER_VALIDATE_URL)) { $mail->addCustomHeader('List-Unsubscribe', '<' . $unsubscribeUrl . '>'); } $mail->addAddress($to); $mail->Subject = $subject; $mail->isHTML(true); $mail->Body = $html; $mail->AltBody = $plain; $mail->send(); } function newsletterData(array $config, bool $testMode = false): array { [$start, $end, $periodLabel, $periodKey] = newsletterPeriod($config['period_mode']); $allArticles = fetchLatestArticles($config['wiki_url'], (int)$config['article_fetch_limit']); // Testversand nutzt denselben Zeitraum wie der Liveversand. $articles = filterArticlesForPeriod($allArticles, $start, $end, (int)$config['article_limit']); if ($testMode) { $periodKey = 'test-' . date('Y-m-d-H-i-s'); } return [$articles, $periodLabel, $periodKey]; } function sendNewsletter(array $config, bool $testMode = false): string { [$articles, $periodLabel] = newsletterData($config, $testMode); $to = $testMode && !empty($config['test_recipient_email']) ? $config['test_recipient_email'] : $config['recipient_email']; $subject = subjectFromTemplate($config['subject'], $periodLabel, count($articles)); if ($testMode) { $subject = '[TEST] ' . $subject; } $html = buildNewsletterHtml($config, $articles, $periodLabel, $testMode); $plain = buildPlainText($config, $articles, $periodLabel, $testMode); sendMail($config, $to, $subject, $html, $plain); return 'Newsletter wurde an ' . $to . ' gesendet. Artikel: ' . count($articles); } function sendMonthlyIfDue(array $config): string { [$articles, $periodLabel, $periodKey] = newsletterData($config, false); $state = loadJsonFile(STATE_FILE, []); if (($state['last_sent_period'] ?? '') === $periodKey) { return 'Monatsnewsletter für ' . $periodLabel . ' wurde bereits gesendet. Kein erneuter Versand.'; } $subject = subjectFromTemplate($config['subject'], $periodLabel, count($articles)); $html = buildNewsletterHtml($config, $articles, $periodLabel, false); $plain = buildPlainText($config, $articles, $periodLabel, false); sendMail($config, $config['recipient_email'], $subject, $html, $plain); $state['last_sent_period'] = $periodKey; $state['last_sent_at'] = date('c'); $state['last_article_count'] = count($articles); saveJsonFile(STATE_FILE, $state); return 'Monatsnewsletter für ' . $periodLabel . ' wurde gesendet. Artikel: ' . count($articles); } function configFromPost(array $current): array { $fields = [ 'wiki_url', 'article_limit', 'article_fetch_limit', 'period_mode', 'smtp_host', 'smtp_port', 'smtp_encryption', 'smtp_user', 'from_email', 'from_name', 'recipient_email', 'test_recipient_email', 'subject', 'reply_to', 'unsubscribe_url', 'cron_token' ]; foreach ($fields as $field) { if (isset($_POST[$field])) { $current[$field] = trim((string)$_POST[$field]); } } if (isset($_POST['smtp_pass']) && trim((string)$_POST['smtp_pass']) !== '') { $current['smtp_pass'] = (string)$_POST['smtp_pass']; } $current['article_limit'] = max(1, min(100, (int)$current['article_limit'])); $current['article_fetch_limit'] = max($current['article_limit'], min(500, (int)$current['article_fetch_limit'])); $current['smtp_port'] = (int)$current['smtp_port']; if (!in_array($current['smtp_encryption'], ['tls', 'ssl', 'none'], true)) { $current['smtp_encryption'] = 'tls'; } if (!in_array($current['period_mode'], ['previous_month', 'current_month'], true)) { $current['period_mode'] = 'previous_month'; } if (trim($current['cron_token']) === '') { $current['cron_token'] = bin2hex(random_bytes(24)); } return $current; } $config = loadJsonFile(CONFIG_FILE, $defaultConfig); $message = ''; $error = ''; $previewHtml = ''; $previewClipboardHtml = ''; $flash = $_SESSION['wiki_newsletter_flash'] ?? null; if (is_array($flash)) { $message = (string)($flash['message'] ?? ''); $error = (string)($flash['error'] ?? ''); $previewHtml = (string)($flash['preview_html'] ?? ''); $previewClipboardHtml = (string)($flash['preview_clipboard_html'] ?? ''); unset($_SESSION['wiki_newsletter_flash']); } $isCron = (PHP_SAPI === 'cli' && cliArg('cron') !== null) || isset($_GET['cron']); if ($isCron) { $givenToken = PHP_SAPI === 'cli' ? (cliArg('token') ?? '') : (string)($_GET['cron'] ?? ''); if (!hash_equals((string)$config['cron_token'], (string)$givenToken)) { http_response_code(403); exit("Ungültiger Cron-Token.\n"); } try { echo sendMonthlyIfDue($config) . "\n"; } catch (Throwable $e) { http_response_code(500); echo 'Fehler: ' . $e->getMessage() . "\n"; } exit; } if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'login') { if (ADMIN_PASSWORD !== 'CHANGE_ME_ADMIN_PASSWORD' && hash_equals(ADMIN_PASSWORD, (string)($_POST['admin_password'] ?? ''))) { $_SESSION['wiki_newsletter_admin'] = true; header('Location: ' . strtok($_SERVER['REQUEST_URI'], '?')); exit; } $error = 'Falsches Admin-Passwort.'; } if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') !== 'login') { $redirectUrl = strtok($_SERVER['REQUEST_URI'], '?'); try { requireAdmin(); $config = configFromPost($config); saveJsonFile(CONFIG_FILE, $config); $action = $_POST['action'] ?? 'save'; if ($action === 'save') { $message = 'Einstellungen gespeichert.'; } elseif ($action === 'preview') { [$articles, $periodLabel] = newsletterData($config, true); $previewHtml = buildNewsletterHtml($config, $articles, $periodLabel, true); $previewClipboardHtml = newsletterHtmlFragmentForClipboard($previewHtml); $message = 'Vorschau wurde erstellt. Einstellungen wurden gespeichert.'; } elseif ($action === 'test_send') { $message = sendNewsletter($config, true); } elseif ($action === 'monthly_send') { $message = sendMonthlyIfDue($config); } $_SESSION['wiki_newsletter_flash'] = [ 'message' => $message, 'error' => '', 'preview_html' => $previewHtml, 'preview_clipboard_html' => $previewClipboardHtml, ]; } catch (Throwable $e) { $_SESSION['wiki_newsletter_flash'] = [ 'message' => '', 'error' => $e->getMessage(), 'preview_html' => '', 'preview_clipboard_html' => '', ]; } header('Location: ' . $redirectUrl); exit; } $isLoggedIn = isAdminLoggedIn(); ?> Wiki Newsletter Admin

Wiki Newsletter Admin

Holt die neuesten Artikel aus dem Thomas-Krenn-Wiki, erstellt daraus einen modernen Newsletter und versendet ihn über SMTP.

Wichtig: Bitte oben im PHP-Code ADMIN_PASSWORD ändern, bevor die Seite öffentlich erreichbar ist.

Anmeldung

Wiki & Newsletter

Standard: Thomas-Krenn „Neueste Artikel“.

Platzhalter: {MONAT}, {ANZAHL}

SMTP Einstellungen

Empfänger

Optional. Wird im Footer und als List-Unsubscribe-Header eingebunden.

Automatisierung

CLI-Cronjob:

0 8 1 * * /usr/bin/php --cron --token=''

Web-Cronjob:

0 8 1 * * /usr/bin/curl -fsS 'https://deine-domain.tld/wiki-newsletter.php?cron=' >/dev/null

Aktionen

Der Testnewsletter nutzt die aktuellsten Artikel ohne Monatsfilter. Der Monatsnewsletter nutzt den eingestellten Zeitraum und wird pro Zeitraum nur einmal versendet.

Vorschau

In Outlook: „Für Outlook kopieren“ klicken und in eine neue E-Mail einfügen (Strg+V). Der komplette Newsletter wird im Outlook-HTML-Format kopiert.