1228 lines
46 KiB
PHP
1228 lines
46 KiB
PHP
<?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
|
||
{
|
||
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('<?xml encoding="UTF-8">' . $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\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('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[^>]*>(.*)<\/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
|
||
? '<tr>
|
||
<td style="padding:0 24px 12px 24px;" class="mobile-padding">
|
||
<table role="presentation" border="0" cellspacing="0" cellpadding="0">
|
||
<tr>
|
||
<td bgcolor="#fff3cd" style="background-color:#fff3cd;border:1px solid #f4d58a;padding:6px 10px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:15px;font-weight:700;color:#7a5200;">
|
||
TESTVERSAND
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>'
|
||
: '';
|
||
|
||
$articleRows = '';
|
||
if ($count === 0) {
|
||
$articleRows = '
|
||
<tr>
|
||
<td style="padding:0 24px 18px 24px;" class="mobile-padding">
|
||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="width:100%;border:1px solid #d7d7d7;background:#ffffff;">
|
||
<tr>
|
||
<td style="padding:18px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#333333;">
|
||
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 24px 18px 24px;" class="mobile-padding">
|
||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="width:100%;background:#ffffff;">
|
||
<tr>
|
||
<td style="padding:0 0 5px 0;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:17px;color:#595959;">
|
||
Thomas-Krenn-Wiki
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:0 0 5px 0;font-family:Arial,Helvetica,sans-serif;font-size:22px;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 0 10px 0;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:22px;color:#2e2e2e;">
|
||
Veröffentlicht am ' . h($article['date_de']) . ' von ' . h($article['author']) . '.
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:0;">
|
||
<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;color:#ffffff;text-decoration:none;">Jetzt Artikel lesen</a>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</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:#e6e6e6; }
|
||
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: 640px) {
|
||
.email-container { width:100% !important; }
|
||
.mobile-padding { padding-left:14px !important; padding-right:14px !important; }
|
||
.mobile-body { font-size:15px !important; line-height:23px !important; }
|
||
.mobile-title { font-size:19px !important; line-height:25px !important; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body style="margin:0;padding:0;background:#e6e6e6;">
|
||
<div class="preheader">' . h($preheader) . ' ‌ ‌ ‌</div>
|
||
<center role="article" aria-roledescription="email" lang="de" style="width:100%;background:#e6e6e6;">
|
||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" bgcolor="#e6e6e6" style="width:100%;background:#e6e6e6;">
|
||
<tr>
|
||
<td align="center" style="padding:6px 8px;">
|
||
<table role="presentation" width="620" border="0" cellspacing="0" cellpadding="0" class="email-container" style="width:100%;max-width:620px;background:#ffffff;">
|
||
<tr>
|
||
<td align="center" style="padding:8px 20px 6px 20px;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:15px;color:#4e4e4e;">
|
||
Sollte diese E-Mail nicht einwandfrei dargestellt werden, klicken Sie bitte hier.
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td align="center" style="padding:4px 20px 10px 20px;font-family:Arial,Helvetica,sans-serif;font-size:30px;line-height:32px;font-weight:700;color:#555555;">
|
||
THOMAS<br>KRENN
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:0 24px;" class="mobile-padding">
|
||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="width:100%;background:#142232;">
|
||
<tr>
|
||
<td style="padding:16px 14px;font-family:Arial,Helvetica,sans-serif;color:#ffffff;">
|
||
<div style="font-size:12px;line-height:16px;font-weight:700;letter-spacing:0.6px;color:#ef7d00;">NEWSLETTER</div>
|
||
<div class="mobile-title" style="font-size:24px;line-height:30px;font-weight:700;color:#ffffff;">' . h($periodLabel) . '</div>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:10px 24px 8px 24px;background:#f3f3f3;" class="mobile-padding">
|
||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0">
|
||
<tr>
|
||
<td class="mobile-body" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#1f1f1f;">
|
||
Guten Tag,<br><br>
|
||
„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.
|
||
<br><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:0 24px 18px 24px;" class="mobile-padding">
|
||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="width:100%;">
|
||
<tr>
|
||
<td style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:22px;color:#2e2e2e;">
|
||
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:#ececec;padding:18px 24px;text-align:center;font-family:Arial,Helvetica,sans-serif;">
|
||
<div style="font-size:12px;line-height:18px;color:#666666;">
|
||
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']);
|
||
|
||
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();
|
||
?>
|
||
<!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']) ?>' >/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>
|