Files

1265 lines
47 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Wiki Newsletter - Thomas-Krenn Wiki
*
* Voraussetzungen:
* composer require phpmailer/phpmailer
*
* Aufruf im Browser:
* https://deine-domain.tld/wiki-newsletter.php
*
* Cronjob monatlich:
* 0 8 1 * * /usr/bin/php /pfad/wiki-newsletter.php --cron --token='DEIN_CRON_TOKEN'
*/
declare(strict_types=1);
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception as MailException;
session_start();
require_once __DIR__ . '/vendor/autoload.php';
const ADMIN_PASSWORD = 'CHANGE_ME_ADMIN_PASSWORD'; // Unbedingt ändern!
const CONFIG_FILE = __DIR__ . '/newsletter-config.json';
const STATE_FILE = __DIR__ . '/newsletter-state.json';
$defaultConfig = [
'wiki_url' => '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
{
$json = json_encode(
$data,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE
);
if ($json === false) {
throw new RuntimeException('Konfiguration konnte nicht kodiert werden.');
}
$written = @file_put_contents($file, $json, LOCK_EX);
if ($written === false) {
throw new RuntimeException('Konfiguration konnte nicht gespeichert werden: ' . $file . ' (Dateirechte prüfen).');
}
}
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('<?xml encoding="UTF-8">' . $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\b[^>]*>(.*?)<\/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('/<a\b[^>]*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[^>]*>(.*)<\/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 1 neuer Wiki-Artikel veröffentlicht.'
: 'In diesem Zeitraum wurden ' . $count . ' neue Wiki-Artikel veröffentlicht.';
[$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');
$articleRows = '';
if ($count === 0) {
$articleRows = '
<tr>
<td style="padding:0 26px 18px 26px;" class="mobile-padding">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="width:100%;border:1px solid #d9dfe7;background:#ffffff;">
<tr>
<td style="padding:18px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:22px;color:#2b3340;">
Für diesen Zeitraum wurden keine neuen Artikel gefunden.
</td>
</tr>
</table>
</td>
</tr>';
} else {
foreach ($articles as $article) {
$articleRows .= '
<tr>
<td style="padding:0 26px 16px 26px;" class="mobile-padding">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="width:100%;background:#ffffff;border:1px solid #e2e7ee;border-left:4px solid #ef7d00;">
<tr>
<td style="padding:14px 16px 0 16px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:17px;color:#5c6675;">
Thomas-Krenn-Wiki
</td>
</tr>
<tr>
<td style="padding:4px 16px 6px 16px;font-family:Arial,Helvetica,sans-serif;font-size:21px;line-height:28px;font-weight:700;color:#ef7d00;">
<a href="' . h($article['url']) . '" target="_blank" style="color:#ef7d00;text-decoration:none;">' . h($article['title']) . '</a>
</td>
</tr>
<tr>
<td style="padding:0 16px 12px 16px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#313844;">
Veröffentlicht am ' . h($article['date_de']) . ' von ' . h($article['author']) . '.
</td>
</tr>
<tr>
<td style="padding:0 16px 15px 16px;">
<table role="presentation" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ef7d00" style="background:#ef7d00;">
<a href="' . h($article['url']) . '" target="_blank" style="display:inline-block;padding:8px 14px;font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:16px;font-weight:700;color:#ffffff;text-decoration:none;">Jetzt Artikel lesen</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>';
}
}
$testBadge = $isTest
? '<tr>
<td style="padding:0 26px 12px 26px;" class="mobile-padding">
<table role="presentation" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#fff3cd" style="background:#fff3cd;border:1px solid #f3d38a;padding:6px 10px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:14px;font-weight:700;color:#7a5200;">
TESTVERSAND
</td>
</tr>
</table>
</td>
</tr>'
: '';
$unsubscribeFooter = '';
$unsubscribeUrl = trim((string)($config['unsubscribe_url'] ?? ''));
if ($unsubscribeUrl !== '' && $unsubscribeUrl !== '#' && filter_var($unsubscribeUrl, FILTER_VALIDATE_URL)) {
$unsubscribeFooter = '<br><a href="' . h($unsubscribeUrl) . '" target="_blank" style="color:#ef7d00;text-decoration:underline;">Abmelden</a>';
}
return '<!doctype html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Thomas-Krenn Wiki Newsletter</title>
<style type="text/css">
html, body { margin:0 !important; padding:0 !important; width:100% !important; background:#dadada; }
table, td { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; }
img { border:0; outline:none; text-decoration:none; -ms-interpolation-mode:bicubic; display:block; }
a { text-decoration:none; }
* { -ms-text-size-adjust:100%; -webkit-text-size-adjust:100%; }
.preheader { display:none !important; visibility:hidden; opacity:0; color:transparent; height:0; width:0; max-height:0; max-width:0; overflow:hidden; mso-hide:all; }
@media only screen and (max-width: 680px) {
.email-container { width:100% !important; }
.mobile-padding { padding-left:14px !important; padding-right:14px !important; }
.mobile-intro { font-size:16px !important; line-height:24px !important; }
}
</style>
</head>
<body style="margin:0;padding:0;background:#dadada;">
<div class="preheader">' . h($preheader) . '&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;</div>
<center role="article" aria-roledescription="email" lang="de" style="width:100%;background:#dadada;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" bgcolor="#dadada" style="width:100%;background:#dadada;">
<tr>
<td align="center" style="padding:12px 8px;">
<table role="presentation" width="690" border="0" cellspacing="0" cellpadding="0" class="email-container" style="width:100%;max-width:690px;background:#ffffff;">
<tr>
<td align="center" style="padding:8px 26px 14px 26px;">
<img src="https://www.thomas-krenn.com/res/pics/tk_logo_340px.png" width="170" alt="Thomas-Krenn Logo" style="display:block;margin:0 auto;border:0;outline:none;text-decoration:none;height:auto;">
</td>
</tr>
<tr>
<td style="padding:0 26px;" class="mobile-padding">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="width:100%;background:#10233a;">
<tr>
<td style="padding:18px 18px 20px 18px;font-family:Arial,Helvetica,sans-serif;">
<div style="font-size:13px;line-height:18px;font-weight:700;color:#ef7d00;letter-spacing:0.7px;">NEWSLETTER</div>
<div style="font-size:20px;line-height:26px;font-weight:700;color:#ffffff;margin-top:2px;">' . h($periodLabel) . '</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:14px 26px 12px 26px;background:#eef0f3;" class="mobile-padding">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td class="mobile-intro" style="font-family:Arial,Helvetica,sans-serif;font-size:17px;line-height:27px;color:#1e2530;">
Guten Tag,<br><br>
hier erhalten Sie Ihre aktuelle Übersicht aus dem Thomas-Krenn-Wiki, ' . $dateRangeText . '.<br>
' . $summaryText . '<br><br>
Viel Spaß beim Lesen wünscht<br>Ihr Team von Thomas-Krenn
</td>
</tr>
</table>
</td>
</tr>
' . $testBadge . '
' . $articleRows . '
<tr>
<td style="padding:4px 26px 20px 26px;" class="mobile-padding">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:22px;color:#2a303b;">
Haben Sie Fragen oder einen Themenwunsch?
<a href="' . h($replyHref) . '" style="color:#ef7d00;text-decoration:underline;">Kontaktieren Sie uns gerne direkt.</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="background:#eceef1;padding:20px 26px;text-align:center;font-family:Arial,Helvetica,sans-serif;">
<div style="font-size:12px;line-height:20px;color:#646d7b;">
Tel.: +49 8551 9150 0 | Fax: +49 8551 9150 55<br>
<a href="https://www.thomas-krenn.com/de/wiki/Hauptseite" target="_blank" style="color:#ef7d00;text-decoration:underline;">thomas-krenn.com</a><br><br>
Thomas-Krenn.AG | Speltenbach-Steinäcker 1 | D-94078 Freyung
' . $unsubscribeFooter . '
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</center>
</body>
</html>';
}
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();
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wiki Newsletter Admin</title>
<style>
:root {
--bg: #f5f7fa;
--card: #ffffff;
--text: #111827;
--muted: #6b7280;
--border: #e5e7eb;
--primary: #2563eb;
--primary-soft: #eff6ff;
--accent: #f97316;
--danger: #dc2626;
--ok: #16a34a;
--radius: 18px;
--shadow: 0 24px 60px rgba(15, 23, 42, .08);
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 40px 16px;
background: var(--bg);
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
color: var(--text);
}
.wrap { max-width: 1040px; margin: 0 auto; }
.hero {
padding: 34px 38px;
border-radius: 24px;
background: linear-gradient(135deg, rgba(37,99,235,.08), rgba(249,115,22,.08)), #fff;
border: 1px solid var(--border);
box-shadow: var(--shadow);
margin-bottom: 22px;
}
.hero h1 { margin: 0; font-size: 38px; letter-spacing: -.04em; }
.hero p { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.6; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 14px 36px rgba(15,23,42,.05);
padding: 24px;
}
.card.full { grid-column: 1 / -1; }
h2 { margin: 0 0 18px; font-size: 21px; letter-spacing: -.02em; }
label { display: block; margin: 14px 0 7px; color: #374151; font-size: 14px; font-weight: 700; }
input, select {
width: 100%;
height: 42px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: #fff;
color: var(--text);
font-size: 14px;
outline: none;
}
input:focus, select:focus { border-color: rgba(37,99,235,.55); box-shadow: 0 0 0 4px rgba(37,99,235,.08); }
.hint { margin: 7px 0 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 22px; }
button {
height: 42px;
padding: 0 16px;
border: 0;
border-radius: 12px;
background: var(--primary);
color: #fff;
font-weight: 800;
cursor: pointer;
}
button.secondary { background: #111827; }
button.light { background: var(--primary-soft); color: var(--primary); }
button.warn { background: var(--accent); }
.notice, .error, .warning {
margin-bottom: 18px;
padding: 14px 16px;
border-radius: 14px;
font-weight: 700;
line-height: 1.5;
}
.notice { background: #ecfdf5; color: #166534; border: 1px solid #bbf7d0; }
.error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
.warning { background: #fffbeb; color: #92400e; border: 1px solid #fde68a; }
code { padding: 2px 6px; border-radius: 7px; background: #f3f4f6; }
.preview { width: 100%; min-height: 760px; border: 1px solid var(--border); border-radius: 16px; background: #fff; }
.preview-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.preview-toolbar p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; max-width: 520px; }
.preview-source {
display: none;
}
.outlook-copy-buffer {
position: fixed;
left: -10000px;
top: 0;
width: 720px;
opacity: 0;
pointer-events: none;
}
.copy-status {
margin-top: 10px;
color: var(--ok);
font-size: 13px;
font-weight: 700;
min-height: 20px;
}
@media (max-width: 820px) { .grid { grid-template-columns: 1fr; } .hero { padding: 28px 24px; } }
</style>
</head>
<body>
<div class="wrap">
<section class="hero">
<h1>Wiki Newsletter Admin</h1>
<p>Holt die neuesten Artikel aus dem Thomas-Krenn-Wiki, erstellt daraus einen modernen Newsletter und versendet ihn über SMTP.</p>
</section>
<?php if (ADMIN_PASSWORD === 'CHANGE_ME_ADMIN_PASSWORD'): ?>
<div class="warning">Wichtig: Bitte oben im PHP-Code <code>ADMIN_PASSWORD</code> ändern, bevor die Seite öffentlich erreichbar ist.</div>
<?php endif; ?>
<?php if ($message !== ''): ?><div class="notice"><?= h($message) ?></div><?php endif; ?>
<?php if ($error !== ''): ?><div class="error"><?= h($error) ?></div><?php endif; ?>
<?php if (!$isLoggedIn): ?>
<form method="post" class="card">
<input type="hidden" name="action" value="login">
<h2>Anmeldung</h2>
<label>Admin-Passwort</label>
<input type="password" name="admin_password" autocomplete="current-password" required>
<div class="actions"><button type="submit">Einloggen</button></div>
</form>
<?php else: ?>
<form method="post">
<div class="grid">
<section class="card">
<h2>Wiki & Newsletter</h2>
<label>Wiki-Quelle</label>
<input name="wiki_url" value="<?= h($config['wiki_url']) ?>">
<p class="hint">Standard: Thomas-Krenn „Neueste Artikel“.</p>
<label>Zeitraum</label>
<select name="period_mode">
<option value="previous_month" <?= $config['period_mode'] === 'previous_month' ? 'selected' : '' ?>>Vorheriger Monat</option>
<option value="current_month" <?= $config['period_mode'] === 'current_month' ? 'selected' : '' ?>>Aktueller Monat</option>
</select>
<label>Max. Artikel im Newsletter</label>
<input type="number" name="article_limit" min="1" max="100" value="<?= h((string)$config['article_limit']) ?>">
<label>Max. Artikel von Wiki abrufen</label>
<input type="number" name="article_fetch_limit" min="1" max="500" value="<?= h((string)$config['article_fetch_limit']) ?>">
<label>Betreff</label>
<input name="subject" value="<?= h($config['subject']) ?>">
<p class="hint">Platzhalter: <code>{MONAT}</code>, <code>{ANZAHL}</code></p>
</section>
<section class="card">
<h2>SMTP Einstellungen</h2>
<label>SMTP Host</label>
<input name="smtp_host" value="<?= h($config['smtp_host']) ?>" placeholder="smtp.example.com">
<label>SMTP Port</label>
<input type="number" name="smtp_port" value="<?= h((string)$config['smtp_port']) ?>">
<label>Verschlüsselung</label>
<select name="smtp_encryption">
<option value="tls" <?= $config['smtp_encryption'] === 'tls' ? 'selected' : '' ?>>STARTTLS / Port 587</option>
<option value="ssl" <?= $config['smtp_encryption'] === 'ssl' ? 'selected' : '' ?>>SSL/TLS / Port 465</option>
<option value="none" <?= $config['smtp_encryption'] === 'none' ? 'selected' : '' ?>>Keine</option>
</select>
<label>SMTP Benutzer</label>
<input name="smtp_user" value="<?= h($config['smtp_user']) ?>" autocomplete="username">
<label>SMTP Passwort</label>
<input type="password" name="smtp_pass" value="" autocomplete="new-password" placeholder="Leer lassen = gespeichertes Passwort behalten">
<label>Absender-Mail</label>
<input name="from_email" value="<?= h($config['from_email']) ?>" placeholder="wiki@example.com">
<label>Absender-Name</label>
<input name="from_name" value="<?= h($config['from_name']) ?>">
<label>Reply-To / Kontakt-Mail</label>
<input name="reply_to" value="<?= h($config['reply_to']) ?>" placeholder="Optional">
</section>
<section class="card">
<h2>Empfänger</h2>
<label>Newsletter-Empfänger</label>
<input name="recipient_email" value="<?= h($config['recipient_email']) ?>" placeholder="ziel@example.com">
<label>Test-Empfänger</label>
<input name="test_recipient_email" value="<?= h($config['test_recipient_email']) ?>" placeholder="Optional, sonst Newsletter-Empfänger">
<label>Abmeldelink</label>
<input name="unsubscribe_url" value="<?= h($config['unsubscribe_url']) ?>" placeholder="https://example.com/newsletter/abmelden">
<p class="hint">Optional. Wird im Footer und als <code>List-Unsubscribe</code>-Header eingebunden.</p>
</section>
<section class="card">
<h2>Automatisierung</h2>
<label>Cron Token</label>
<input name="cron_token" value="<?= h($config['cron_token']) ?>">
<p class="hint">CLI-Cronjob:</p>
<p class="hint"><code>0 8 1 * * /usr/bin/php <?= h(__FILE__) ?> --cron --token='<?= h($config['cron_token']) ?>'</code></p>
<p class="hint">Web-Cronjob:</p>
<p class="hint"><code>0 8 1 * * /usr/bin/curl -fsS 'https://deine-domain.tld/wiki-newsletter.php?cron=<?= h($config['cron_token']) ?>' &gt;/dev/null</code></p>
</section>
<section class="card full">
<h2>Aktionen</h2>
<div class="actions">
<button type="submit" name="action" value="save">Einstellungen speichern</button>
<button type="submit" name="action" value="preview" class="light">Vorschau erzeugen</button>
<button type="submit" name="action" value="test_send" class="secondary">Testnewsletter senden</button>
<button type="submit" name="action" value="monthly_send" class="warn">Monatsnewsletter jetzt senden</button>
</div>
<p class="hint">Der Testnewsletter nutzt die aktuellsten Artikel ohne Monatsfilter. Der Monatsnewsletter nutzt den eingestellten Zeitraum und wird pro Zeitraum nur einmal versendet.</p>
</section>
</div>
</form>
<?php if ($previewHtml !== ''): ?>
<section class="card full" style="margin-top:18px;">
<h2>Vorschau</h2>
<div class="preview-toolbar">
<p>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.</p>
<div class="actions" style="margin-top:0;">
<button type="button" id="copy-outlook-rich" class="light">Für Outlook kopieren</button>
<button type="button" id="copy-outlook-html" class="secondary">HTML kopieren</button>
</div>
</div>
<iframe class="preview" id="preview-frame" srcdoc="<?= h($previewHtml) ?>"></iframe>
<textarea class="preview-source" id="preview-html-source" readonly><?= h($previewHtml) ?></textarea>
<script type="application/json" id="preview-clipboard-fragment"><?= json_encode($previewClipboardHtml, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE) ?></script>
<div class="outlook-copy-buffer" id="outlook-copy-buffer" aria-hidden="true"></div>
<div class="copy-status" id="copy-status" aria-live="polite"></div>
</section>
<script>
(function () {
const htmlSource = document.getElementById('preview-html-source');
const fragmentNode = document.getElementById('preview-clipboard-fragment');
const copyBuffer = document.getElementById('outlook-copy-buffer');
const previewFrame = document.getElementById('preview-frame');
const status = document.getElementById('copy-status');
const richButton = document.getElementById('copy-outlook-rich');
const htmlButton = document.getElementById('copy-outlook-html');
if (!htmlSource || !fragmentNode || !copyBuffer || !status || !richButton || !htmlButton) {
return;
}
let clipboardFragment = '';
try {
clipboardFragment = JSON.parse(fragmentNode.textContent || '""');
} catch (error) {
clipboardFragment = '';
}
function showStatus(message) {
status.textContent = message;
window.setTimeout(function () {
if (status.textContent === message) {
status.textContent = '';
}
}, 4000);
}
function stripHtml(html) {
const tmp = document.createElement('div');
tmp.innerHTML = html;
return (tmp.textContent || tmp.innerText || '').replace(/\s+/g, ' ').trim();
}
function buildClipboardHtml(fragment) {
return (
'<!doctype html>' +
'<html lang="de" xmlns="http://www.w3.org/1999/xhtml" ' +
'xmlns:v="urn:schemas-microsoft-com:vml" ' +
'xmlns:o="urn:schemas-microsoft-com:office:office">' +
'<head><meta charset="utf-8">' +
'<style type="text/css">' +
'body{margin:0;padding:0;background:#eef2f6;}' +
'table,td{border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}' +
'a{text-decoration:none;}' +
'</style></head><body>' + fragment + '</body></html>'
);
}
function getFragmentHtml() {
if (previewFrame && previewFrame.contentDocument && previewFrame.contentDocument.body) {
return previewFrame.contentDocument.body.innerHTML;
}
if (clipboardFragment) {
return clipboardFragment;
}
const fullHtml = htmlSource.value || '';
const match = fullHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
return match ? match[1] : fullHtml;
}
function copyViaClipboardEvent(fragment) {
copyBuffer.innerHTML = fragment;
const range = document.createRange();
range.selectNodeContents(copyBuffer);
const selection = window.getSelection();
if (!selection) {
return false;
}
selection.removeAllRanges();
selection.addRange(range);
let copied = false;
function onCopy(event) {
event.preventDefault();
event.clipboardData.setData('text/html', buildClipboardHtml(fragment));
event.clipboardData.setData('text/plain', stripHtml(fragment));
copied = true;
}
document.addEventListener('copy', onCopy);
copied = document.execCommand('copy');
document.removeEventListener('copy', onCopy);
selection.removeAllRanges();
copyBuffer.innerHTML = '';
return copied;
}
function copyFromPreviewFrame() {
if (!previewFrame || !previewFrame.contentWindow || !previewFrame.contentDocument) {
return false;
}
const frameDoc = previewFrame.contentDocument;
const frameWindow = previewFrame.contentWindow;
const frameSelection = frameWindow.getSelection();
if (!frameDoc.body || !frameSelection) {
return false;
}
const range = frameDoc.createRange();
range.selectNodeContents(frameDoc.body);
frameSelection.removeAllRanges();
frameSelection.addRange(range);
frameWindow.focus();
const copied = frameDoc.execCommand('copy');
frameSelection.removeAllRanges();
return copied;
}
async function copyForOutlook() {
const fragment = getFragmentHtml();
if (!fragment) {
showStatus('Keine Vorschau zum Kopieren gefunden.');
return;
}
if (copyFromPreviewFrame()) {
showStatus('Newsletter wurde aus der Vorschau für Outlook kopiert.');
return;
}
if (copyViaClipboardEvent(fragment)) {
showStatus('Newsletter wurde im Outlook-kompatiblen HTML-Format kopiert.');
return;
}
if (window.ClipboardItem && navigator.clipboard && navigator.clipboard.write) {
try {
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([buildClipboardHtml(fragment)], { type: 'text/html' }),
'text/plain': new Blob([stripHtml(fragment)], { type: 'text/plain' })
})
]);
showStatus('Newsletter wurde für Outlook kopiert.');
return;
} catch (error) {
// Fallback unten
}
}
htmlSource.value = buildClipboardHtml(fragment);
htmlSource.style.display = 'block';
htmlSource.focus();
htmlSource.select();
showStatus('Automatisches Kopieren nicht möglich. HTML ist markiert bitte mit Strg+C kopieren.');
}
async function copyText(value, successMessage) {
try {
await navigator.clipboard.writeText(value);
showStatus(successMessage);
} catch (error) {
htmlSource.style.display = 'block';
htmlSource.value = value;
htmlSource.focus();
htmlSource.select();
showStatus('HTML markiert. Bitte mit Strg+C kopieren.');
}
}
richButton.addEventListener('click', copyForOutlook);
htmlButton.addEventListener('click', function () {
copyText(htmlSource.value || buildClipboardHtml(getFragmentHtml()), 'HTML-Quelltext wurde kopiert.');
});
})();
</script>
<?php endif; ?>
<?php endif; ?>
</div>
</body>
</html>