'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) {
$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('first day of next month')->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
? 'Im ' . h($periodLabel) . ' wurde 1 neuer Wiki-Artikel veröffentlicht.'
: 'Im ' . h($periodLabel) . ' wurden ' . $count . ' neue Wiki-Artikel veröffentlicht.';
$testBadge = $isTest
? '
|
|
'
: '';
$articleRows = '';
if ($count === 0) {
$articleRows = '
|
Für diesen Zeitraum wurden keine neuen Artikel gefunden.
|
|
';
} else {
foreach ($articles as $article) {
$articleRows .= '
|
|
';
}
}
$unsubscribeFooter = '';
$unsubscribeUrl = trim((string)($config['unsubscribe_url'] ?? ''));
if ($unsubscribeUrl !== '' && $unsubscribeUrl !== '#' && filter_var($unsubscribeUrl, FILTER_VALIDATE_URL)) {
$unsubscribeFooter = '
Abmelden';
}
return '
Thomas-Krenn Wiki Newsletter
|
Sollte diese E-Mail nicht einwandfrei dargestellt werden, klicken Sie bitte hier.
|
THOMAS KRENN
|
|
NEWSLETTER
' . h($periodLabel) . '
|
|
Guten Tag,
„oh mei“ sagen wir in Bayern, wenn mal was schiefgeht. In diesem Newsletter finden Sie aktuelles und kostenfreies IT-Know-how aus dem Thomas-Krenn-Wiki.
' . $summaryText . '
Viel Spaß beim Lesen wünscht Ihr Team von Thomas-Krenn
|
|
' . $testBadge . '
' . $articleRows . '
|
|
Tel.: +49 8551 9150 0 | Fax: +49 8551 9150 55
thomas-krenn.com
Thomas-Krenn.AG | Speltenbach-Steinäcker 1 | D-94078 Freyung
' . $unsubscribeFooter . '
|
|
';
}
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']);
if ($testMode) {
$articles = array_slice($allArticles, 0, (int)$config['article_limit']);
$periodLabel = 'aktuelle Übersicht';
$periodKey = 'test-' . date('Y-m-d-H-i-s');
} else {
$articles = filterArticlesForPeriod($allArticles, $start, $end, (int)$config['article_limit']);
}
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 = '';
$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') {
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);
}
} catch (Throwable $e) {
$error = $e->getMessage();
}
}
$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.
= h($message) ?>
= h($error) ?>